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 +17 -0
- package/lib/index.js +2 -0
- package/lib/terminalNotifications/index.d.ts +15 -0
- package/lib/terminalNotifications/index.js +213 -0
- package/package.json +1 -1
- package/schema/terminal-notifications.json +21 -0
- package/src/index.ts +2 -0
- package/src/terminalNotifications/index.ts +295 -0
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.
|
|
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;
|