From 657601215027277115e422f62f82790aca047adc Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:17:28 -0800 Subject: [PATCH 01/12] Initial OSC 99 implementation --- src/vs/platform/terminal/common/terminal.ts | 1 + .../terminal/browser/terminalInstance.ts | 1 + .../contrib/terminal/common/terminal.ts | 1 + .../terminal/common/terminalConfiguration.ts | 5 + .../contrib/terminal/terminal.all.ts | 1 + .../terminal.oscNotifications.contribution.ts | 509 ++++++++++++++++++ 6 files changed, 518 insertions(+) create mode 100644 src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 4431f19b659..1cbef39fcdf 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -82,6 +82,7 @@ export const enum TerminalSettingId { ConfirmOnKill = 'terminal.integrated.confirmOnKill', EnableBell = 'terminal.integrated.enableBell', EnableVisualBell = 'terminal.integrated.enableVisualBell', + EnableNotifications = 'terminal.integrated.enableNotifications', CommandsToSkipShell = 'terminal.integrated.commandsToSkipShell', AllowChords = 'terminal.integrated.allowChords', AllowMnemonics = 'terminal.integrated.allowMnemonics', diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 3ec8c50c4fd..37068b6ecd2 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -119,6 +119,7 @@ interface IGridDimensions { rows: number; } + const shellIntegrationSupportedShellTypes: (PosixShellType | GeneralShellType | WindowsShellType)[] = [ PosixShellType.Bash, PosixShellType.Zsh, diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 19a1d971cdb..1572df0ad4c 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -171,6 +171,7 @@ export interface ITerminalConfiguration { confirmOnExit: ConfirmOnExit; confirmOnKill: ConfirmOnKill; enableBell: boolean; + enableNotifications: boolean; env: { linux: { [key: string]: string }; osx: { [key: string]: string }; diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 87c3d392b20..4d451f9f460 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -413,6 +413,11 @@ const terminalConfiguration: IStringDictionary = { type: 'boolean', default: false }, + [TerminalSettingId.EnableNotifications]: { + description: localize('terminal.integrated.enableNotifications', "Controls whether notifications sent from the terminal via OSC 99 are shown."), + type: 'boolean', + default: true + }, [TerminalSettingId.CommandsToSkipShell]: { markdownDescription: localize( 'terminal.integrated.commandsToSkipShell', diff --git a/src/vs/workbench/contrib/terminal/terminal.all.ts b/src/vs/workbench/contrib/terminal/terminal.all.ts index 6f08c629347..361be62a7e9 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/oscNotifications/browser/terminal.oscNotifications.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/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts b/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts new file mode 100644 index 00000000000..e13af42d11b --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts @@ -0,0 +1,509 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Action, IAction } from '../../../../../base/common/actions.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; +import { decodeBase64 } from '../../../../../base/common/buffer.js'; +import * as dom from '../../../../../base/browser/dom.js'; +import { Disposable, type IDisposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { INotificationService, NotificationPriority, Severity, type INotificationHandle } from '../../../../../platform/notification/common/notification.js'; +import { ITerminalLogService, TerminalSettingId } 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'; + +const enum Osc99PayloadType { + Title = 'title', + Body = 'body', + Buttons = 'buttons', + Close = 'close', + Query = '?', + Alive = 'alive' +} + +type Osc99Occasion = 'always' | 'unfocused' | 'invisible'; + +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; + autoCloseDisposable: IDisposable | undefined; + reportOnActivate: boolean; + reportOnClose: boolean; + focusOnActivate: boolean; +} + +class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution { + static readonly ID = 'terminal.oscNotifications'; + + private _isVisible = false; + private readonly _osc99PendingNotifications = new Map(); + private _osc99PendingAnonymous: IOsc99NotificationState | undefined; + private readonly _osc99ActiveNotifications = new Map(); + + constructor( + private readonly _ctx: ITerminalContributionContext, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @INotificationService private readonly _notificationService: INotificationService, + @ITerminalLogService private readonly _logService: ITerminalLogService, + ) { + super(); + this._register(this._ctx.instance.onDidChangeVisibility(visible => this._isVisible = visible)); + } + + xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void { + this._register(xterm.raw.parser.registerOscHandler(99, data => this._handleOsc99(data))); + } + + private _handleOsc99(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._configurationService.getValue(TerminalSettingId.EnableNotifications)) { + 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; + } + + 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._logService.warn('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 _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 = dom.getActiveWindow().document.hasFocus(); + switch (occasion) { + case 'unfocused': + return !windowFocused; + case 'invisible': + return !windowFocused && !this._isVisible; + default: + return true; + } + } + + private _showOsc99Notification(state: IOsc99NotificationState): void { + const message = this._getOsc99NotificationMessage(state); + if (!message) { + return; + } + + 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 handleRef: { current: INotificationHandle | undefined } = { current: undefined }; + const reportActivation = (buttonIndex?: number, forceFocus?: boolean) => { + if (forceFocus || state.focusOnActivate) { + this._ctx.instance.focus(true); + } + 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; + } + primaryActions.push(new Action(`terminal.osc99.button.${i}`, label, undefined, true, () => { + reportActivation(i + 1); + handleRef.current?.close(); + })); + } + primaryActions.push(new Action( + 'terminal.osc99.focus', + localize('terminalNotificationFocus', 'Focus Terminal'), + undefined, + true, + () => { + reportActivation(undefined, true); + handleRef.current?.close(); + } + )); + + const secondaryActions: IAction[] = []; + secondaryActions.push(new Action( + 'terminal.osc99.dismiss', + localize('terminalNotificationDismiss', 'Dismiss'), + undefined, + true, + () => handleRef.current?.close() + )); + secondaryActions.push(new Action( + 'terminal.osc99.disable', + localize('terminalNotificationDisable', 'Disable Terminal Notifications'), + undefined, + true, + async () => { + await this._configurationService.updateValue(TerminalSettingId.EnableNotifications, false); + handleRef.current?.close(); + } + )); + + const actions = { primary: primaryActions, secondary: secondaryActions }; + + if (state.id) { + const existing = this._osc99ActiveNotifications.get(state.id); + if (existing) { + existing.handle.updateMessage(message); + existing.handle.updateSeverity(severity); + existing.handle.updateActions(actions); + existing.focusOnActivate = state.focusOnActivate; + existing.reportOnActivate = state.reportOnActivate; + existing.reportOnClose = state.reportOnClose; + existing.autoCloseDisposable?.dispose(); + existing.autoCloseDisposable = this._scheduleOsc99AutoClose(existing.handle, state.autoCloseMs); + return; + } + } + + const handle = this._notificationService.notify({ + id: state.id ? `terminal.osc99.${state.id}` : undefined, + severity, + message, + source, + actions, + priority + }); + handleRef.current = handle; + + const active: IOsc99ActiveNotification = { + id: state.id, + handle, + autoCloseDisposable: undefined, + reportOnActivate: state.reportOnActivate, + reportOnClose: state.reportOnClose, + focusOnActivate: state.focusOnActivate + }; + active.autoCloseDisposable = this._scheduleOsc99AutoClose(handle, state.autoCloseMs); + this._register(handle.onDidClose(() => { + if (active.reportOnClose) { + this._sendOsc99CloseReport(active.id); + } + active.autoCloseDisposable?.dispose(); + if (active.id) { + this._osc99ActiveNotifications.delete(active.id); + } + })); + + if (active.id) { + this._osc99ActiveNotifications.set(active.id, active); + } + } + + private _getOsc99NotificationMessage(state: IOsc99NotificationState): string | undefined { + const title = state.title; + const body = state.body; + const hasTitle = title.trim().length > 0; + const hasBody = body.trim().length > 0; + if (hasTitle && hasBody) { + return `${title}\n${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(handle: INotificationHandle, autoCloseMs: number | undefined): IDisposable | undefined { + if (autoCloseMs === undefined || autoCloseMs <= 0) { + return undefined; + } + return disposableTimeout(() => handle.close(), autoCloseMs, this._store); + } + + private _closeOsc99Notification(id: string | undefined): void { + if (!id) { + return; + } + const active = this._osc99ActiveNotifications.get(id); + if (active) { + 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(':'); + void this._ctx.processManager.write(`\x1b]99;${metadata};${payload}\x1b\\`); + } +} + +registerTerminalContribution(TerminalOscNotificationsContribution.ID, TerminalOscNotificationsContribution); + +export function getTerminalOscNotifications(instance: ITerminalInstance): TerminalOscNotificationsContribution | null { + return instance.getContribution(TerminalOscNotificationsContribution.ID); +} From 928687e78e63f068490e3b389d34b2a67dae9d86 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:10:19 -0800 Subject: [PATCH 02/12] Test refactor --- .../terminal.oscNotifications.contribution.ts | 75 ++++-- .../browser/terminalOscNotifications.test.ts | 231 ++++++++++++++++++ 2 files changed, 284 insertions(+), 22 deletions(-) create mode 100644 src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts diff --git a/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts b/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts index e13af42d11b..018308530a4 100644 --- a/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts @@ -11,7 +11,7 @@ import * as dom from '../../../../../base/browser/dom.js'; import { Disposable, type IDisposable } from '../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { INotificationService, NotificationPriority, Severity, type INotificationHandle } from '../../../../../platform/notification/common/notification.js'; +import { INotificationService, NotificationPriority, Severity, type INotification, type INotificationHandle } from '../../../../../platform/notification/common/notification.js'; import { ITerminalLogService, TerminalSettingId } 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'; @@ -49,29 +49,29 @@ interface IOsc99ActiveNotification { focusOnActivate: boolean; } -class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution { - static readonly ID = 'terminal.oscNotifications'; +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; +} - private _isVisible = false; +export class Osc99NotificationHandler extends Disposable { private readonly _osc99PendingNotifications = new Map(); private _osc99PendingAnonymous: IOsc99NotificationState | undefined; private readonly _osc99ActiveNotifications = new Map(); constructor( - private readonly _ctx: ITerminalContributionContext, - @IConfigurationService private readonly _configurationService: IConfigurationService, - @INotificationService private readonly _notificationService: INotificationService, - @ITerminalLogService private readonly _logService: ITerminalLogService, + private readonly _host: IOsc99NotificationHost ) { super(); - this._register(this._ctx.instance.onDidChangeVisibility(visible => this._isVisible = visible)); } - xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void { - this._register(xterm.raw.parser.registerOscHandler(99, data => this._handleOsc99(data))); - } - - private _handleOsc99(data: string): boolean { + handleSequence(data: string): boolean { const { metadata, payload } = this._splitOsc99Data(data); const metadataEntries = this._parseOsc99Metadata(metadata); const payloadTypes = metadataEntries.get('p'); @@ -79,7 +79,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin const payloadType = rawPayloadType && rawPayloadType.length > 0 ? rawPayloadType : Osc99PayloadType.Title; const id = this._sanitizeOsc99Id(metadataEntries.get('i')?.[0]); - if (!this._configurationService.getValue(TerminalSettingId.EnableNotifications)) { + if (!this._host.isEnabled()) { return true; } @@ -175,7 +175,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin try { return decodeBase64(payload).toString(); } catch { - this._logService.warn('Failed to decode OSC 99 payload'); + this._host.logWarn('Failed to decode OSC 99 payload'); return ''; } } @@ -288,12 +288,12 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin if (!occasion || occasion === 'always') { return true; } - const windowFocused = dom.getActiveWindow().document.hasFocus(); + const windowFocused = this._host.isWindowFocused(); switch (occasion) { case 'unfocused': return !windowFocused; case 'invisible': - return !windowFocused && !this._isVisible; + return !windowFocused && !this._host.isTerminalVisible(); default: return true; } @@ -316,7 +316,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin const handleRef: { current: INotificationHandle | undefined } = { current: undefined }; const reportActivation = (buttonIndex?: number, forceFocus?: boolean) => { if (forceFocus || state.focusOnActivate) { - this._ctx.instance.focus(true); + this._host.focusTerminal(); } if (state.reportOnActivate) { this._sendOsc99ActivationReport(state.id, buttonIndex); @@ -359,7 +359,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin undefined, true, async () => { - await this._configurationService.updateValue(TerminalSettingId.EnableNotifications, false); + await this._host.updateEnableNotifications(false); handleRef.current?.close(); } )); @@ -381,7 +381,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin } } - const handle = this._notificationService.notify({ + const handle = this._host.notify({ id: state.id ? `terminal.osc99.${state.id}` : undefined, severity, message, @@ -498,7 +498,38 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin private _sendOsc99Response(metadataParts: string[], payload: string = ''): void { const metadata = metadataParts.join(':'); - void this._ctx.processManager.write(`\x1b]99;${metadata};${payload}\x1b\\`); + this._host.writeToProcess(`\x1b]99;${metadata};${payload}\x1b\\`); + } +} + +class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution { + static readonly ID = 'terminal.oscNotifications'; + + private _isVisible = false; + private readonly _handler: Osc99NotificationHandler; + + 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 Osc99NotificationHandler({ + isEnabled: () => this._configurationService.getValue(TerminalSettingId.EnableNotifications), + isWindowFocused: () => dom.getActiveWindow().document.hasFocus(), + isTerminalVisible: () => this._isVisible, + focusTerminal: () => this._ctx.instance.focus(true), + notify: notification => this._notificationService.notify(notification), + updateEnableNotifications: value => this._configurationService.updateValue(TerminalSettingId.EnableNotifications, value), + logWarn: message => this._logService.warn(message), + writeToProcess: data => { void this._ctx.processManager.write(data); } + })); + this._register(this._ctx.instance.onDidChangeVisibility(visible => this._isVisible = visible)); + } + + xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void { + this._register(xterm.raw.parser.registerOscHandler(99, data => this._handler.handleSequence(data))); } } diff --git a/src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts b/src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts new file mode 100644 index 00000000000..4020fae1217 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts @@ -0,0 +1,231 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Osc99NotificationHandler, type IOsc99NotificationHost } from '../../browser/terminal.oscNotifications.contribution.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.actions = actions; + } + + close(): void { + this.closed = true; + this._onDidClose.fire(); + } +} + +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(); + + function createHandler(host: TestOsc99Host): Osc99NotificationHandler { + return store.add(new Osc99NotificationHandler(host)); + } + + test('ignores notifications when disabled', () => { + const host = new TestOsc99Host(); + host.enabled = false; + const handler = createHandler(host); + + handler.handleSequence(';Hello'); + strictEqual(host.notifications.length, 0); + strictEqual(host.writes.length, 0); + }); + + test('creates notification for title and body and updates', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + handler.handleSequence('i=1:p=title;Hello'); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].message, 'Hello'); + + handler.handleSequence('i=1:p=body;World'); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].message, 'Hello\nWorld'); + }); + + test('decodes base64 payloads', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + handler.handleSequence('e=1:p=title;SGVsbG8='); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].message, 'Hello'); + }); + + test('defers display until done', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + 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 () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + handler.handleSequence('i=btn: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('sends close report when requested', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + 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', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + 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', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + 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', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + 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', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + handler.handleSequence('u=2:p=title;Urgent'); + strictEqual(host.notifications[0].severity, Severity.Warning); + strictEqual(host.notifications[0].priority, NotificationPriority.URGENT); + }); +}); From e23d2e944544517d96fb1d0935e69dd14ad341d4 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:02:22 -0800 Subject: [PATCH 03/12] Refactor notifications terminalcontrib --- src/vs/platform/terminal/common/terminal.ts | 1 - .../contrib/terminal/browser/terminal.ts | 1 + .../terminal/browser/terminalInstance.ts | 4 +- .../contrib/terminal/common/terminal.ts | 1 - .../terminal/common/terminalConfiguration.ts | 5 -- .../contrib/terminal/terminal.all.ts | 2 +- .../terminal/terminalContribExports.ts | 2 + .../terminal.notifications.contribution.ts | 51 +++++++++++++ .../terminal.notifications.handler.ts} | 68 ++++------------- .../terminalNotificationsConfiguration.ts | 20 +++++ .../browser/terminalNotifications.test.ts} | 74 +++++++++---------- 11 files changed, 131 insertions(+), 98 deletions(-) create mode 100644 src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.contribution.ts rename src/vs/workbench/contrib/terminalContrib/{oscNotifications/browser/terminal.oscNotifications.contribution.ts => notifications/browser/terminal.notifications.handler.ts} (83%) create mode 100644 src/vs/workbench/contrib/terminalContrib/notifications/common/terminalNotificationsConfiguration.ts rename src/vs/workbench/contrib/terminalContrib/{oscNotifications/test/browser/terminalOscNotifications.test.ts => notifications/test/browser/terminalNotifications.test.ts} (84%) diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 1cbef39fcdf..4431f19b659 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -82,7 +82,6 @@ export const enum TerminalSettingId { ConfirmOnKill = 'terminal.integrated.confirmOnKill', EnableBell = 'terminal.integrated.enableBell', EnableVisualBell = 'terminal.integrated.enableVisualBell', - EnableNotifications = 'terminal.integrated.enableNotifications', CommandsToSkipShell = 'terminal.integrated.commandsToSkipShell', AllowChords = 'terminal.integrated.allowChords', AllowMnemonics = 'terminal.integrated.allowMnemonics', 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 37068b6ecd2..42c8195da39 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -151,7 +151,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[]; @@ -219,6 +218,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/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 1572df0ad4c..19a1d971cdb 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -171,7 +171,6 @@ export interface ITerminalConfiguration { confirmOnExit: ConfirmOnExit; confirmOnKill: ConfirmOnKill; enableBell: boolean; - enableNotifications: boolean; env: { linux: { [key: string]: string }; osx: { [key: string]: string }; diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 4d451f9f460..87c3d392b20 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -413,11 +413,6 @@ const terminalConfiguration: IStringDictionary = { type: 'boolean', default: false }, - [TerminalSettingId.EnableNotifications]: { - description: localize('terminal.integrated.enableNotifications', "Controls whether notifications sent from the terminal via OSC 99 are shown."), - type: 'boolean', - default: true - }, [TerminalSettingId.CommandsToSkipShell]: { markdownDescription: localize( 'terminal.integrated.commandsToSkipShell', diff --git a/src/vs/workbench/contrib/terminal/terminal.all.ts b/src/vs/workbench/contrib/terminal/terminal.all.ts index 361be62a7e9..a61db29612f 100644 --- a/src/vs/workbench/contrib/terminal/terminal.all.ts +++ b/src/vs/workbench/contrib/terminal/terminal.all.ts @@ -24,7 +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/oscNotifications/browser/terminal.oscNotifications.contribution.js'; +import '../terminalContrib/notifications/browser/terminal.notifications.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..f6918c387ca 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/notifications/common/terminalNotificationsConfiguration.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/notifications/browser/terminal.notifications.contribution.ts b/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.contribution.ts new file mode 100644 index 00000000000..1ffe20afaf7 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.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/terminalNotificationsConfiguration.js'; +import { Osc99NotificationHandler } from './terminal.notifications.handler.js'; + + +class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution { + static readonly ID = 'terminal.oscNotifications'; + + private readonly _handler: Osc99NotificationHandler; + + 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 Osc99NotificationHandler({ + isEnabled: () => this._configurationService.getValue(TerminalOscNotificationsSettingId.EnableNotifications), + 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.processManager.write(data); } + })); + } + + 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/oscNotifications/browser/terminal.oscNotifications.contribution.ts b/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.handler.ts similarity index 83% rename from src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts rename to src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.handler.ts index 018308530a4..06a0ad0ab93 100644 --- a/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.handler.ts @@ -3,18 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Terminal as RawXtermTerminal } from '@xterm/xterm'; import { Action, IAction } from '../../../../../base/common/actions.js'; import { disposableTimeout } from '../../../../../base/common/async.js'; import { decodeBase64 } from '../../../../../base/common/buffer.js'; -import * as dom from '../../../../../base/browser/dom.js'; -import { Disposable, type IDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, type IDisposable } from '../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../nls.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { INotificationService, NotificationPriority, Severity, type INotification, type INotificationHandle } from '../../../../../platform/notification/common/notification.js'; -import { ITerminalLogService, TerminalSettingId } 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 { NotificationPriority, Severity, type INotification, type INotificationHandle } from '../../../../../platform/notification/common/notification.js'; const enum Osc99PayloadType { Title = 'title', @@ -43,6 +37,7 @@ interface IOsc99NotificationState { interface IOsc99ActiveNotification { id: string | undefined; handle: INotificationHandle; + actionStore: DisposableStore; autoCloseDisposable: IDisposable | undefined; reportOnActivate: boolean; reportOnClose: boolean; @@ -312,6 +307,7 @@ export class Osc99NotificationHandler extends Disposable { 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 reportActivation = (buttonIndex?: number, forceFocus?: boolean) => { @@ -329,12 +325,13 @@ export class Osc99NotificationHandler extends Disposable { if (!label) { continue; } - primaryActions.push(new Action(`terminal.osc99.button.${i}`, label, undefined, true, () => { + const action = actionStore.add(new Action(`terminal.osc99.button.${i}`, label, undefined, true, () => { reportActivation(i + 1); handleRef.current?.close(); })); + primaryActions.push(action); } - primaryActions.push(new Action( + primaryActions.push(actionStore.add(new Action( 'terminal.osc99.focus', localize('terminalNotificationFocus', 'Focus Terminal'), undefined, @@ -343,17 +340,17 @@ export class Osc99NotificationHandler extends Disposable { reportActivation(undefined, true); handleRef.current?.close(); } - )); + ))); const secondaryActions: IAction[] = []; - secondaryActions.push(new Action( + secondaryActions.push(actionStore.add(new Action( 'terminal.osc99.dismiss', localize('terminalNotificationDismiss', 'Dismiss'), undefined, true, () => handleRef.current?.close() - )); - secondaryActions.push(new Action( + ))); + secondaryActions.push(actionStore.add(new Action( 'terminal.osc99.disable', localize('terminalNotificationDisable', 'Disable Terminal Notifications'), undefined, @@ -362,7 +359,7 @@ export class Osc99NotificationHandler extends Disposable { await this._host.updateEnableNotifications(false); handleRef.current?.close(); } - )); + ))); const actions = { primary: primaryActions, secondary: secondaryActions }; @@ -372,6 +369,8 @@ export class Osc99NotificationHandler extends Disposable { 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; @@ -394,6 +393,7 @@ export class Osc99NotificationHandler extends Disposable { const active: IOsc99ActiveNotification = { id: state.id, handle, + actionStore, autoCloseDisposable: undefined, reportOnActivate: state.reportOnActivate, reportOnClose: state.reportOnClose, @@ -404,6 +404,7 @@ export class Osc99NotificationHandler extends Disposable { if (active.reportOnClose) { this._sendOsc99CloseReport(active.id); } + active.actionStore.dispose(); active.autoCloseDisposable?.dispose(); if (active.id) { this._osc99ActiveNotifications.delete(active.id); @@ -501,40 +502,3 @@ export class Osc99NotificationHandler extends Disposable { this._host.writeToProcess(`\x1b]99;${metadata};${payload}\x1b\\`); } } - -class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution { - static readonly ID = 'terminal.oscNotifications'; - - private _isVisible = false; - private readonly _handler: Osc99NotificationHandler; - - 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 Osc99NotificationHandler({ - isEnabled: () => this._configurationService.getValue(TerminalSettingId.EnableNotifications), - isWindowFocused: () => dom.getActiveWindow().document.hasFocus(), - isTerminalVisible: () => this._isVisible, - focusTerminal: () => this._ctx.instance.focus(true), - notify: notification => this._notificationService.notify(notification), - updateEnableNotifications: value => this._configurationService.updateValue(TerminalSettingId.EnableNotifications, value), - logWarn: message => this._logService.warn(message), - writeToProcess: data => { void this._ctx.processManager.write(data); } - })); - this._register(this._ctx.instance.onDidChangeVisibility(visible => this._isVisible = visible)); - } - - 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/notifications/common/terminalNotificationsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/notifications/common/terminalNotificationsConfiguration.ts new file mode 100644 index 00000000000..2ad950e8d7b --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/notifications/common/terminalNotificationsConfiguration.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."), + type: 'boolean', + default: true + }, +}; diff --git a/src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts b/src/vs/workbench/contrib/terminalContrib/notifications/test/browser/terminalNotifications.test.ts similarity index 84% rename from src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts rename to src/vs/workbench/contrib/terminalContrib/notifications/test/browser/terminalNotifications.test.ts index 4020fae1217..27a210c3346 100644 --- a/src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/notifications/test/browser/terminalNotifications.test.ts @@ -7,7 +7,7 @@ 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 { Osc99NotificationHandler, type IOsc99NotificationHost } from '../../browser/terminal.oscNotifications.contribution.js'; +import { Osc99NotificationHandler, type IOsc99NotificationHost } from '../../browser/terminal.notifications.handler.js'; class TestNotificationProgress implements INotificationProgress { infinite(): void { } @@ -45,13 +45,33 @@ class TestNotificationHandle implements INotificationHandle { } 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 { @@ -103,14 +123,22 @@ class TestOsc99Host implements IOsc99NotificationHost { suite('Terminal OSC 99 notifications', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createHandler(host: TestOsc99Host): Osc99NotificationHandler { - return store.add(new Osc99NotificationHandler(host)); - } + let host: TestOsc99Host; + let handler: Osc99NotificationHandler; + + setup(() => { + host = new TestOsc99Host(); + handler = store.add(new Osc99NotificationHandler(host)); + }); + + teardown(() => { + for (const notification of host.notifications) { + notification.close(); + } + }); test('ignores notifications when disabled', () => { - const host = new TestOsc99Host(); host.enabled = false; - const handler = createHandler(host); handler.handleSequence(';Hello'); strictEqual(host.notifications.length, 0); @@ -118,12 +146,8 @@ suite('Terminal OSC 99 notifications', () => { }); test('creates notification for title and body and updates', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - - handler.handleSequence('i=1:p=title;Hello'); - strictEqual(host.notifications.length, 1); - strictEqual(host.notifications[0].message, 'Hello'); + 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); @@ -131,18 +155,12 @@ suite('Terminal OSC 99 notifications', () => { }); test('decodes base64 payloads', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - handler.handleSequence('e=1:p=title;SGVsbG8='); strictEqual(host.notifications.length, 1); strictEqual(host.notifications[0].message, 'Hello'); }); test('defers display until done', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - handler.handleSequence('i=chunk:d=0:p=title;Hello '); strictEqual(host.notifications.length, 0); @@ -152,10 +170,7 @@ suite('Terminal OSC 99 notifications', () => { }); test('reports activation on button click', async () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - - handler.handleSequence('i=btn:a=report:p=title;Hi'); + handler.handleSequence('i=btn:d=0:a=report:p=title;Hi'); handler.handleSequence('i=btn:p=buttons;Yes'); const actions = host.notifications[0].actions; @@ -167,9 +182,6 @@ suite('Terminal OSC 99 notifications', () => { }); test('sends close report when requested', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - handler.handleSequence('i=close:c=1:p=title;Bye'); strictEqual(host.notifications.length, 1); host.notifications[0].close(); @@ -177,9 +189,6 @@ suite('Terminal OSC 99 notifications', () => { }); test('responds to query and alive', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - handler.handleSequence('i=a:p=title;A'); handler.handleSequence('i=b:p=title;B'); handler.handleSequence('i=q:p=?;'); @@ -190,9 +199,6 @@ suite('Terminal OSC 99 notifications', () => { }); test('honors occasion for visibility and focus', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - host.windowFocused = true; host.terminalVisible = true; handler.handleSequence('o=unfocused:p=title;Hidden'); @@ -209,9 +215,6 @@ suite('Terminal OSC 99 notifications', () => { }); test('closes notifications via close payload', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - handler.handleSequence('i=closeme:p=title;Close'); strictEqual(host.notifications.length, 1); strictEqual(host.notifications[0].closed, false); @@ -221,9 +224,6 @@ suite('Terminal OSC 99 notifications', () => { }); test('maps urgency to severity and priority', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - handler.handleSequence('u=2:p=title;Urgent'); strictEqual(host.notifications[0].severity, Severity.Warning); strictEqual(host.notifications[0].priority, NotificationPriority.URGENT); From 768af7b87f58d3fff561240f4ffb92bcc7055e78 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 04:49:35 -0800 Subject: [PATCH 04/12] notifications -> notification --- src/vs/workbench/contrib/terminal/terminal.all.ts | 2 +- .../workbench/contrib/terminal/terminalContribExports.ts | 2 +- .../browser/terminal.notification.contribution.ts} | 8 ++++---- .../browser/terminalNotificationHandler.ts} | 2 +- .../common/terminalNotificationConfiguration.ts} | 0 .../test/browser/terminalNotification.test.ts} | 6 +++--- 6 files changed, 10 insertions(+), 10 deletions(-) rename src/vs/workbench/contrib/terminalContrib/{notifications/browser/terminal.notifications.contribution.ts => notification/browser/terminal.notification.contribution.ts} (92%) rename src/vs/workbench/contrib/terminalContrib/{notifications/browser/terminal.notifications.handler.ts => notification/browser/terminalNotificationHandler.ts} (99%) rename src/vs/workbench/contrib/terminalContrib/{notifications/common/terminalNotificationsConfiguration.ts => notification/common/terminalNotificationConfiguration.ts} (100%) rename src/vs/workbench/contrib/terminalContrib/{notifications/test/browser/terminalNotifications.test.ts => notification/test/browser/terminalNotification.test.ts} (96%) diff --git a/src/vs/workbench/contrib/terminal/terminal.all.ts b/src/vs/workbench/contrib/terminal/terminal.all.ts index a61db29612f..c586b200ef6 100644 --- a/src/vs/workbench/contrib/terminal/terminal.all.ts +++ b/src/vs/workbench/contrib/terminal/terminal.all.ts @@ -24,7 +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/notifications/browser/terminal.notifications.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 f6918c387ca..a24b204a899 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribExports.ts @@ -14,7 +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/notifications/common/terminalNotificationsConfiguration.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'; diff --git a/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.contribution.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts similarity index 92% rename from src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.contribution.ts rename to src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts index 1ffe20afaf7..4cf58b88a2a 100644 --- a/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts @@ -11,14 +11,14 @@ import { INotificationService } from '../../../../../platform/notification/commo 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/terminalNotificationsConfiguration.js'; -import { Osc99NotificationHandler } from './terminal.notifications.handler.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: Osc99NotificationHandler; + private readonly _handler: TerminalNotificationHandler; constructor( private readonly _ctx: ITerminalContributionContext, @@ -27,7 +27,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin @ITerminalLogService private readonly _logService: ITerminalLogService, ) { super(); - this._handler = this._register(new Osc99NotificationHandler({ + this._handler = this._register(new TerminalNotificationHandler({ isEnabled: () => this._configurationService.getValue(TerminalOscNotificationsSettingId.EnableNotifications), isWindowFocused: () => dom.getActiveWindow().document.hasFocus(), isTerminalVisible: () => this._ctx.instance.isVisible, diff --git a/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.handler.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts similarity index 99% rename from src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.handler.ts rename to src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts index 06a0ad0ab93..41bc22f7293 100644 --- a/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.handler.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts @@ -55,7 +55,7 @@ export interface IOsc99NotificationHost { writeToProcess(data: string): void; } -export class Osc99NotificationHandler extends Disposable { +export class TerminalNotificationHandler extends Disposable { private readonly _osc99PendingNotifications = new Map(); private _osc99PendingAnonymous: IOsc99NotificationState | undefined; private readonly _osc99ActiveNotifications = new Map(); diff --git a/src/vs/workbench/contrib/terminalContrib/notifications/common/terminalNotificationsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts similarity index 100% rename from src/vs/workbench/contrib/terminalContrib/notifications/common/terminalNotificationsConfiguration.ts rename to src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts diff --git a/src/vs/workbench/contrib/terminalContrib/notifications/test/browser/terminalNotifications.test.ts b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts similarity index 96% rename from src/vs/workbench/contrib/terminalContrib/notifications/test/browser/terminalNotifications.test.ts rename to src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts index 27a210c3346..9b34d940448 100644 --- a/src/vs/workbench/contrib/terminalContrib/notifications/test/browser/terminalNotifications.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts @@ -7,7 +7,7 @@ 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 { Osc99NotificationHandler, type IOsc99NotificationHost } from '../../browser/terminal.notifications.handler.js'; +import { TerminalNotificationHandler, type IOsc99NotificationHost } from '../../browser/terminalNotificationHandler.js'; class TestNotificationProgress implements INotificationProgress { infinite(): void { } @@ -124,11 +124,11 @@ suite('Terminal OSC 99 notifications', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let host: TestOsc99Host; - let handler: Osc99NotificationHandler; + let handler: TerminalNotificationHandler; setup(() => { host = new TestOsc99Host(); - handler = store.add(new Osc99NotificationHandler(host)); + handler = store.add(new TerminalNotificationHandler(host)); }); teardown(() => { From 71a9aaa7c38a2c0a66c15ed339bac2e235661983 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:15:47 -0800 Subject: [PATCH 05/12] Use correct method for writing to proc --- .../notification/browser/terminal.notification.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 4cf58b88a2a..6f1c54eb597 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts @@ -35,7 +35,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin 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.processManager.write(data); } + writeToProcess: data => { void this._ctx.instance.sendText(data, false); } })); } From 65bfb3303c6841bbc774981c735d71b64a2ab919 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:53:57 -0800 Subject: [PATCH 06/12] Improve body/title formatting --- .../notification/browser/terminalNotificationHandler.ts | 2 +- .../notification/test/browser/terminalNotification.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts index 41bc22f7293..9d8a435b8f3 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts @@ -422,7 +422,7 @@ export class TerminalNotificationHandler extends Disposable { const hasTitle = title.trim().length > 0; const hasBody = body.trim().length > 0; if (hasTitle && hasBody) { - return `${title}\n${body}`; + return `${title}: ${body}`; } if (hasTitle) { return title; 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 index 9b34d940448..fce8080b3be 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts @@ -151,7 +151,7 @@ suite('Terminal OSC 99 notifications', () => { handler.handleSequence('i=1:p=body;World'); strictEqual(host.notifications.length, 1); - strictEqual(host.notifications[0].message, 'Hello\nWorld'); + strictEqual(host.notifications[0].message, 'Hello: World'); }); test('decodes base64 payloads', () => { From 48c903255b60d2a62990bb21c05da3715af44de2 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:08:06 -0800 Subject: [PATCH 07/12] Fix custom buttons --- .../browser/terminalNotificationHandler.ts | 61 ++++++++++++------- .../test/browser/terminalNotification.test.ts | 24 ++++++++ 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts index 9d8a435b8f3..c0397f46461 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts @@ -20,6 +20,7 @@ const enum Osc99PayloadType { } type Osc99Occasion = 'always' | 'unfocused' | 'invisible'; +type Osc99CloseReason = 'button' | 'secondary' | 'auto' | 'protocol'; interface IOsc99NotificationState { id: string | undefined; @@ -42,6 +43,7 @@ interface IOsc99ActiveNotification { reportOnActivate: boolean; reportOnClose: boolean; focusOnActivate: boolean; + closeReason: Osc99CloseReason | undefined; } export interface IOsc99NotificationHost { @@ -119,8 +121,9 @@ export class TerminalNotificationHandler extends Disposable { return true; } - this._showOsc99Notification(state); - this._clearOsc99PendingState(id); + if (this._showOsc99Notification(state)) { + this._clearOsc99PendingState(id); + } return true; } @@ -294,10 +297,10 @@ export class TerminalNotificationHandler extends Disposable { } } - private _showOsc99Notification(state: IOsc99NotificationState): void { + private _showOsc99Notification(state: IOsc99NotificationState): boolean { const message = this._getOsc99NotificationMessage(state); if (!message) { - return; + return false; } const severity = state.urgency === 2 ? Severity.Warning : Severity.Info; @@ -310,6 +313,7 @@ export class TerminalNotificationHandler extends Disposable { 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(); @@ -326,21 +330,14 @@ export class TerminalNotificationHandler extends Disposable { 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); } - primaryActions.push(actionStore.add(new Action( - 'terminal.osc99.focus', - localize('terminalNotificationFocus', 'Focus Terminal'), - undefined, - true, - () => { - reportActivation(undefined, true); - handleRef.current?.close(); - } - ))); const secondaryActions: IAction[] = []; secondaryActions.push(actionStore.add(new Action( @@ -348,7 +345,12 @@ export class TerminalNotificationHandler extends Disposable { localize('terminalNotificationDismiss', 'Dismiss'), undefined, true, - () => handleRef.current?.close() + () => { + if (activeRef.current) { + activeRef.current.closeReason = 'secondary'; + } + handleRef.current?.close(); + } ))); secondaryActions.push(actionStore.add(new Action( 'terminal.osc99.disable', @@ -357,6 +359,9 @@ export class TerminalNotificationHandler extends Disposable { true, async () => { await this._host.updateEnableNotifications(false); + if (activeRef.current) { + activeRef.current.closeReason = 'secondary'; + } handleRef.current?.close(); } ))); @@ -366,6 +371,7 @@ export class TerminalNotificationHandler extends Disposable { if (state.id) { const existing = this._osc99ActiveNotifications.get(state.id); if (existing) { + activeRef.current = existing; existing.handle.updateMessage(message); existing.handle.updateSeverity(severity); existing.handle.updateActions(actions); @@ -375,8 +381,8 @@ export class TerminalNotificationHandler extends Disposable { existing.reportOnActivate = state.reportOnActivate; existing.reportOnClose = state.reportOnClose; existing.autoCloseDisposable?.dispose(); - existing.autoCloseDisposable = this._scheduleOsc99AutoClose(existing.handle, state.autoCloseMs); - return; + existing.autoCloseDisposable = this._scheduleOsc99AutoClose(existing, state.autoCloseMs); + return true; } } @@ -397,10 +403,18 @@ export class TerminalNotificationHandler extends Disposable { autoCloseDisposable: undefined, reportOnActivate: state.reportOnActivate, reportOnClose: state.reportOnClose, - focusOnActivate: state.focusOnActivate + focusOnActivate: state.focusOnActivate, + closeReason: undefined }; - active.autoCloseDisposable = this._scheduleOsc99AutoClose(handle, state.autoCloseMs); + 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); } @@ -414,6 +428,7 @@ export class TerminalNotificationHandler extends Disposable { if (active.id) { this._osc99ActiveNotifications.set(active.id, active); } + return true; } private _getOsc99NotificationMessage(state: IOsc99NotificationState): string | undefined { @@ -446,11 +461,14 @@ export class TerminalNotificationHandler extends Disposable { } } - private _scheduleOsc99AutoClose(handle: INotificationHandle, autoCloseMs: number | undefined): IDisposable | undefined { + private _scheduleOsc99AutoClose(active: IOsc99ActiveNotification, autoCloseMs: number | undefined): IDisposable | undefined { if (autoCloseMs === undefined || autoCloseMs <= 0) { return undefined; } - return disposableTimeout(() => handle.close(), autoCloseMs, this._store); + return disposableTimeout(() => { + active.closeReason = 'auto'; + active.handle.close(); + }, autoCloseMs, this._store); } private _closeOsc99Notification(id: string | undefined): void { @@ -459,6 +477,7 @@ export class TerminalNotificationHandler extends Disposable { } const active = this._osc99ActiveNotifications.get(id); if (active) { + active.closeReason = 'protocol'; active.handle.close(); } this._osc99PendingNotifications.delete(id); 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 index fce8080b3be..518e4821c57 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts @@ -181,6 +181,30 @@ suite('Terminal OSC 99 notifications', () => { 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); From 2d4622e0abf598266c81595810c3567293b72e5d Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:11:54 -0800 Subject: [PATCH 08/12] Clarify support in config --- .../notification/common/terminalNotificationConfiguration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts index 2ad950e8d7b..f4e1e8dc3c2 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts @@ -13,7 +13,7 @@ export const enum TerminalOscNotificationsSettingId { export const terminalOscNotificationsConfiguration: IStringDictionary = { [TerminalOscNotificationsSettingId.EnableNotifications]: { - description: localize('terminal.integrated.enableNotifications', "Controls whether notifications sent from the terminal via OSC 99 are shown."), + 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 }, From c6636b60fb19b1537fd650fd74457c31078c8a86 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:19:53 -0800 Subject: [PATCH 09/12] Use type param in getValue --- .../notification/browser/terminal.notification.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 6f1c54eb597..85e3b84aa0a 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts @@ -28,7 +28,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin ) { super(); this._handler = this._register(new TerminalNotificationHandler({ - isEnabled: () => this._configurationService.getValue(TerminalOscNotificationsSettingId.EnableNotifications), + isEnabled: () => this._configurationService.getValue(TerminalOscNotificationsSettingId.EnableNotifications) === true, isWindowFocused: () => dom.getActiveWindow().document.hasFocus(), isTerminalVisible: () => this._ctx.instance.isVisible, focusTerminal: () => this._ctx.instance.focus(true), From e17015d68fa860386662029364348051962c1c66 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:35:12 -0800 Subject: [PATCH 10/12] Strip markdown links from output --- .../notification/browser/terminalNotificationHandler.ts | 8 ++++++-- .../test/browser/terminalNotification.test.ts | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts index c0397f46461..25e7466f5b3 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts @@ -186,6 +186,10 @@ export class TerminalNotificationHandler extends Disposable { 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) { @@ -432,8 +436,8 @@ export class TerminalNotificationHandler extends Disposable { } private _getOsc99NotificationMessage(state: IOsc99NotificationState): string | undefined { - const title = state.title; - const body = state.body; + 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) { 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 index 518e4821c57..a44395c5e94 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts @@ -160,6 +160,13 @@ suite('Terminal OSC 99 notifications', () => { 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); From 92de02a661d208a8b0dac6484b8eb4f060263158 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:36:31 -0800 Subject: [PATCH 11/12] Remove unwanted whitespace --- src/vs/workbench/contrib/terminal/browser/terminalInstance.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 42c8195da39..7a71b3c59e1 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -119,7 +119,6 @@ interface IGridDimensions { rows: number; } - const shellIntegrationSupportedShellTypes: (PosixShellType | GeneralShellType | WindowsShellType)[] = [ PosixShellType.Bash, PosixShellType.Zsh, From 30cd06b93d47b98d2cfa7c32be721d3c20aa0761 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:38:29 -0800 Subject: [PATCH 12/12] Address handle feedback --- .../notification/browser/terminalNotificationHandler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts index 25e7466f5b3..74c18acb5b2 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts @@ -376,6 +376,7 @@ export class TerminalNotificationHandler extends Disposable { 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);