Path: blob/main/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Codicon } from '../../../../base/common/codicons.js';6import { Disposable } from '../../../../base/common/lifecycle.js';7import { autorun, derived } from '../../../../base/common/observable.js';8import { URI } from '../../../../base/common/uri.js';9import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js';10import { localize, localize2 } from '../../../../nls.js';11import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';12import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js';13import { ILogService } from '../../../../platform/log/common/log.js';14import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';15import { IAgentHostTerminalService } from '../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js';16import { ITerminalInstance, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js';17import { TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js';18import { IPathService } from '../../../../workbench/services/path/common/pathService.js';19import { Menus } from '../../../browser/menus.js';20import { isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js';21import { SessionsWelcomeVisibleContext, IsPhoneLayoutContext } from '../../../common/contextkeys.js';22import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';23import { isWorkspaceAgentSessionType, ISession } from '../../../services/sessions/common/session.js';24import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';25import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js';26import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';27import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';28import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js';29import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';30import { ITerminalProfileService, TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js';31import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js';3233const SessionsTerminalViewVisibleContext = new RawContextKey<boolean>('sessionsTerminalViewVisible', false);3435interface ISessionTerminalInfo {36/** The cwd to use for terminal matching/creation. For agent host sessions this is the unwrapped file URI. */37readonly cwd: URI;38/** When set, the terminal should be created on the agent host rather than locally. */39readonly agentHostCwd?: URI;40}4142/**43* Returns terminal info for the given session: worktree or repository path for44* workspace-backed agent sessions. Returns `undefined` for sessions without a45* workspace (e.g. Cloud), or when no path is available.46*/47function getSessionTerminalInfo(session: ISession | undefined): ISessionTerminalInfo | undefined {48if (!session || !isWorkspaceAgentSessionType(session.sessionType)) {49return undefined;50}51const repo = session.workspace.get()?.repositories[0];52const cwd = repo?.workingDirectory ?? repo?.uri;53if (!cwd) {54return undefined;55}56if (cwd.scheme === AGENT_HOST_SCHEME) {57return { cwd: fromAgentHostUri(cwd), agentHostCwd: cwd };58}59return { cwd };60}6162/**63* Manages terminal instances in the sessions window, ensuring:64* - A terminal exists for the active session's worktree (or repository if no worktree).65* - Terminals are shown/hidden based on their initial cwd matching the active path.66* - Terminals for an archived/removed session are closed only when no other67* live session still owns the same cwd (terminals are reused across sessions68* at the same worktree).69*/70export class SessionsTerminalContribution extends Disposable implements IWorkbenchContribution {7172static readonly ID = 'workbench.contrib.sessionsTerminal';7374private _activeKey: string | undefined;7576constructor(77@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,78@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,79@ITerminalService private readonly _terminalService: ITerminalService,80@IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService,81@ILogService private readonly _logService: ILogService,82@IPathService private readonly _pathService: IPathService,83@ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService,84@IViewsService viewsService: IViewsService,85@IContextKeyService contextKeyService: IContextKeyService,86) {87super();8889const profileOverride = derived(reader => {90const session = this._sessionsManagementService.activeSession.read(reader);91if (!session || session.providerId === LOCAL_AGENT_HOST_PROVIDER_ID) {92return; // no need to override local default profiles with the local AH93}9495const address = this._getSessionAgentHostAddress(session);96if (!address) {97return;98}99100const profiles = this._agentHostTerminalService.profiles.read(reader);101return profiles.find(p => p.address === address) ?? this._agentHostTerminalService.getProfileForConnection(address);102});103104this._register(autorun(reader => {105const profile = profileOverride.read(reader);106if (profile) {107reader.store.add(this._terminalProfileService.overrideDefaultProfile(108profile.extensionIdentifier, profile.profileId,109));110}111}));112113// Keep the default cwd in sync with the active session's working directory114// so that "New Terminal" uses it automatically.115// This is a little hacky but I don't see any better approach.116this._register(autorun(reader => {117const session = this._sessionsManagementService.activeSession.read(reader);118const info = getSessionTerminalInfo(session);119this._agentHostTerminalService.setDefaultCwd(info?.cwd);120}));121122// Track whether the terminal view is visible so the titlebar toggle123// button shows the correct checked state.124const terminalViewVisible = SessionsTerminalViewVisibleContext.bindTo(contextKeyService);125terminalViewVisible.set(viewsService.isViewVisible(TERMINAL_VIEW_ID));126this._register(viewsService.onDidChangeViewVisibility(e => {127if (e.id === TERMINAL_VIEW_ID) {128terminalViewVisible.set(e.visible);129}130}));131132// React to active session changes — use worktree/repo for background sessions, home dir otherwise133this._register(autorun(reader => {134const session = this._sessionsManagementService.activeSession.read(reader);135this._onActiveSessionChanged(session);136}));137138// Hide restored terminals from a previous window session that don't139// belong to the current active session. These arrive asynchronously140// during reconnection and would otherwise flash in the foreground.141this._register(this._terminalService.onDidCreateInstance(instance => {142if (instance.shellLaunchConfig.attachPersistentProcess && this._activeKey) {143instance.getInitialCwd().then(cwd => {144if (cwd.toLowerCase() !== this._activeKey) {145const availableInstance = this._getAvailableTerminal(instance, `hide restored terminal for ${cwd}`);146if (!availableInstance) {147return;148}149this._terminalService.moveToBackground(availableInstance);150this._logService.trace(`[SessionsTerminal] Hid restored terminal ${availableInstance.instanceId} (cwd: ${cwd})`);151}152});153}154}));155156// Close terminals for archived/removed sessions, but only when no other157// live session still owns that cwd. Terminals are reused across sessions158// at the same cwd, so a plain cwd match would kill a terminal still in use159// (e.g. the committed session from `onDidReplaceSession`).160// TODO: Consider removing the logic for trying to "delete/clean-up" terminal.161// Or consider tag terminals by sessionId + refcount instead of guarding here.162163this._register(this._sessionsManagementService.onDidChangeSessions(e => {164const archivedChanged = e.changed.filter(s => s.isArchived.get());165if (e.removed.length === 0 && archivedChanged.length === 0) {166return;167}168const removedIds = new Set(e.removed.map(s => s.sessionId));169const liveCwdKeys = new Set<string>();170for (const session of this._sessionsManagementService.getSessions()) {171if (removedIds.has(session.sessionId) || session.isArchived.get()) {172continue;173}174const info = getSessionTerminalInfo(session);175if (info) {176liveCwdKeys.add(info.cwd.fsPath.toLowerCase());177}178}179for (const session of [...e.removed, ...archivedChanged]) {180const info = getSessionTerminalInfo(session);181if (info && !liveCwdKeys.has(info.cwd.fsPath.toLowerCase())) {182this._closeTerminalsForPath(info.cwd.fsPath);183}184}185}));186}187188/**189* Ensures a terminal exists for the given cwd by scanning all terminal190* instances for a matching initial cwd. If none is found, creates a new191* one. Sets it as active and optionally focuses it.192*193* When {@link session} is provided and the session is backed by an agent194* host, the terminal is created on the agent host instead of locally.195*/196async ensureTerminal(cwd: URI, focus: boolean, session?: ISession): Promise<ITerminalInstance[]> {197const key = cwd.fsPath.toLowerCase();198let existing = await this._findTerminalsForKey(key);199200if (existing.length === 0) {201try {202const instance = await this._createTerminalForSession(cwd, session);203const createdInstance = this._getAvailableTerminal(instance, `activate created terminal for ${cwd.fsPath}`);204if (!createdInstance) {205return [];206}207existing = [createdInstance];208this._terminalService.setActiveInstance(createdInstance);209this._logService.trace(`[SessionsTerminal] Created terminal ${createdInstance.instanceId} for ${cwd.fsPath}`);210} catch (e) {211this._logService.trace(`[SessionsTerminal] Cannot create terminal for ${cwd.fsPath}: ${e}`);212return [];213}214}215216if (focus) {217await this._terminalService.focusActiveInstance();218}219220return existing;221}222223/**224* Creates a terminal for the given cwd. If the session is backed by an225* agent host, creates an agent host terminal; otherwise creates a local one.226*/227private async _createTerminalForSession(cwd: URI, session: ISession | undefined): Promise<ITerminalInstance> {228const address = session && this._getSessionAgentHostAddress(session);229if (address) {230const instance = await this._agentHostTerminalService.createTerminalForEntry(address, { cwd });231if (instance) {232return instance;233}234}235return this._terminalService.createTerminal({ config: { cwd } });236}237238/**239* Returns the agent host address for the given session's provider,240* or `undefined` if the session is not backed by an agent host.241*/242private _getSessionAgentHostAddress(session: ISession | undefined): string | undefined {243if (!session) {244return undefined;245}246const provider = this._sessionsProvidersService.getProvider(session.providerId);247if (!provider || !isAgentHostProvider(provider)) {248return undefined;249}250return provider.remoteAddress ?? '__local__';251}252253private async _onActiveSessionChanged(session: ISession | undefined): Promise<void> {254if (!session) {255return;256}257258const info = getSessionTerminalInfo(session);259const targetPath = info?.cwd ?? await this._pathService.userHome();260const targetKey = targetPath.fsPath.toLowerCase();261if (this._activeKey === targetKey) {262return;263}264this._activeKey = targetKey;265266const instances = await this.ensureTerminal(targetPath, false, info?.agentHostCwd ? session : undefined);267268// If the active key changed while we were awaiting, a newer call has269// taken over — skip the visibility update to avoid flicker.270if (this._activeKey !== targetKey) {271return;272}273await this._updateTerminalVisibility(targetKey, instances.map(instance => instance.instanceId));274}275276/**277* Finds the first terminal instance whose initial cwd (lower-cased) matches278* the given key.279*/280private async _findTerminalsForKey(key: string): Promise<ITerminalInstance[]> {281const result: ITerminalInstance[] = [];282for (const instance of this._terminalService.instances) {283try {284const cwd = await instance.getInitialCwd();285if (cwd.toLowerCase() === key) {286result.push(instance);287}288} catch {289// ignore terminals whose cwd cannot be resolved290}291}292return result;293}294295private _getAvailableTerminal(instance: ITerminalInstance, action: string): ITerminalInstance | undefined {296const currentInstance = this._terminalService.getInstanceFromId(instance.instanceId);297if (!currentInstance || currentInstance.isDisposed) {298this._logService.trace(`[SessionsTerminal] Cannot ${action}; terminal ${instance.instanceId} is no longer available`);299return undefined;300}301return currentInstance;302}303304/**305* Shows background terminals whose initial cwd matches the active key and306* hides foreground terminals whose initial cwd does not match.307*/308private async _updateTerminalVisibility(activeKey: string, forceForegroundTerminalIds: number[]): Promise<void> {309const toShow: ITerminalInstance[] = [];310const toHide: ITerminalInstance[] = [];311312for (const instance of [...this._terminalService.instances]) {313let cwd: string | undefined;314try {315cwd = (await instance.getInitialCwd()).toLowerCase();316} catch {317continue;318}319const currentInstance = this._getAvailableTerminal(instance, `update visibility for ${cwd}`);320if (!currentInstance) {321continue;322}323324const isForeground = this._terminalService.foregroundInstances.includes(currentInstance);325const isForceVisible = forceForegroundTerminalIds.includes(currentInstance.instanceId);326const belongsToActiveSession = cwd === activeKey;327if ((belongsToActiveSession || isForceVisible) && !isForeground) {328toShow.push(currentInstance);329} else if (!belongsToActiveSession && !isForceVisible && isForeground) {330toHide.push(currentInstance);331}332}333334for (const instance of toShow) {335const availableInstance = this._getAvailableTerminal(instance, 'show background terminal');336if (availableInstance) {337await this._terminalService.showBackgroundTerminal(availableInstance, true);338}339}340for (const instance of toHide) {341const availableInstance = this._getAvailableTerminal(instance, 'move terminal to background');342if (availableInstance) {343this._terminalService.moveToBackground(availableInstance);344}345}346347// Set the terminal with the most recent command as active348const foreground = this._terminalService.foregroundInstances;349let mostRecent: ITerminalInstance | undefined;350let mostRecentTimestamp = -1;351for (const instance of foreground) {352const cmdDetection = instance.capabilities.get(TerminalCapability.CommandDetection);353const lastCmd = cmdDetection?.commands.at(-1);354if (lastCmd && lastCmd.timestamp > mostRecentTimestamp) {355mostRecentTimestamp = lastCmd.timestamp;356mostRecent = instance;357}358}359if (mostRecent) {360this._terminalService.setActiveInstance(mostRecent);361}362}363364private async _closeTerminalsForPath(fsPath: string): Promise<void> {365const key = fsPath.toLowerCase();366for (const instance of [...this._terminalService.instances]) {367try {368const cwd = (await instance.getInitialCwd()).toLowerCase();369if (cwd === key) {370const availableInstance = this._getAvailableTerminal(instance, `close archived terminal for ${fsPath}`);371if (!availableInstance) {372continue;373}374this._terminalService.safeDisposeTerminal(availableInstance);375this._logService.trace(`[SessionsTerminal] Closed archived terminal ${availableInstance.instanceId}`);376}377} catch {378// ignore379}380}381}382383async dumpTracking(): Promise<void> {384console.log(`[SessionsTerminal] Active key: ${this._activeKey ?? '<none>'}`);385console.log('[SessionsTerminal] === All Terminals ===');386for (const instance of this._terminalService.instances) {387let cwd = '<unknown>';388try { cwd = await instance.getInitialCwd(); } catch { /* ignored */ }389const isForeground = this._terminalService.foregroundInstances.includes(instance);390console.log(` ${instance.instanceId} - ${cwd} - ${isForeground ? 'foreground' : 'background'}`);391}392}393394async showAllTerminals(): Promise<void> {395for (const instance of this._terminalService.instances) {396if (!this._terminalService.foregroundInstances.includes(instance)) {397await this._terminalService.showBackgroundTerminal(instance, true);398this._logService.trace(`[SessionsTerminal] Moved terminal ${instance.instanceId} to foreground`);399}400}401}402}403404registerWorkbenchContribution2(SessionsTerminalContribution.ID, SessionsTerminalContribution, WorkbenchPhase.AfterRestored);405406class OpenSessionInTerminalAction extends Action2 {407408constructor() {409super({410id: 'agentSession.openInTerminal',411title: localize2('openInTerminal', "Open Terminal"),412icon: Codicon.terminal,413toggled: {414condition: SessionsTerminalViewVisibleContext,415title: localize('hideTerminal', "Hide Terminal"),416},417menu: [{418id: Menus.TitleBarSessionMenu,419group: 'navigation',420order: 10,421when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()),422}]423});424}425426override async run(_accessor: ServicesAccessor): Promise<void> {427const telemetryService = _accessor.get(ITelemetryService);428logSessionsInteraction(telemetryService, 'openTerminal');429430const layoutService = _accessor.get(IWorkbenchLayoutService);431const viewsService = _accessor.get(IViewsService);432433// Toggle: if panel is visible and the terminal view is active, hide it.434// If the panel is visible but showing another view, open the terminal instead.435if (layoutService.isVisible(Parts.PANEL_PART)) {436if (viewsService.isViewVisible(TERMINAL_VIEW_ID)) {437layoutService.setPartHidden(true, Parts.PANEL_PART);438return;439}440}441442const contribution = getWorkbenchContribution<SessionsTerminalContribution>(SessionsTerminalContribution.ID);443const sessionsManagementService = _accessor.get(ISessionsManagementService);444const pathService = _accessor.get(IPathService);445446const activeSession = sessionsManagementService.activeSession.get();447const info = getSessionTerminalInfo(activeSession);448const cwd = info?.cwd ?? await pathService.userHome();449await contribution.ensureTerminal(cwd, true, info?.agentHostCwd ? activeSession : undefined);450viewsService.openView(TERMINAL_VIEW_ID);451}452}453454registerAction2(OpenSessionInTerminalAction);455456class DumpTerminalTrackingAction extends Action2 {457458constructor() {459super({460id: 'agentSession.dumpTerminalTracking',461title: localize2('dumpTerminalTracking', "Dump Terminal Tracking"),462f1: true,463});464}465466override async run(): Promise<void> {467const contribution = getWorkbenchContribution<SessionsTerminalContribution>(SessionsTerminalContribution.ID);468await contribution.dumpTracking();469}470}471472registerAction2(DumpTerminalTrackingAction);473474class ShowAllTerminalsAction extends Action2 {475476constructor() {477super({478id: 'agentSession.showAllTerminals',479title: localize2('showAllTerminals', "Show All Terminals"),480f1: true,481});482}483484override async run(): Promise<void> {485const contribution = getWorkbenchContribution<SessionsTerminalContribution>(SessionsTerminalContribution.ID);486await contribution.showAllTerminals();487}488}489490registerAction2(ShowAllTerminalsAction);491492493