Merge pull request #294703 from microsoft/tyriar/osc_notifications

Terminal notification support
This commit is contained in:
Daniel Imms
2026-02-13 14:00:21 -08:00
committed by GitHub
8 changed files with 868 additions and 1 deletions

View File

@@ -834,6 +834,7 @@ export interface ITerminalInstance extends IBaseTerminalInstance {
readonly fixedCols?: number;
readonly fixedRows?: number;
readonly domElement: HTMLElement;
readonly isVisible: boolean;
readonly icon?: TerminalIcon;
readonly color?: string;
readonly reconnectionProperties?: IReconnectionProperties;

View File

@@ -150,7 +150,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
private _latestXtermParseData: number = 0;
private _isExiting: boolean;
private _hadFocusOnExit: boolean;
private _isVisible: boolean;
private _exitCode: number | undefined;
private _exitReason: TerminalExitReason | undefined;
private _skipTerminalCommands: string[];
@@ -218,6 +217,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
this._shellLaunchConfig.waitOnExit = value;
}
private _isVisible: boolean;
get isVisible(): boolean { return this._isVisible; }
private _targetRef: ImmortalReference<TerminalLocation | undefined> = new ImmortalReference(undefined);
get targetRef(): IReference<TerminalLocation | undefined> { return this._targetRef; }

View File

@@ -24,6 +24,7 @@ import '../terminalContrib/commandGuide/browser/terminal.commandGuide.contributi
import '../terminalContrib/history/browser/terminal.history.contribution.js';
import '../terminalContrib/inlineHint/browser/terminal.initialHint.contribution.js';
import '../terminalContrib/links/browser/terminal.links.contribution.js';
import '../terminalContrib/notification/browser/terminal.notification.contribution.js';
import '../terminalContrib/zoom/browser/terminal.zoom.contribution.js';
import '../terminalContrib/stickyScroll/browser/terminal.stickyScroll.contribution.js';
import '../terminalContrib/quickAccess/browser/terminal.quickAccess.contribution.js';

View File

