diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index c4cc744d7e4..1cbbd458e6d 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -449,6 +449,9 @@ const _allApiProposals = { terminalShellEnv: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.terminalShellEnv.d.ts', }, + terminalTitle: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.terminalTitle.d.ts', + }, testObserver: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testObserver.d.ts', }, diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 18c7915e8f2..dc7c596c346 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -671,6 +671,13 @@ export interface IShellLaunchConfig { * This allows extensions to control shell integration for terminals they create. */ shellIntegrationNonce?: string; + + /** + * A title template string that supports the same variables as the + * `terminal.integrated.tabs.title` setting. When set, this overrides the config-based + * title template for this terminal instance. + */ + titleTemplate?: string; } export interface ITerminalTabAction { @@ -686,6 +693,7 @@ export interface ICreateContributedTerminalProfileOptions { color?: string; location?: TerminalLocation | { viewColumn: number; preserveState?: boolean } | { splitActiveTerminal: boolean }; cwd?: string | URI; + titleTemplate?: string; } export enum TerminalLocation { @@ -713,6 +721,7 @@ export interface IShellLaunchConfigDto { isFeatureTerminal?: boolean; tabActions?: ITerminalTabAction[]; shellIntegrationEnvironmentReporting?: boolean; + titleTemplate?: string; } /** @@ -962,6 +971,7 @@ export interface ITerminalProfileContribution { id: string; icon?: URI | { light: URI; dark: URI } | string; color?: string; + titleTemplate?: string; } export interface IExtensionTerminalProfile extends ITerminalProfileContribution { diff --git a/src/vs/workbench/api/browser/mainThreadTerminalService.ts b/src/vs/workbench/api/browser/mainThreadTerminalService.ts index 8e85ad2cac5..ccf282f9cc8 100644 --- a/src/vs/workbench/api/browser/mainThreadTerminalService.ts +++ b/src/vs/workbench/api/browser/mainThreadTerminalService.ts @@ -173,7 +173,8 @@ export class MainThreadTerminalService extends Disposable implements MainThreadT isExtensionOwnedTerminal: launchConfig.isExtensionOwnedTerminal, useShellEnvironment: launchConfig.useShellEnvironment, isTransient: launchConfig.isTransient, - shellIntegrationNonce: launchConfig.shellIntegrationNonce + shellIntegrationNonce: launchConfig.shellIntegrationNonce, + titleTemplate: launchConfig.titleTemplate, }; const terminal = Promises.withAsyncBody(async r => { const terminal = await this._terminalService.createTerminal({ @@ -415,7 +416,8 @@ export class MainThreadTerminalService extends Disposable implements MainThreadT cwd: terminalInstance.shellLaunchConfig.cwd, env: terminalInstance.shellLaunchConfig.env, hideFromUser: terminalInstance.shellLaunchConfig.hideFromUser, - tabActions: terminalInstance.shellLaunchConfig.tabActions + tabActions: terminalInstance.shellLaunchConfig.tabActions, + titleTemplate: terminalInstance.shellLaunchConfig.titleTemplate }; this._proxy.$acceptTerminalOpened(terminalInstance.instanceId, extHostTerminalId, terminalInstance.title, shellLaunchConfigDto); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index f26f121a87d..1ed78327aa5 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -941,6 +941,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, createTerminal(nameOrOptions?: vscode.TerminalOptions | vscode.ExtensionTerminalOptions | string, shellPath?: string, shellArgs?: readonly string[] | string): vscode.Terminal { if (typeof nameOrOptions === 'object') { + if ('titleTemplate' in nameOrOptions && nameOrOptions.titleTemplate !== undefined) { + checkProposedApiEnabled(extension, 'terminalTitle'); + } if ('pty' in nameOrOptions) { return extHostTerminalService.createExtensionTerminal(nameOrOptions); } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 7164a0217ca..bf2039fe59c 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -626,6 +626,7 @@ export interface TerminalLaunchConfig { location?: TerminalLocation | { viewColumn: number; preserveFocus?: boolean } | { parentTerminal: ExtHostTerminalIdentifier } | { splitActiveTerminal: boolean }; isTransient?: boolean; shellIntegrationNonce?: string; + titleTemplate?: string; } diff --git a/src/vs/workbench/api/common/extHostTerminalService.ts b/src/vs/workbench/api/common/extHostTerminalService.ts index 225ed9c2231..52becdb9649 100644 --- a/src/vs/workbench/api/common/extHostTerminalService.ts +++ b/src/vs/workbench/api/common/extHostTerminalService.ts @@ -29,6 +29,7 @@ import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { ISerializedTerminalInstanceContext } from '../../contrib/terminal/common/terminal.js'; import { isWindows } from '../../../base/common/platform.js'; import { hasKey } from '../../../base/common/types.js'; +import { checkProposedApiEnabled } from '../../services/extensions/common/extensions.js'; export interface IExtHostTerminalService extends ExtHostTerminalServiceShape, IDisposable { @@ -193,11 +194,12 @@ export class ExtHostTerminal extends Disposable { location: internalOptions?.location || this._serializeParentTerminal(options.location, internalOptions?.resolvedExtHostIdentifier), isTransient: options.isTransient ?? undefined, shellIntegrationNonce: options.shellIntegrationNonce ?? undefined, + titleTemplate: options.titleTemplate ?? undefined, }); } - public async createExtensionTerminal(location?: TerminalLocation | vscode.TerminalEditorLocationOptions | vscode.TerminalSplitLocationOptions, internalOptions?: ITerminalInternalOptions, parentTerminal?: ExtHostTerminalIdentifier, iconPath?: TerminalIcon, color?: ThemeColor, shellIntegrationNonce?: string): Promise { + public async createExtensionTerminal(location?: TerminalLocation | vscode.TerminalEditorLocationOptions | vscode.TerminalSplitLocationOptions, internalOptions?: ITerminalInternalOptions, parentTerminal?: ExtHostTerminalIdentifier, iconPath?: TerminalIcon, color?: ThemeColor, shellIntegrationNonce?: string, titleTemplate?: string): Promise { if (typeof this._id !== 'string') { throw new Error('Terminal has already been created'); } @@ -209,6 +211,7 @@ export class ExtHostTerminal extends Disposable { location: internalOptions?.location || this._serializeParentTerminal(location, parentTerminal), isTransient: true, shellIntegrationNonce: shellIntegrationNonce ?? undefined, + titleTemplate: titleTemplate ?? undefined, }); // At this point, the id has been set via `$acceptTerminalOpened` if (typeof this._id === 'string') { @@ -423,7 +426,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I private readonly _bufferer: TerminalDataBufferer; private readonly _linkProviders: Set = new Set(); private readonly _completionProviders: Map> = new Map(); - private readonly _profileProviders: Map = new Map(); + private readonly _profileProviders: Map = new Map(); private readonly _quickFixProviders: Map = new Map(); private readonly _terminalLinkCache: Map> = new Map(); private readonly _terminalLinkCancellationSource: Map = new Map(); @@ -513,7 +516,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I public createExtensionTerminal(options: vscode.ExtensionTerminalOptions, internalOptions?: ITerminalInternalOptions): vscode.Terminal { const terminal = new ExtHostTerminal(this._proxy, generateUuid(), options, options.name); const p = new ExtHostPseudoterminal(options.pty); - terminal.createExtensionTerminal(options.location, internalOptions, this._serializeParentTerminal(options, internalOptions).resolvedExtHostIdentifier, asTerminalIcon(options.iconPath), asTerminalColor(options.color), options.shellIntegrationNonce).then(id => { + terminal.createExtensionTerminal(options.location, internalOptions, this._serializeParentTerminal(options, internalOptions).resolvedExtHostIdentifier, asTerminalIcon(options.iconPath), asTerminalColor(options.color), options.shellIntegrationNonce, options.titleTemplate).then(id => { const disposable = this._setupExtHostProcessListeners(id, p); this._terminalProcessDisposables[id] = disposable; }); @@ -634,7 +637,8 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I shellArgs: shellLaunchConfigDto.args, cwd: typeof shellLaunchConfigDto.cwd === 'string' ? shellLaunchConfigDto.cwd : URI.revive(shellLaunchConfigDto.cwd), env: shellLaunchConfigDto.env, - hideFromUser: shellLaunchConfigDto.hideFromUser + hideFromUser: shellLaunchConfigDto.hideFromUser, + titleTemplate: shellLaunchConfigDto.titleTemplate }; const terminal = new ExtHostTerminal(this._proxy, id, creationOptions, name); this._terminals.push(terminal); @@ -749,7 +753,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I if (this._profileProviders.has(id)) { throw new Error(`Terminal profile provider "${id}" already registered`); } - this._profileProviders.set(id, provider); + this._profileProviders.set(id, { provider, extension }); this._proxy.$registerProfileProvider(id, extension.identifier.value); return new VSCodeDisposable(() => { this._profileProviders.delete(id); @@ -842,7 +846,11 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I public async $createContributedProfileTerminal(id: string, options: ICreateContributedTerminalProfileOptions): Promise { const token = new CancellationTokenSource().token; - let profile = await this._profileProviders.get(id)?.provideTerminalProfile(token); + const profileProviderData = this._profileProviders.get(id); + if (!profileProviderData) { + throw new Error(`No terminal profile provider registered for id "${id}"`); + } + let profile = await profileProviderData.provider.provideTerminalProfile(token); if (token.isCancellationRequested) { return; } @@ -854,11 +862,19 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I throw new Error(`No terminal profile options provided for id "${id}"`); } - if (hasKey(profile.options, { pty: true })) { - this.createExtensionTerminal(profile.options, options); + if (profile.options.titleTemplate !== undefined) { + checkProposedApiEnabled(profileProviderData.extension, 'terminalTitle'); + } + + const profileOptions = options.titleTemplate && !profile.options.titleTemplate + ? { ...profile.options, titleTemplate: options.titleTemplate } + : profile.options; + + if (hasKey(profileOptions, { pty: true })) { + this.createExtensionTerminal(profileOptions, options); return; } - this.createTerminalFromOptions(profile.options, options); + this.createTerminalFromOptions(profileOptions, options); } public registerLinkProvider(provider: vscode.TerminalLinkProvider): vscode.Disposable { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index a9fd028211a..f78a087fbd8 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -518,7 +518,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // When a custom pty is used set the name immediately so it gets passed over to the exthost // and is available when Pseudoterminal.open fires. - if (this.shellLaunchConfig.customPtyImplementation) { + if (this.shellLaunchConfig.customPtyImplementation && !this._shellLaunchConfig.titleTemplate) { this._setTitle(this._shellLaunchConfig.name, TitleEventSource.Api); } @@ -1471,7 +1471,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } })); } - if (this._shellLaunchConfig.name) { + if (this._shellLaunchConfig.name && !this._shellLaunchConfig.titleTemplate) { this._setTitle(this._shellLaunchConfig.name, TitleEventSource.Api); } else { // Listen to xterm.js' sequence title change event, trigger this async to ensure @@ -1483,7 +1483,13 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } }); }); - this._setTitle(this._shellLaunchConfig.executable, TitleEventSource.Process); + // When a title template is provided, use the name as the initial process name + // so it can be referenced via ${process} in the template + if (this._shellLaunchConfig.titleTemplate && this._shellLaunchConfig.name) { + this._setTitle(this._shellLaunchConfig.name, TitleEventSource.Process); + } else { + this._setTitle(this._shellLaunchConfig.executable, TitleEventSource.Process); + } } })); this._register(processManager.onProcessExit(exitCode => this._onProcessExit(exitCode))); @@ -2633,7 +2639,8 @@ export class TerminalLabelComputer extends Disposable { } refreshLabel(instance: Pick, reset?: boolean): void { - this._title = this.computeLabel(instance, this._terminalConfigurationService.config.tabs.title, TerminalLabelType.Title, reset); + const titleTemplate = instance.shellLaunchConfig.titleTemplate ?? this._terminalConfigurationService.config.tabs.title; + this._title = this.computeLabel(instance, titleTemplate, TerminalLabelType.Title, reset); this._description = this.computeLabel(instance, this._terminalConfigurationService.config.tabs.description, TerminalLabelType.Description); if (this._title !== instance.title || this._description !== instance.description || reset) { this._onDidChangeLabel.fire({ title: this._title, description: this._description }); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProfileQuickpick.ts b/src/vs/workbench/contrib/terminal/browser/terminalProfileQuickpick.ts index 1f06f04d3f0..28318f5403d 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProfileQuickpick.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProfileQuickpick.ts @@ -77,16 +77,29 @@ export class TerminalProfileQuickpick { await this._configurationService.updateValue(defaultProfileKey, result.profileName, ConfigurationTarget.USER); } else if (type === 'createInstance') { if (hasKey(result.profile, { id: true })) { + const config: { + extensionIdentifier: string; + id: string; + title: string; + titleTemplate?: string; + options: { + icon: IExtensionTerminalProfile['icon']; + color: IExtensionTerminalProfile['color']; + }; + } = { + extensionIdentifier: result.profile.extensionIdentifier, + id: result.profile.id, + title: result.profile.title, + options: { + icon: result.profile.icon, + color: result.profile.color, + } + }; + if (result.profile.titleTemplate !== undefined) { + config.titleTemplate = result.profile.titleTemplate; + } return { - config: { - extensionIdentifier: result.profile.extensionIdentifier, - id: result.profile.id, - title: result.profile.title, - options: { - icon: result.profile.icon, - color: result.profile.color, - } - }, + config, keyMods: result.keyMods }; } else { @@ -177,7 +190,8 @@ export class TerminalProfileQuickpick { title: contributed.title, icon: contributed.icon, id: contributed.id, - color: contributed.color + color: contributed.color, + titleTemplate: contributed.titleTemplate }, profileName: contributed.title, iconClasses diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts b/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts index 0cbef05b922..598715341de 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts @@ -233,7 +233,8 @@ export class TerminalProfileService extends Disposable implements ITerminalProfi icon: args.options.icon, id: args.id, title: args.title, - color: args.options.color + color: args.options.color, + titleTemplate: args.titleTemplate }; (profilesConfig as { [key: string]: ITerminalProfileObject })[args.title] = newProfile; @@ -271,5 +272,6 @@ function contributedProfilesEqual(one: IExtensionTerminalProfile, other: IExtens one.color === other.color && one.icon === other.icon && one.id === other.id && - one.title === other.title; + one.title === other.title && + one.titleTemplate === other.titleTemplate; } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index a01da1a710c..6107058afad 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -256,7 +256,8 @@ export class TerminalService extends Disposable implements ITerminalService { await this.createContributedTerminalProfile(result.config.extensionIdentifier, result.config.id, { icon: result.config.options?.icon, color: result.config.options?.color, - location: !!(keyMods?.alt && activeInstance) ? { splitActiveTerminal: true } : defaultLocation + location: !!(keyMods?.alt && activeInstance) ? { splitActiveTerminal: true } : defaultLocation, + titleTemplate: result.config.titleTemplate, }); return; } else if (result.config && hasKey(result.config, { profileName: true })) { @@ -1032,6 +1033,7 @@ export class TerminalService extends Disposable implements ITerminalService { color: contributedProfile.color, location, cwd: shellLaunchConfig.cwd, + titleTemplate: contributedProfile.titleTemplate, }); const instanceHost = resolvedLocation === TerminalLocation.Editor ? this._terminalEditorService : this._terminalGroupService; // TODO@meganrogge: This returns undefined in the remote & web smoke tests but the function diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 12fa676c314..c5951189a80 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -67,7 +67,7 @@ export interface ITerminalProfileResolverService { export const ShellIntegrationExitCode = 633; export interface IRegisterContributedProfileArgs { - extensionIdentifier: string; id: string; title: string; options: ICreateContributedTerminalProfileOptions; + extensionIdentifier: string; id: string; title: string; options: ICreateContributedTerminalProfileOptions; titleTemplate?: string; } export const ITerminalProfileService = createDecorator('terminalProfileService'); @@ -693,6 +693,10 @@ export const terminalContributionsDescriptor: IExtensionPointDescriptor { strictEqual(terminalLabelComputer.title, 'my-title'); strictEqual(terminalLabelComputer.description, 'folder'); }); + test('should use shellLaunchConfig.titleTemplate as template when set', () => { + const terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' - ', title: '${process}', description: '${cwd}' } } } }); + terminalLabelComputer.refreshLabel(createInstance({ capabilities, sequence: 'my-sequence', processName: 'zsh', shellLaunchConfig: { titleTemplate: '${sequence}' } })); + strictEqual(terminalLabelComputer.title, 'my-sequence'); + strictEqual(terminalLabelComputer.description, 'cwd'); + }); test('should provide cwdFolder for all cwds only when in multi-root', () => { const terminalLabelComputer = createLabelComputer({ terminal: { integrated: { tabs: { separator: ' ~ ', title: '${process}${separator}${cwdFolder}', description: '${cwdFolder}' } } } }); terminalLabelComputer.refreshLabel(createInstance({ capabilities, processName: 'process', workspaceFolder: { uri: URI.from({ scheme: Schemas.file, path: ROOT_1 }) } as IWorkspaceFolder, cwd: ROOT_1 })); diff --git a/src/vscode-dts/vscode.proposed.terminalTitle.d.ts b/src/vscode-dts/vscode.proposed.terminalTitle.d.ts new file mode 100644 index 00000000000..7833a644a49 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.terminalTitle.d.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/xyz + + export interface TerminalOptions { + /** + * A title template string for the terminal tab. This supports the same variables as the + * `terminal.integrated.tabs.title` setting, such as `${sequence}`, `${process}`, `${cwd}`, + * `${cwdFolder}`, `${workspaceFolderName}`, etc. When set, this overrides the default title + * behavior (which uses the `name` as a static title) and instead uses the template for + * dynamic title resolution. + * + * For example, setting `titleTemplate` to `"${sequence}"` allows the terminal's escape sequence + * title to be used as the tab title. + */ + titleTemplate?: string; + } + + export interface ExtensionTerminalOptions { + /** + * A title template string for the terminal tab. This supports the same variables as the + * `terminal.integrated.tabs.title` setting, such as `${sequence}`, `${process}`, `${cwd}`, + * `${cwdFolder}`, `${workspaceFolderName}`, etc. When set, this overrides the default title + * behavior (which uses the `name` as a static title) and instead uses the template for + * dynamic title resolution. + * + * For example, setting `titleTemplate` to `"${sequence}"` allows the terminal's escape sequence + * title to be used as the tab title. + */ + titleTemplate?: string; + } +}