terminal: support tab title templates for extension terminals (#296404)

* terminal: support tab title templates for extension terminals

* terminal: omit undefined tabTitle from profile quick pick

* refactor: rename terminal title properties to titleTemplate for consistency

* proposed api check.
This commit is contained in:
Peng Lyu
2026-02-20 18:58:54 -08:00
committed by GitHub
13 changed files with 136 additions and 29 deletions
@@ -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',
},
@@ -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 {
@@ -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<ITerminalInstance>(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);
}
@@ -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);
}
@@ -626,6 +626,7 @@ export interface TerminalLaunchConfig {
location?: TerminalLocation | { viewColumn: number; preserveFocus?: boolean } | { parentTerminal: ExtHostTerminalIdentifier } | { splitActiveTerminal: boolean };
isTransient?: boolean;
shellIntegrationNonce?: string;
titleTemplate?: string;
}
@@ -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<number> {
public async createExtensionTerminal(location?: TerminalLocation | vscode.TerminalEditorLocationOptions | vscode.TerminalSplitLocationOptions, internalOptions?: ITerminalInternalOptions, parentTerminal?: ExtHostTerminalIdentifier, iconPath?: TerminalIcon, color?: ThemeColor, shellIntegrationNonce?: string, titleTemplate?: string): Promise<number> {
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<vscode.TerminalLinkProvider> = new Set();
private readonly _completionProviders: Map<string, vscode.TerminalCompletionProvider<vscode.TerminalCompletionItem>> = new Map();
private readonly _profileProviders: Map<string, vscode.TerminalProfileProvider> = new Map();
private readonly _profileProviders: Map<string, { provider: vscode.TerminalProfileProvider; extension: IExtensionDescription }> = new Map();
private readonly _quickFixProviders: Map<string, vscode.TerminalQuickFixProvider> = new Map();
private readonly _terminalLinkCache: Map<number, Map<number, ICachedLinkEntry>> = new Map();
private readonly _terminalLinkCancellationSource: Map<number, CancellationTokenSource> = 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<void> {
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 {
@@ -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<ITerminalInstance, 'shellLaunchConfig' | 'shellType' | 'cwd' | 'fixedCols' | 'fixedRows' | 'initialCwd' | 'processName' | 'sequence' | 'userHome' | 'workspaceFolder' | 'staticTitle' | 'capabilities' | 'title' | 'description'>, 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 });
@@ -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
@@ -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;
}
@@ -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
@@ -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<ITerminalProfileService>('terminalProfileService');
@@ -693,6 +693,10 @@ export const terminalContributionsDescriptor: IExtensionPointDescriptor<ITermina
}
}]
},
titleTemplate: {
description: nls.localize('vscode.extension.contributes.terminal.profiles.titleTemplate', "A title template string for the terminal tab. Supports variables like $\{sequence}, $\{process}, $\{cwd}, etc. Overrides the default terminal.integrated.tabs.title setting for terminals created with this profile."),
type: 'string',
},
},
},
},
@@ -404,6 +404,12 @@ suite('Workbench - TerminalInstance', () => {
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 }));
+37
View File
@@ -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;
}
}