xtralab 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,23 @@ from `main` are uploaded as workflow artifacts on the
34
34
  [CONTRIBUTING.md](./CONTRIBUTING.md) for the desktop app architecture and local
35
35
  build instructions.
36
36
 
37
+ On macOS the build is not notarized yet (it has no Apple Developer ID), so
38
+ Gatekeeper blocks it on first launch, and agent notifications fall back to
39
+ `osascript`, appearing as "Script Editor". In the meantime you can self-sign it
40
+ for free: this lets it launch and gives native notifications instead, branded as
41
+ xtralab and clickable to jump to the terminal that fired them. Create a
42
+ code-signing certificate once in Keychain Access (Certificate Assistant, Create
43
+ a Certificate, with Identity Type "Self Signed Root" and Certificate Type "Code
44
+ Signing") named `xtralab-selfsign`, then sign the app you copied out of the DMG:
45
+
46
+ ```bash
47
+ codesign --force --deep --sign "xtralab-selfsign" --timestamp=none "/Applications/xtralab.app"
48
+ xattr -dr com.apple.quarantine "/Applications/xtralab.app"
49
+ ```
50
+
51
+ macOS prompts once to let `codesign` use the key; choose Always Allow. None of
52
+ this will be needed once the app ships with a Developer ID signature.
53
+
37
54
  ## What's included
38
55
 
39
56
  xtralab builds on [`ajlab`](https://github.com/jtpio/ajlab), the agent-ready
package/lib/index.js CHANGED
@@ -7,6 +7,7 @@ import gitPlugins from './git';
7
7
  import launcherPlugins from './launcher';
8
8
  import menusPlugin from './menus';
9
9
  import sidebarPlugin from './sidebar';
10
+ import terminalNotificationsPlugin from './terminalNotifications';
10
11
  import terminalsPlugin from './terminals';
11
12
  import topBarPlugin from './topBar';
12
13
  /**
@@ -28,6 +29,7 @@ const plugins = [
28
29
  ...launcherPlugins,
29
30
  menusPlugin,
30
31
  sidebarPlugin,
32
+ terminalNotificationsPlugin,
31
33
  terminalsPlugin,
32
34
  topBarPlugin
33
35
  ];
@@ -0,0 +1,15 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ /**
3
+ * Turns the notifications coding agents already emit (OSC 9, OSC 777, the bell)
4
+ * into desktop notifications. JupyterLab's xterm renders these sequences but
5
+ * never forwards them to the OS, so this plugin hooks each terminal and bridges
6
+ * them through `window.xtralab.notify` (desktop) or the web Notifications API.
7
+ *
8
+ * A notification is suppressed while its terminal is the focused, active tab,
9
+ * and throttled per terminal. The desktop shell advertises
10
+ * `TERM_PROGRAM=iTerm.app` so agents emit OSC 9 in the first place, and the
11
+ * session name is forwarded so clicking a notification focuses the terminal
12
+ * that fired it.
13
+ */
14
+ declare const plugin: JupyterFrontEndPlugin<void>;
15
+ export default plugin;
@@ -0,0 +1,213 @@
1
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
2
+ import { ITerminalTracker } from '@jupyterlab/terminal';
3
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
4
+ const PLUGIN_ID = 'xtralab:terminal-notifications';
5
+ // Per-terminal gap between notifications, so the OSC-9-plus-bell agents emit
6
+ // together collapses into one and a chatty program cannot flood the OS.
7
+ const NOTIFY_THROTTLE_MS = 3000;
8
+ // iTerm2 growl notification: `ESC ] 9 ; <message> BEL`. What Claude Code and
9
+ // most agents emit on their default `auto` setting once TERM_PROGRAM is set.
10
+ const OSC_ITERM2_GROWL = 9;
11
+ // rxvt/Ghostty notification: `ESC ] 777 ; notify ; <title> ; <body> BEL`.
12
+ const OSC_RXVT_NOTIFY = 777;
13
+ const MAX_TEXT_LENGTH = 256;
14
+ /**
15
+ * Turns the notifications coding agents already emit (OSC 9, OSC 777, the bell)
16
+ * into desktop notifications. JupyterLab's xterm renders these sequences but
17
+ * never forwards them to the OS, so this plugin hooks each terminal and bridges
18
+ * them through `window.xtralab.notify` (desktop) or the web Notifications API.
19
+ *
20
+ * A notification is suppressed while its terminal is the focused, active tab,
21
+ * and throttled per terminal. The desktop shell advertises
22
+ * `TERM_PROGRAM=iTerm.app` so agents emit OSC 9 in the first place, and the
23
+ * session name is forwarded so clicking a notification focuses the terminal
24
+ * that fired it.
25
+ */
26
+ const plugin = {
27
+ id: PLUGIN_ID,
28
+ description: 'Forwards desktop notifications emitted by agents in xtralab terminals (OSC 9, OSC 777, bell) to the operating system.',
29
+ autoStart: true,
30
+ requires: [ITerminalTracker],
31
+ optional: [ISettingRegistry, ITranslator],
32
+ activate: async (app, tracker, settingRegistry, translator) => {
33
+ var _a;
34
+ const trans = (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab');
35
+ let enabled = true;
36
+ let notifyOnBell = true;
37
+ // Per-widget throttle timestamps and xterm hooks. WeakMaps let disposed
38
+ // widgets be collected; hooks are torn down when a tab closes.
39
+ const lastNotified = new WeakMap();
40
+ const hooks = new WeakMap();
41
+ // True when the user is already looking at this terminal, so a banner would
42
+ // just be noise.
43
+ const isActivelyViewing = (widget) => document.hasFocus() && app.shell.currentWidget === widget;
44
+ const deliver = (title, body, session) => {
45
+ const bridge = window.xtralab;
46
+ if (bridge && typeof bridge.notify === 'function') {
47
+ void Promise.resolve(bridge.notify(title, body, session)).catch(reason => {
48
+ console.warn('xtralab: desktop notification failed', reason);
49
+ });
50
+ return;
51
+ }
52
+ // Plain-browser fallback (pip install): the Web Notifications API.
53
+ if (typeof Notification === 'undefined') {
54
+ return;
55
+ }
56
+ if (Notification.permission === 'granted') {
57
+ new Notification(title, { body });
58
+ }
59
+ else if (Notification.permission !== 'denied') {
60
+ void Notification.requestPermission().then(permission => {
61
+ if (permission === 'granted') {
62
+ new Notification(title, { body });
63
+ }
64
+ });
65
+ }
66
+ };
67
+ const notify = (widget, title, body) => {
68
+ var _a;
69
+ if (!enabled || isActivelyViewing(widget)) {
70
+ return;
71
+ }
72
+ const now = Date.now();
73
+ const previous = lastNotified.get(widget);
74
+ if (previous !== undefined && now - previous < NOTIFY_THROTTLE_MS) {
75
+ return;
76
+ }
77
+ lastNotified.set(widget, now);
78
+ const session = (_a = widget.content.session) === null || _a === void 0 ? void 0 : _a.name;
79
+ deliver(sanitize(title) || 'xtralab', sanitize(body), session);
80
+ };
81
+ // The tab label is the agent's name once the launcher or an xterm title sets
82
+ // it, so it reads well as the title for body-only sequences (OSC 9, bell).
83
+ const labelOf = (widget) => widget.title.label || trans.__('Terminal');
84
+ const onOsc9 = (widget, data) => {
85
+ // OSC 9 is overloaded: ConEmu/Windows Terminal use `9 ; <n> ; …`
86
+ // subcommands (e.g. `9 ; 4` progress), so skip a numeric-subcommand
87
+ // payload and leave it unconsumed rather than treat it as a notification.
88
+ if (/^\d+(;|$)/.test(data)) {
89
+ return false;
90
+ }
91
+ const message = sanitize(data);
92
+ if (message) {
93
+ notify(widget, labelOf(widget), message);
94
+ }
95
+ return true;
96
+ };
97
+ const onOsc777 = (widget, data) => {
98
+ var _a;
99
+ // `notify ; <title> ; <body>`; a body may contain ';'. Other OSC 777
100
+ // subcommands are left unconsumed.
101
+ const parts = data.split(';');
102
+ if (parts.shift() !== 'notify') {
103
+ return false;
104
+ }
105
+ const title = sanitize((_a = parts.shift()) !== null && _a !== void 0 ? _a : '') || labelOf(widget);
106
+ const body = sanitize(parts.join(';'));
107
+ if (body || title) {
108
+ notify(widget, title, body);
109
+ }
110
+ return true;
111
+ };
112
+ const onBell = (widget) => {
113
+ if (notifyOnBell) {
114
+ notify(widget, labelOf(widget), trans.__('Activity in terminal'));
115
+ }
116
+ };
117
+ const hookWidget = (widget) => {
118
+ if (hooks.has(widget)) {
119
+ return;
120
+ }
121
+ const content = widget.content;
122
+ void content.ready
123
+ .then(() => {
124
+ const term = content._term;
125
+ if (widget.isDisposed) {
126
+ return;
127
+ }
128
+ if (!term) {
129
+ console.warn('xtralab: xterm internals not found; terminal notifications are disabled for this terminal');
130
+ return;
131
+ }
132
+ // Handlers are wrapped so a parse slip cannot break xterm's parser; an
133
+ // OSC handler returns whether it consumed the sequence.
134
+ const disposables = [
135
+ term.onBell(() => guard(() => onBell(widget))),
136
+ term.parser.registerOscHandler(OSC_ITERM2_GROWL, data => guardOsc(() => onOsc9(widget, data))),
137
+ term.parser.registerOscHandler(OSC_RXVT_NOTIFY, data => guardOsc(() => onOsc777(widget, data)))
138
+ ];
139
+ hooks.set(widget, disposables);
140
+ widget.disposed.connect(() => {
141
+ for (const disposable of disposables) {
142
+ disposable.dispose();
143
+ }
144
+ hooks.delete(widget);
145
+ });
146
+ })
147
+ .catch(reason => {
148
+ console.warn('xtralab: could not hook terminal notifications', reason);
149
+ });
150
+ };
151
+ tracker.forEach(hookWidget);
152
+ tracker.widgetAdded.connect((_, widget) => hookWidget(widget));
153
+ // Focus the terminal that fired a notification when the desktop shell
154
+ // reports a click. `terminal:open` activates its tab or reopens the session.
155
+ const desktopBridge = window
156
+ .xtralab;
157
+ (_a = desktopBridge === null || desktopBridge === void 0 ? void 0 : desktopBridge.onFocusTerminal) === null || _a === void 0 ? void 0 : _a.call(desktopBridge, session => {
158
+ if (session) {
159
+ void app.commands.execute('terminal:open', { name: session });
160
+ }
161
+ });
162
+ if (settingRegistry) {
163
+ try {
164
+ const settings = await settingRegistry.load(PLUGIN_ID);
165
+ const readSettings = () => {
166
+ enabled = boolOption(settings.composite.enabled, true);
167
+ notifyOnBell = boolOption(settings.composite.notifyOnBell, true);
168
+ };
169
+ readSettings();
170
+ settings.changed.connect(readSettings);
171
+ }
172
+ catch (reason) {
173
+ console.error('xtralab: failed to load terminal-notifications settings', reason);
174
+ }
175
+ }
176
+ }
177
+ };
178
+ // Run `fn`, swallowing errors so a malformed escape sequence cannot take the
179
+ // terminal's parser or bell handler down with it.
180
+ function guard(fn) {
181
+ try {
182
+ fn();
183
+ }
184
+ catch (reason) {
185
+ console.warn('xtralab: terminal notification handler failed', reason);
186
+ }
187
+ }
188
+ // `guard` for an OSC handler that reports whether it consumed the sequence.
189
+ function guardOsc(fn) {
190
+ try {
191
+ return fn();
192
+ }
193
+ catch (reason) {
194
+ console.warn('xtralab: terminal notification handler failed', reason);
195
+ return false;
196
+ }
197
+ }
198
+ // Replace control characters with spaces (so a notification cannot carry escape
199
+ // sequences), collapse whitespace, and clamp the length.
200
+ function sanitize(value) {
201
+ var _a;
202
+ let result = '';
203
+ for (const char of value) {
204
+ const code = (_a = char.codePointAt(0)) !== null && _a !== void 0 ? _a : 0;
205
+ const isControl = code < 0x20 || (code >= 0x7f && code <= 0x9f);
206
+ result += isControl ? ' ' : char;
207
+ }
208
+ return result.replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT_LENGTH);
209
+ }
210
+ function boolOption(value, fallback) {
211
+ return typeof value === 'boolean' ? value : fallback;
212
+ }
213
+ export default plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtralab",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "An opinionated JupyterLab meta-package that bundles a curated set of extensions, ships a path-first file browser, renders jupyterlab-git's text, notebook and image diffs with its own viewers, and applies a quieter default workspace configuration.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -0,0 +1,21 @@
1
+ {
2
+ "title": "xtralab terminal notifications",
3
+ "description": "Desktop notifications from coding agents running in xtralab terminals.",
4
+ "jupyter.lab.shortcuts": [],
5
+ "type": "object",
6
+ "properties": {
7
+ "enabled": {
8
+ "type": "boolean",
9
+ "title": "Enable terminal notifications",
10
+ "description": "When true, a desktop notification is shown when a program in a terminal asks for one (the iTerm2/Ghostty OSC sequences or the bell) — for example when a coding agent finishes or needs input. Notifications are suppressed while you are looking at that terminal.",
11
+ "default": true
12
+ },
13
+ "notifyOnBell": {
14
+ "type": "boolean",
15
+ "title": "Notify on the terminal bell",
16
+ "description": "When true, a plain terminal bell also raises a notification. The richer OSC 9 / OSC 777 sequences are always handled; disable this if the bell alone proves too noisy.",
17
+ "default": true
18
+ }
19
+ },
20
+ "additionalProperties": false
21
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import gitPlugins from './git';
9
9
  import launcherPlugins from './launcher';
10
10
  import menusPlugin from './menus';
11
11
  import sidebarPlugin from './sidebar';
12
+ import terminalNotificationsPlugin from './terminalNotifications';
12
13
  import terminalsPlugin from './terminals';
13
14
  import topBarPlugin from './topBar';
14
15
 
@@ -31,6 +32,7 @@ const plugins: JupyterFrontEndPlugin<unknown>[] = [
31
32
  ...launcherPlugins,
32
33
  menusPlugin,
33
34
  sidebarPlugin,
35
+ terminalNotificationsPlugin,
34
36
  terminalsPlugin,
35
37
  topBarPlugin
36
38
  ];
@@ -0,0 +1,295 @@
1
+ import {
2
+ JupyterFrontEnd,
3
+ JupyterFrontEndPlugin
4
+ } from '@jupyterlab/application';
5
+ import type { MainAreaWidget } from '@jupyterlab/apputils';
6
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
7
+ import { ITerminal, ITerminalTracker } from '@jupyterlab/terminal';
8
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
9
+
10
+ const PLUGIN_ID = 'xtralab:terminal-notifications';
11
+
12
+ // Per-terminal gap between notifications, so the OSC-9-plus-bell agents emit
13
+ // together collapses into one and a chatty program cannot flood the OS.
14
+ const NOTIFY_THROTTLE_MS = 3000;
15
+
16
+ // iTerm2 growl notification: `ESC ] 9 ; <message> BEL`. What Claude Code and
17
+ // most agents emit on their default `auto` setting once TERM_PROGRAM is set.
18
+ const OSC_ITERM2_GROWL = 9;
19
+
20
+ // rxvt/Ghostty notification: `ESC ] 777 ; notify ; <title> ; <body> BEL`.
21
+ const OSC_RXVT_NOTIFY = 777;
22
+
23
+ const MAX_TEXT_LENGTH = 256;
24
+
25
+ // The bits of an xterm.js `Terminal` we reach into. JupyterLab's terminal
26
+ // widget keeps its xterm in a private `_term` field and does not expose these
27
+ // hooks, so a structural type uses them without depending on `@xterm/xterm`. If
28
+ // the field is ever renamed, `_term` is `undefined` and notifications stop, with
29
+ // a console warning per terminal.
30
+ interface IXtermDisposable {
31
+ dispose(): void;
32
+ }
33
+ interface IXtermTerminal {
34
+ onBell(handler: () => void): IXtermDisposable;
35
+ parser: {
36
+ registerOscHandler(
37
+ ident: number,
38
+ callback: (data: string) => boolean
39
+ ): IXtermDisposable;
40
+ };
41
+ }
42
+ interface ITerminalContentInternals extends ITerminal.ITerminal {
43
+ ready: Promise<void>;
44
+ _term?: IXtermTerminal;
45
+ }
46
+
47
+ type TerminalWidget = MainAreaWidget<ITerminal.ITerminal>;
48
+
49
+ // The renderer→main bridge the desktop shell injects on the lab window. Absent
50
+ // for pip-install users in a browser, who fall back to web Notifications.
51
+ interface IDesktopBridge {
52
+ notify?: (
53
+ title: string,
54
+ body: string,
55
+ session?: string
56
+ ) => Promise<void> | void;
57
+ onFocusTerminal?: (callback: (session: string) => void) => void;
58
+ }
59
+
60
+ /**
61
+ * Turns the notifications coding agents already emit (OSC 9, OSC 777, the bell)
62
+ * into desktop notifications. JupyterLab's xterm renders these sequences but
63
+ * never forwards them to the OS, so this plugin hooks each terminal and bridges
64
+ * them through `window.xtralab.notify` (desktop) or the web Notifications API.
65
+ *
66
+ * A notification is suppressed while its terminal is the focused, active tab,
67
+ * and throttled per terminal. The desktop shell advertises
68
+ * `TERM_PROGRAM=iTerm.app` so agents emit OSC 9 in the first place, and the
69
+ * session name is forwarded so clicking a notification focuses the terminal
70
+ * that fired it.
71
+ */
72
+ const plugin: JupyterFrontEndPlugin<void> = {
73
+ id: PLUGIN_ID,
74
+ description:
75
+ 'Forwards desktop notifications emitted by agents in xtralab terminals (OSC 9, OSC 777, bell) to the operating system.',
76
+ autoStart: true,
77
+ requires: [ITerminalTracker],
78
+ optional: [ISettingRegistry, ITranslator],
79
+ activate: async (
80
+ app: JupyterFrontEnd,
81
+ tracker: ITerminalTracker,
82
+ settingRegistry: ISettingRegistry | null,
83
+ translator: ITranslator | null
84
+ ): Promise<void> => {
85
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
86
+
87
+ let enabled = true;
88
+ let notifyOnBell = true;
89
+
90
+ // Per-widget throttle timestamps and xterm hooks. WeakMaps let disposed
91
+ // widgets be collected; hooks are torn down when a tab closes.
92
+ const lastNotified = new WeakMap<TerminalWidget, number>();
93
+ const hooks = new WeakMap<TerminalWidget, IXtermDisposable[]>();
94
+
95
+ // True when the user is already looking at this terminal, so a banner would
96
+ // just be noise.
97
+ const isActivelyViewing = (widget: TerminalWidget): boolean =>
98
+ document.hasFocus() && app.shell.currentWidget === widget;
99
+
100
+ const deliver = (title: string, body: string, session?: string): void => {
101
+ const bridge = (window as Window & { xtralab?: IDesktopBridge }).xtralab;
102
+ if (bridge && typeof bridge.notify === 'function') {
103
+ void Promise.resolve(bridge.notify(title, body, session)).catch(
104
+ reason => {
105
+ console.warn('xtralab: desktop notification failed', reason);
106
+ }
107
+ );
108
+ return;
109
+ }
110
+ // Plain-browser fallback (pip install): the Web Notifications API.
111
+ if (typeof Notification === 'undefined') {
112
+ return;
113
+ }
114
+ if (Notification.permission === 'granted') {
115
+ new Notification(title, { body });
116
+ } else if (Notification.permission !== 'denied') {
117
+ void Notification.requestPermission().then(permission => {
118
+ if (permission === 'granted') {
119
+ new Notification(title, { body });
120
+ }
121
+ });
122
+ }
123
+ };
124
+
125
+ const notify = (
126
+ widget: TerminalWidget,
127
+ title: string,
128
+ body: string
129
+ ): void => {
130
+ if (!enabled || isActivelyViewing(widget)) {
131
+ return;
132
+ }
133
+ const now = Date.now();
134
+ const previous = lastNotified.get(widget);
135
+ if (previous !== undefined && now - previous < NOTIFY_THROTTLE_MS) {
136
+ return;
137
+ }
138
+ lastNotified.set(widget, now);
139
+ const session = widget.content.session?.name;
140
+ deliver(sanitize(title) || 'xtralab', sanitize(body), session);
141
+ };
142
+
143
+ // The tab label is the agent's name once the launcher or an xterm title sets
144
+ // it, so it reads well as the title for body-only sequences (OSC 9, bell).
145
+ const labelOf = (widget: TerminalWidget): string =>
146
+ widget.title.label || trans.__('Terminal');
147
+
148
+ const onOsc9 = (widget: TerminalWidget, data: string): boolean => {
149
+ // OSC 9 is overloaded: ConEmu/Windows Terminal use `9 ; <n> ; …`
150
+ // subcommands (e.g. `9 ; 4` progress), so skip a numeric-subcommand
151
+ // payload and leave it unconsumed rather than treat it as a notification.
152
+ if (/^\d+(;|$)/.test(data)) {
153
+ return false;
154
+ }
155
+ const message = sanitize(data);
156
+ if (message) {
157
+ notify(widget, labelOf(widget), message);
158
+ }
159
+ return true;
160
+ };
161
+
162
+ const onOsc777 = (widget: TerminalWidget, data: string): boolean => {
163
+ // `notify ; <title> ; <body>`; a body may contain ';'. Other OSC 777
164
+ // subcommands are left unconsumed.
165
+ const parts = data.split(';');
166
+ if (parts.shift() !== 'notify') {
167
+ return false;
168
+ }
169
+ const title = sanitize(parts.shift() ?? '') || labelOf(widget);
170
+ const body = sanitize(parts.join(';'));
171
+ if (body || title) {
172
+ notify(widget, title, body);
173
+ }
174
+ return true;
175
+ };
176
+
177
+ const onBell = (widget: TerminalWidget): void => {
178
+ if (notifyOnBell) {
179
+ notify(widget, labelOf(widget), trans.__('Activity in terminal'));
180
+ }
181
+ };
182
+
183
+ const hookWidget = (widget: TerminalWidget): void => {
184
+ if (hooks.has(widget)) {
185
+ return;
186
+ }
187
+ const content = widget.content as ITerminalContentInternals;
188
+ void content.ready
189
+ .then(() => {
190
+ const term = content._term;
191
+ if (widget.isDisposed) {
192
+ return;
193
+ }
194
+ if (!term) {
195
+ console.warn(
196
+ 'xtralab: xterm internals not found; terminal notifications are disabled for this terminal'
197
+ );
198
+ return;
199
+ }
200
+ // Handlers are wrapped so a parse slip cannot break xterm's parser; an
201
+ // OSC handler returns whether it consumed the sequence.
202
+ const disposables: IXtermDisposable[] = [
203
+ term.onBell(() => guard(() => onBell(widget))),
204
+ term.parser.registerOscHandler(OSC_ITERM2_GROWL, data =>
205
+ guardOsc(() => onOsc9(widget, data))
206
+ ),
207
+ term.parser.registerOscHandler(OSC_RXVT_NOTIFY, data =>
208
+ guardOsc(() => onOsc777(widget, data))
209
+ )
210
+ ];
211
+ hooks.set(widget, disposables);
212
+ widget.disposed.connect(() => {
213
+ for (const disposable of disposables) {
214
+ disposable.dispose();
215
+ }
216
+ hooks.delete(widget);
217
+ });
218
+ })
219
+ .catch(reason => {
220
+ console.warn(
221
+ 'xtralab: could not hook terminal notifications',
222
+ reason
223
+ );
224
+ });
225
+ };
226
+
227
+ tracker.forEach(hookWidget);
228
+ tracker.widgetAdded.connect((_, widget) => hookWidget(widget));
229
+
230
+ // Focus the terminal that fired a notification when the desktop shell
231
+ // reports a click. `terminal:open` activates its tab or reopens the session.
232
+ const desktopBridge = (window as Window & { xtralab?: IDesktopBridge })
233
+ .xtralab;
234
+ desktopBridge?.onFocusTerminal?.(session => {
235
+ if (session) {
236
+ void app.commands.execute('terminal:open', { name: session });
237
+ }
238
+ });
239
+
240
+ if (settingRegistry) {
241
+ try {
242
+ const settings = await settingRegistry.load(PLUGIN_ID);
243
+ const readSettings = (): void => {
244
+ enabled = boolOption(settings.composite.enabled, true);
245
+ notifyOnBell = boolOption(settings.composite.notifyOnBell, true);
246
+ };
247
+ readSettings();
248
+ settings.changed.connect(readSettings);
249
+ } catch (reason) {
250
+ console.error(
251
+ 'xtralab: failed to load terminal-notifications settings',
252
+ reason
253
+ );
254
+ }
255
+ }
256
+ }
257
+ };
258
+
259
+ // Run `fn`, swallowing errors so a malformed escape sequence cannot take the
260
+ // terminal's parser or bell handler down with it.
261
+ function guard(fn: () => void): void {
262
+ try {
263
+ fn();
264
+ } catch (reason) {
265
+ console.warn('xtralab: terminal notification handler failed', reason);
266
+ }
267
+ }
268
+
269
+ // `guard` for an OSC handler that reports whether it consumed the sequence.
270
+ function guardOsc(fn: () => boolean): boolean {
271
+ try {
272
+ return fn();
273
+ } catch (reason) {
274
+ console.warn('xtralab: terminal notification handler failed', reason);
275
+ return false;
276
+ }
277
+ }
278
+
279
+ // Replace control characters with spaces (so a notification cannot carry escape
280
+ // sequences), collapse whitespace, and clamp the length.
281
+ function sanitize(value: string): string {
282
+ let result = '';
283
+ for (const char of value) {
284
+ const code = char.codePointAt(0) ?? 0;
285
+ const isControl = code < 0x20 || (code >= 0x7f && code <= 0x9f);
286
+ result += isControl ? ' ' : char;
287
+ }
288
+ return result.replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT_LENGTH);
289
+ }
290
+
291
+ function boolOption(value: unknown, fallback: boolean): boolean {
292
+ return typeof value === 'boolean' ? value : fallback;
293
+ }
294
+
295
+ export default plugin;