diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index c4bcfd5b70b..b13ee6aded3 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -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; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 3ec8c50c4fd..7a71b3c59e1 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -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 = new ImmortalReference(undefined); get targetRef(): IReference { return this._targetRef; } diff --git a/src/vs/workbench/contrib/terminal/terminal.all.ts b/src/vs/workbench/contrib/terminal/terminal.all.ts index 6f08c629347..c586b200ef6 100644 --- a/src/vs/workbench/contrib/terminal/terminal.all.ts +++ b/src/vs/workbench/contrib/terminal/terminal.all.ts @@ -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'; diff --git a/src/vs/workbench/contrib/terminal/terminalContribExports.ts b/src/vs/workbench/contrib/terminal/terminalContribExports.ts index 5692332ebc9..a24b204a899 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribExports.ts @@ -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, diff --git a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts new file mode 100644 index 00000000000..85e3b84aa0a --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts @@ -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(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.ID); +} diff --git a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts new file mode 100644 index 00000000000..74c18acb5b2 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts @@ -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; + logWarn(message: string): void; + writeToProcess(data: string): void; +} + +export class TerminalNotificationHandler extends Disposable { + private readonly _osc99PendingNotifications = new Map(); + private _osc99PendingAnonymous: IOsc99NotificationState | undefined; + private readonly _osc99ActiveNotifications = new Map(); + + 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 { + const result = new Map(); + 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): 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\\`); + } +} diff --git a/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts new file mode 100644 index 00000000000..f4e1e8dc3c2 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts @@ -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 = { + [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 + }, +}; diff --git a/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts new file mode 100644 index 00000000000..a44395c5e94 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts @@ -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(); + 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 { + 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); + }); +});