@@ -14,6 +14,7 @@ import { terminalCommandGuideConfiguration } from '../terminalContrib/commandGui
import { TerminalDeveloperCommandId } from '../terminalContrib/developer/common/terminal.developer.js';
import { defaultTerminalFindCommandToSkipShell } from '../terminalContrib/find/common/terminal.find.js';
import { defaultTerminalHistoryCommandsToSkipShell, terminalHistoryConfiguration } from '../terminalContrib/history/common/terminal.history.js';
import { terminalOscNotificationsConfiguration } from '../terminalContrib/notification/common/terminalNotificationConfiguration.js';
import { TerminalStickyScrollSettingId, terminalStickyScrollConfiguration } from '../terminalContrib/stickyScroll/common/terminalStickyScrollConfiguration.js';
import { defaultTerminalSuggestCommandsToSkipShell } from '../terminalContrib/suggest/common/terminal.suggest.js';
import { TerminalSuggestSettingId, terminalSuggestConfiguration } from '../terminalContrib/suggest/common/terminalSuggestConfiguration.js';
@@ -65,6 +66,7 @@ export const terminalContribConfiguration: IConfigurationNode['properties'] = {
...terminalInitialHintConfiguration,
...terminalCommandGuideConfiguration,
...terminalHistoryConfiguration,
...terminalOscNotificationsConfiguration,
...terminalStickyScrollConfiguration,
...terminalSuggestConfiguration,
...terminalTypeAheadConfiguration,

View File

@@ -0,0 +1,51 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { Terminal as RawXtermTerminal } from '@xterm/xterm';
import * as dom from '../../../../../base/browser/dom.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
import { ITerminalLogService } from '../../../../../platform/terminal/common/terminal.js';
import type { ITerminalContribution, ITerminalInstance, IXtermTerminal } from '../../../terminal/browser/terminal.js';
import { registerTerminalContribution, type ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js';
import { TerminalOscNotificationsSettingId } from '../common/terminalNotificationConfiguration.js';
import { TerminalNotificationHandler } from './terminalNotificationHandler.js';
class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution {
static readonly ID = 'terminal.oscNotifications';
private readonly _handler: TerminalNotificationHandler;
constructor(
private readonly _ctx: ITerminalContributionContext,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@INotificationService private readonly _notificationService: INotificationService,
@ITerminalLogService private readonly _logService: ITerminalLogService,
) {
super();
this._handler = this._register(new TerminalNotificationHandler({
isEnabled: () => this._configurationService.getValue<boolean>(TerminalOscNotificationsSettingId.EnableNotifications) === true,
isWindowFocused: () => dom.getActiveWindow().document.hasFocus(),
isTerminalVisible: () => this._ctx.instance.isVisible,
focusTerminal: () => this._ctx.instance.focus(true),
notify: notification => this._notificationService.notify(notification),
updateEnableNotifications: value => this._configurationService.updateValue(TerminalOscNotificationsSettingId.EnableNotifications, value),
logWarn: message => this._logService.warn(message),
writeToProcess: data => { void this._ctx.instance.sendText(data, false); }
}));
}
xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void {
this._register(xterm.raw.parser.registerOscHandler(99, data => this._handler.handleSequence(data)));
}
}
registerTerminalContribution(TerminalOscNotificationsContribution.ID, TerminalOscNotificationsContribution);
export function getTerminalOscNotifications(instance: ITerminalInstance): TerminalOscNotificationsContribution | null {
return instance.getContribution<TerminalOscNotificationsContribution>(TerminalOscNotificationsContribution.ID);
}

View File

@@ -0,0 +1,528 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Action, IAction } from '../../../../../base/common/actions.js';
import { disposableTimeout } from '../../../../../base/common/async.js';
import { decodeBase64 } from '../../../../../base/common/buffer.js';
import { Disposable, DisposableStore, type IDisposable } from '../../../../../base/common/lifecycle.js';
import { localize } from '../../../../../nls.js';
import { NotificationPriority, Severity, type INotification, type INotificationHandle } from '../../../../../platform/notification/common/notification.js';
const enum Osc99PayloadType {
Title = 'title',
Body = 'body',
Buttons = 'buttons',
Close = 'close',
Query = '?',
Alive = 'alive'
}
type Osc99Occasion = 'always' | 'unfocused' | 'invisible';
type Osc99CloseReason = 'button' | 'secondary' | 'auto' | 'protocol';
interface IOsc99NotificationState {
id: string | undefined;
title: string;
body: string;
buttonsPayload: string;
focusOnActivate: boolean;
reportOnActivate: boolean;
reportOnClose: boolean;
urgency: number | undefined;
autoCloseMs: number | undefined;
occasion: Osc99Occasion | undefined;
}
interface IOsc99ActiveNotification {
id: string | undefined;
handle: INotificationHandle;
actionStore: DisposableStore;
autoCloseDisposable: IDisposable | undefined;
reportOnActivate: boolean;
reportOnClose: boolean;
focusOnActivate: boolean;
closeReason: Osc99CloseReason | undefined;
}
export interface IOsc99NotificationHost {
isEnabled(): boolean;
isWindowFocused(): boolean;
isTerminalVisible(): boolean;
focusTerminal(): void;
notify(notification: INotification): INotificationHandle;
updateEnableNotifications(value: boolean): Promise<void>;
logWarn(message: string): void;
writeToProcess(data: string): void;
}
export class TerminalNotificationHandler extends Disposable {
private readonly _osc99PendingNotifications = new Map<string, IOsc99NotificationState>();
private _osc99PendingAnonymous: IOsc99NotificationState | undefined;
private readonly _osc99ActiveNotifications = new Map<string, IOsc99ActiveNotification>();
constructor(
private readonly _host: IOsc99NotificationHost
) {
super();
}
handleSequence(data: string): boolean {
const { metadata, payload } = this._splitOsc99Data(data);
const metadataEntries = this._parseOsc99Metadata(metadata);
const payloadTypes = metadataEntries.get('p');
const rawPayloadType = payloadTypes && payloadTypes.length > 0 ? payloadTypes[payloadTypes.length - 1] : undefined;
const payloadType = rawPayloadType && rawPayloadType.length > 0 ? rawPayloadType : Osc99PayloadType.Title;
const id = this._sanitizeOsc99Id(metadataEntries.get('i')?.[0]);
if (!this._host.isEnabled()) {
return true;
}
switch (payloadType) {
case Osc99PayloadType.Query:
this._sendOsc99QueryResponse(id);
return true;
case Osc99PayloadType.Alive:
this._sendOsc99AliveResponse(id);
return true;
case Osc99PayloadType.Close:
this._closeOsc99Notification(id);
return true;
}
const state = this._getOrCreateOsc99State(id);
this._updateOsc99StateFromMetadata(state, metadataEntries);
const isEncoded = metadataEntries.get('e')?.[0] === '1';
const payloadText = this._decodeOsc99Payload(payload, isEncoded);
const isDone = metadataEntries.get('d')?.[0] !== '0';
switch (payloadType) {
case Osc99PayloadType.Title:
state.title += payloadText;
break;
case Osc99PayloadType.Body:
state.body += payloadText;
break;
case Osc99PayloadType.Buttons:
state.buttonsPayload += payloadText;
break;
default:
return true;
}
if (!isDone) {
return true;
}
if (!this._shouldHonorOsc99Occasion(state.occasion)) {
this._clearOsc99PendingState(id);
return true;
}
if (this._showOsc99Notification(state)) {
this._clearOsc99PendingState(id);
}
return true;
}
private _splitOsc99Data(data: string): { metadata: string; payload: string } {
const separatorIndex = data.indexOf(';');
if (separatorIndex === -1) {
return { metadata: data, payload: '' };
}
return {
metadata: data.substring(0, separatorIndex),
payload: data.substring(separatorIndex + 1)
};
}
private _parseOsc99Metadata(metadata: string): Map<string, string[]> {
const result = new Map<string, string[]>();
if (!metadata) {
return result;
}
for (const entry of metadata.split(':')) {
if (!entry) {
continue;
}
const separatorIndex = entry.indexOf('=');
if (separatorIndex === -1) {
continue;
}
const key = entry.substring(0, separatorIndex);
const value = entry.substring(separatorIndex + 1);
if (!key) {
continue;
}
let values = result.get(key);
if (!values) {
values = [];
result.set(key, values);
}
values.push(value);
}
return result;
}
private _decodeOsc99Payload(payload: string, isEncoded: boolean): string {
if (!isEncoded) {
return payload;
}
try {
return decodeBase64(payload).toString();
} catch {
this._host.logWarn('Failed to decode OSC 99 payload');
return '';
}
}
private _sanitizeOsc99Id(rawId: string | undefined): string | undefined {
if (!rawId) {
return undefined;
}
const sanitized = rawId.replace(/[^a-zA-Z0-9_\-+.]/g, '');
return sanitized.length > 0 ? sanitized : undefined;
}
private _sanitizeOsc99MessageText(text: string): string {
return text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
}
private _getOrCreateOsc99State(id: string | undefined): IOsc99NotificationState {
if (!id) {
if (!this._osc99PendingAnonymous) {
this._osc99PendingAnonymous = this._createOsc99State(undefined);
}
return this._osc99PendingAnonymous;
}
let state = this._osc99PendingNotifications.get(id);
if (!state) {
state = this._createOsc99State(id);
this._osc99PendingNotifications.set(id, state);
}
return state;
}
private _createOsc99State(id: string | undefined): IOsc99NotificationState {
return {
id,
title: '',
body: '',
buttonsPayload: '',
focusOnActivate: true,
reportOnActivate: false,
reportOnClose: false,
urgency: undefined,
autoCloseMs: undefined,
occasion: undefined
};
}
private _clearOsc99PendingState(id: string | undefined): void {
if (!id) {
this._osc99PendingAnonymous = undefined;
return;
}
this._osc99PendingNotifications.delete(id);
}
private _updateOsc99StateFromMetadata(state: IOsc99NotificationState, metadataEntries: Map<string, string[]>): void {
const actionValues = metadataEntries.get('a');
const actionValue = actionValues && actionValues.length > 0 ? actionValues[actionValues.length - 1] : undefined;
if (actionValue !== undefined) {
const actions = this._parseOsc99Actions(actionValue);
state.focusOnActivate = actions.focusOnActivate;
state.reportOnActivate = actions.reportOnActivate;
}
const closeValues = metadataEntries.get('c');
const closeValue = closeValues && closeValues.length > 0 ? closeValues[closeValues.length - 1] : undefined;
if (closeValue !== undefined) {
state.reportOnClose = closeValue === '1';
}
const urgencyValues = metadataEntries.get('u');
const urgencyValue = urgencyValues && urgencyValues.length > 0 ? urgencyValues[urgencyValues.length - 1] : undefined;
if (urgencyValue !== undefined) {
const urgency = Number.parseInt(urgencyValue, 10);
if (!Number.isNaN(urgency)) {
state.urgency = urgency;
}
}
const autoCloseValues = metadataEntries.get('w');
const autoCloseValue = autoCloseValues && autoCloseValues.length > 0 ? autoCloseValues[autoCloseValues.length - 1] : undefined;
if (autoCloseValue !== undefined) {
const autoClose = Number.parseInt(autoCloseValue, 10);
if (!Number.isNaN(autoClose)) {
state.autoCloseMs = autoClose;
}
}
const occasionValues = metadataEntries.get('o');
const occasionValue = occasionValues && occasionValues.length > 0 ? occasionValues[occasionValues.length - 1] : undefined;
if (occasionValue === 'always' || occasionValue === 'unfocused' || occasionValue === 'invisible') {
state.occasion = occasionValue;
}
}
private _parseOsc99Actions(value: string): { focusOnActivate: boolean; reportOnActivate: boolean } {
let focusOnActivate = true;
let reportOnActivate = false;
for (const token of value.split(',')) {
switch (token) {
case 'focus':
focusOnActivate = true;
break;
case '-focus':
focusOnActivate = false;
break;
case 'report':
reportOnActivate = true;
break;
case '-report':
reportOnActivate = false;
break;
}
}
return { focusOnActivate, reportOnActivate };
}
private _shouldHonorOsc99Occasion(occasion: Osc99Occasion | undefined): boolean {
if (!occasion || occasion === 'always') {
return true;
}
const windowFocused = this._host.isWindowFocused();
switch (occasion) {
case 'unfocused':
return !windowFocused;
case 'invisible':
return !windowFocused && !this._host.isTerminalVisible();
default:
return true;
}
}
private _showOsc99Notification(state: IOsc99NotificationState): boolean {
const message = this._getOsc99NotificationMessage(state);
if (!message) {
return false;
}
const severity = state.urgency === 2 ? Severity.Warning : Severity.Info;
const priority = this._getOsc99NotificationPriority(state.urgency);
const source = {
id: 'terminal',
label: localize('terminalNotificationSource', 'Terminal')
};
const buttons = state.buttonsPayload.length > 0 ? state.buttonsPayload.split('\u2028') : [];
const actionStore = this._register(new DisposableStore());
const handleRef: { current: INotificationHandle | undefined } = { current: undefined };
const activeRef: { current: IOsc99ActiveNotification | undefined } = { current: undefined };
const reportActivation = (buttonIndex?: number, forceFocus?: boolean) => {
if (forceFocus || state.focusOnActivate) {
this._host.focusTerminal();
}
if (state.reportOnActivate) {
this._sendOsc99ActivationReport(state.id, buttonIndex);
}
};
const primaryActions: IAction[] = [];
for (let i = 0; i < buttons.length; i++) {
const label = buttons[i];
if (!label) {
continue;
}
const action = actionStore.add(new Action(`terminal.osc99.button.${i}`, label, undefined, true, () => {
if (activeRef.current) {
activeRef.current.closeReason = 'button';
}
reportActivation(i + 1);
handleRef.current?.close();
}));
primaryActions.push(action);
}
const secondaryActions: IAction[] = [];
secondaryActions.push(actionStore.add(new Action(
'terminal.osc99.dismiss',
localize('terminalNotificationDismiss', 'Dismiss'),
undefined,
true,
() => {
if (activeRef.current) {
activeRef.current.closeReason = 'secondary';
}
handleRef.current?.close();
}
)));
secondaryActions.push(actionStore.add(new Action(
'terminal.osc99.disable',
localize('terminalNotificationDisable', 'Disable Terminal Notifications'),
undefined,
true,
async () => {
await this._host.updateEnableNotifications(false);
if (activeRef.current) {
activeRef.current.closeReason = 'secondary';
}
handleRef.current?.close();
}
)));
const actions = { primary: primaryActions, secondary: secondaryActions };
if (state.id) {
const existing = this._osc99ActiveNotifications.get(state.id);
if (existing) {
activeRef.current = existing;
handleRef.current = existing.handle;
existing.handle.updateMessage(message);
existing.handle.updateSeverity(severity);
existing.handle.updateActions(actions);
existing.actionStore.dispose();
existing.actionStore = actionStore;
existing.focusOnActivate = state.focusOnActivate;
existing.reportOnActivate = state.reportOnActivate;
existing.reportOnClose = state.reportOnClose;
existing.autoCloseDisposable?.dispose();
existing.autoCloseDisposable = this._scheduleOsc99AutoClose(existing, state.autoCloseMs);
return true;
}
}
const handle = this._host.notify({
id: state.id ? `terminal.osc99.${state.id}` : undefined,
severity,
message,
source,
actions,
priority
});
handleRef.current = handle;
const active: IOsc99ActiveNotification = {
id: state.id,
handle,
actionStore,
autoCloseDisposable: undefined,
reportOnActivate: state.reportOnActivate,
reportOnClose: state.reportOnClose,
focusOnActivate: state.focusOnActivate,
closeReason: undefined
};
activeRef.current = active;
active.autoCloseDisposable = this._scheduleOsc99AutoClose(active, state.autoCloseMs);
this._register(handle.onDidClose(() => {
if (active.reportOnActivate && active.closeReason === undefined) {
if (active.focusOnActivate) {
this._host.focusTerminal();
}
this._sendOsc99ActivationReport(active.id);
}
if (active.reportOnClose) {
this._sendOsc99CloseReport(active.id);
}
active.actionStore.dispose();
active.autoCloseDisposable?.dispose();
if (active.id) {
this._osc99ActiveNotifications.delete(active.id);
}
}));
if (active.id) {
this._osc99ActiveNotifications.set(active.id, active);
}
return true;
}
private _getOsc99NotificationMessage(state: IOsc99NotificationState): string | undefined {
const title = this._sanitizeOsc99MessageText(state.title);
const body = this._sanitizeOsc99MessageText(state.body);
const hasTitle = title.trim().length > 0;
const hasBody = body.trim().length > 0;
if (hasTitle && hasBody) {
return `${title}: ${body}`;
}
if (hasTitle) {
return title;
}
if (hasBody) {
return body;
}
return undefined;
}
private _getOsc99NotificationPriority(urgency: number | undefined): NotificationPriority | undefined {
switch (urgency) {
case 0:
return NotificationPriority.SILENT;
case 1:
return NotificationPriority.DEFAULT;
case 2:
return NotificationPriority.URGENT;
default:
return undefined;
}
}
private _scheduleOsc99AutoClose(active: IOsc99ActiveNotification, autoCloseMs: number | undefined): IDisposable | undefined {
if (autoCloseMs === undefined || autoCloseMs <= 0) {
return undefined;
}
return disposableTimeout(() => {
active.closeReason = 'auto';
active.handle.close();
}, autoCloseMs, this._store);
}
private _closeOsc99Notification(id: string | undefined): void {
if (!id) {
return;
}
const active = this._osc99ActiveNotifications.get(id);
if (active) {
active.closeReason = 'protocol';
active.handle.close();
}
this._osc99PendingNotifications.delete(id);
}
private _sendOsc99QueryResponse(id: string | undefined): void {
const requestId = id ?? '0';
this._sendOsc99Response([
`i=${requestId}`,
'p=?',
'a=report,focus',
'c=1',
'o=always,unfocused,invisible',
'p=title,body,buttons,close,alive,?',
'u=0,1,2',
'w=1'
]);
}
private _sendOsc99AliveResponse(id: string | undefined): void {
const requestId = id ?? '0';
const aliveIds = Array.from(this._osc99ActiveNotifications.keys()).join(',');
this._sendOsc99Response([
`i=${requestId}`,
'p=alive'
], aliveIds);
}
private _sendOsc99ActivationReport(id: string | undefined, buttonIndex?: number): void {
const reportId = id ?? '0';
this._sendOsc99Response([`i=${reportId}`], buttonIndex !== undefined ? String(buttonIndex) : '');
}
private _sendOsc99CloseReport(id: string | undefined): void {
const reportId = id ?? '0';
this._sendOsc99Response([`i=${reportId}`, 'p=close']);
}
private _sendOsc99Response(metadataParts: string[], payload: string = ''): void {
const metadata = metadataParts.join(':');
this._host.writeToProcess(`\x1b]99;${metadata};${payload}\x1b\\`);
}
}

View File

@@ -0,0 +1,20 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { IStringDictionary } from '../../../../../base/common/collections.js';
import { localize } from '../../../../../nls.js';
import type { IConfigurationPropertySchema } from '../../../../../platform/configuration/common/configurationRegistry.js';
export const enum TerminalOscNotificationsSettingId {
EnableNotifications = 'terminal.integrated.enableNotifications',
}
export const terminalOscNotificationsConfiguration: IStringDictionary<IConfigurationPropertySchema> = {
[TerminalOscNotificationsSettingId.EnableNotifications]: {
description: localize('terminal.integrated.enableNotifications', "Controls whether notifications sent from the terminal via OSC 99 are shown. This uses notifications inside the product instead of desktop notifications. Sounds, icons and filtering are not supported."),
type: 'boolean',
default: true
},
};

View File

@@ -0,0 +1,262 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { strictEqual } from 'assert';
import { Emitter, Event } from '../../../../../../base/common/event.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { NotificationPriority, Severity, type INotification, type INotificationActions, type INotificationHandle, type INotificationProgress, type NotificationMessage } from '../../../../../../platform/notification/common/notification.js';
import { TerminalNotificationHandler, type IOsc99NotificationHost } from '../../browser/terminalNotificationHandler.js';
class TestNotificationProgress implements INotificationProgress {
infinite(): void { }
total(_value: number): void { }
worked(_value: number): void { }
done(): void { }
}
class TestNotificationHandle implements INotificationHandle {
private readonly _onDidClose = new Emitter<void>();
readonly onDidClose = this._onDidClose.event;
readonly onDidChangeVisibility = Event.None;
readonly progress = new TestNotificationProgress();
closed = false;
message: NotificationMessage;
severity: Severity;
actions?: INotificationActions;
priority?: NotificationPriority;
source?: string | { id: string; label: string };
constructor(notification: INotification) {
this.message = notification.message;
this.severity = notification.severity;
this.actions = notification.actions;
this.priority = notification.priority;
this.source = notification.source;
}
updateSeverity(severity: Severity): void {
this.severity = severity;
}
updateMessage(message: NotificationMessage): void {
this.message = message;
}
updateActions(actions?: INotificationActions): void {
this._disposeActions(this.actions);
this.actions = actions;
}
close(): void {
if (this.closed) {
return;
}
this.closed = true;
this._disposeActions(this.actions);
this._onDidClose.fire();
}
private _disposeActions(actions: INotificationActions | undefined): void {
for (const action of actions?.primary ?? []) {
const disposable = action as { dispose?: () => void };
if (typeof disposable.dispose === 'function') {
disposable.dispose();
}
}
for (const action of actions?.secondary ?? []) {
const disposable = action as { dispose?: () => void };
if (typeof disposable.dispose === 'function') {
disposable.dispose();
}
}
}
}
class TestOsc99Host implements IOsc99NotificationHost {
enabled = true;
windowFocused = false;
terminalVisible = false;
writes: string[] = [];
notifications: TestNotificationHandle[] = [];
focusCalls = 0;
updatedEnableNotifications: boolean[] = [];
logMessages: string[] = [];
isEnabled(): boolean {
return this.enabled;
}
isWindowFocused(): boolean {
return this.windowFocused;
}
isTerminalVisible(): boolean {
return this.terminalVisible;
}
focusTerminal(): void {
this.focusCalls++;
}
notify(notification: INotification): INotificationHandle {
const handle = new TestNotificationHandle(notification);
this.notifications.push(handle);
return handle;
}
async updateEnableNotifications(value: boolean): Promise<void> {
this.enabled = value;
this.updatedEnableNotifications.push(value);
}
logWarn(message: string): void {
this.logMessages.push(message);
}
writeToProcess(data: string): void {
this.writes.push(data);
}
}
suite('Terminal OSC 99 notifications', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
let host: TestOsc99Host;
let handler: TerminalNotificationHandler;
setup(() => {
host = new TestOsc99Host();
handler = store.add(new TerminalNotificationHandler(host));
});
teardown(() => {
for (const notification of host.notifications) {
notification.close();
}
});
test('ignores notifications when disabled', () => {
host.enabled = false;
handler.handleSequence(';Hello');
strictEqual(host.notifications.length, 0);
strictEqual(host.writes.length, 0);
});
test('creates notification for title and body and updates', () => {
handler.handleSequence('i=1:d=0:p=title;Hello');
strictEqual(host.notifications.length, 0);
handler.handleSequence('i=1:p=body;World');
strictEqual(host.notifications.length, 1);
strictEqual(host.notifications[0].message, 'Hello: World');
});
test('decodes base64 payloads', () => {
handler.handleSequence('e=1:p=title;SGVsbG8=');
strictEqual(host.notifications.length, 1);
strictEqual(host.notifications[0].message, 'Hello');
});
test('sanitizes markdown links in payloads', () => {
handler.handleSequence('i=link:d=0:p=title;Click [run](command:workbench.action.reloadWindow)');
handler.handleSequence('i=link:p=body;See [docs](https://example.com)');
strictEqual(host.notifications.length, 1);
strictEqual(host.notifications[0].message, 'Click run: See docs');
});
test('defers display until done', () => {
handler.handleSequence('i=chunk:d=0:p=title;Hello ');
strictEqual(host.notifications.length, 0);
handler.handleSequence('i=chunk:d=1:p=title;World');
strictEqual(host.notifications.length, 1);
strictEqual(host.notifications[0].message, 'Hello World');
});
test('reports activation on button click', async () => {
handler.handleSequence('i=btn:d=0:a=report:p=title;Hi');
handler.handleSequence('i=btn:p=buttons;Yes');
const actions = host.notifications[0].actions;
if (!actions?.primary || actions.primary.length === 0) {
throw new Error('Expected primary actions');
}
await actions.primary[0].run();
strictEqual(host.writes[0], '\x1b]99;i=btn;1\x1b\\');
});
test('supports buttons before title and reports body activation', async () => {
handler.handleSequence('i=btn:p=buttons;One\u2028Two');
handler.handleSequence('i=btn:a=report;Buttons test');
strictEqual(host.notifications.length, 1);
const actions = host.notifications[0].actions;
if (!actions?.primary || actions.primary.length !== 2) {
throw new Error('Expected two primary actions');
}
strictEqual(actions.primary[0].label, 'One');
strictEqual(actions.primary[1].label, 'Two');
await actions.primary[1].run();
strictEqual(host.writes[0], '\x1b]99;i=btn;2\x1b\\');
});
test('reports activation when notification closes without button action', () => {
handler.handleSequence('i=btn:p=buttons;One\u2028Two');
handler.handleSequence('i=btn:a=report;Buttons test');
host.notifications[0].close();
strictEqual(host.writes[0], '\x1b]99;i=btn;\x1b\\');
});
test('sends close report when requested', () => {
handler.handleSequence('i=close:c=1:p=title;Bye');
strictEqual(host.notifications.length, 1);
host.notifications[0].close();
strictEqual(host.writes[0], '\x1b]99;i=close:p=close;\x1b\\');
});
test('responds to query and alive', () => {
handler.handleSequence('i=a:p=title;A');
handler.handleSequence('i=b:p=title;B');
handler.handleSequence('i=q:p=?;');
handler.handleSequence('i=q:p=alive;');
strictEqual(host.writes[0], '\x1b]99;i=q:p=?:a=report,focus:c=1:o=always,unfocused,invisible:p=title,body,buttons,close,alive,?:u=0,1,2:w=1;\x1b\\');
strictEqual(host.writes[1], '\x1b]99;i=q:p=alive;a,b\x1b\\');
});
test('honors occasion for visibility and focus', () => {
host.windowFocused = true;
host.terminalVisible = true;
handler.handleSequence('o=unfocused:p=title;Hidden');
strictEqual(host.notifications.length, 0);
host.windowFocused = false;
host.terminalVisible = true;
handler.handleSequence('o=invisible:p=title;Hidden');
strictEqual(host.notifications.length, 0);
host.terminalVisible = false;
handler.handleSequence('o=invisible:p=title;Shown');
strictEqual(host.notifications.length, 1);
});
test('closes notifications via close payload', () => {
handler.handleSequence('i=closeme:p=title;Close');
strictEqual(host.notifications.length, 1);
strictEqual(host.notifications[0].closed, false);
handler.handleSequence('i=closeme:p=close;');
strictEqual(host.notifications[0].closed, true);
});
test('maps urgency to severity and priority', () => {
handler.handleSequence('u=2:p=title;Urgent');
strictEqual(host.notifications[0].severity, Severity.Warning);
strictEqual(host.notifications[0].priority, NotificationPriority.URGENT);
});
});