xtralab 0.9.0 → 0.11.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 +40 -9
- package/lib/commandBar/index.d.ts +14 -0
- package/lib/commandBar/index.js +114 -0
- package/lib/fileBrowser/fileBrowser.js +6 -1
- package/lib/highlight/index.d.ts +15 -0
- package/lib/highlight/index.js +214 -0
- package/lib/index.js +15 -1
- package/lib/launcher/agents.d.ts +7 -0
- package/lib/launcher/agents.js +24 -0
- package/lib/launcher/editorRegistry.js +5 -3
- package/lib/launcher/editors.d.ts +8 -0
- package/lib/launcher/editors.js +17 -0
- package/lib/launcher/index.js +4 -0
- package/lib/launcher/schemaDefaults.d.ts +15 -0
- package/lib/launcher/schemaDefaults.js +44 -0
- package/lib/omnibox/files.d.ts +7 -0
- package/lib/omnibox/files.js +50 -0
- package/lib/omnibox/index.d.ts +14 -0
- package/lib/omnibox/index.js +83 -0
- package/lib/omnibox/model.d.ts +45 -0
- package/lib/omnibox/model.js +142 -0
- package/lib/omnibox/tokens.d.ts +23 -0
- package/lib/omnibox/tokens.js +11 -0
- package/lib/omnibox/widget.d.ts +36 -0
- package/lib/omnibox/widget.js +161 -0
- package/lib/searchReplace/index.d.ts +25 -0
- package/lib/searchReplace/index.js +68 -0
- package/lib/showOutput/index.d.ts +17 -0
- package/lib/showOutput/index.js +154 -0
- package/lib/terminalNotifications/index.d.ts +15 -0
- package/lib/terminalNotifications/index.js +213 -0
- package/lib/terminals/index.d.ts +4 -0
- package/lib/terminals/index.js +65 -16
- package/lib/terminals/model.d.ts +56 -3
- package/lib/terminals/model.js +261 -5
- package/lib/terminals/widget.d.ts +3 -1
- package/lib/terminals/widget.js +11 -3
- package/lib/walkthrough/index.d.ts +14 -0
- package/lib/walkthrough/index.js +162 -0
- package/lib/walkthrough/panel.d.ts +54 -0
- package/lib/walkthrough/panel.js +98 -0
- package/package.json +1 -1
- package/schema/launcher.json +3 -2
- package/schema/terminal-notifications.json +21 -0
- package/schema/terminals.json +15 -0
- package/src/commandBar/index.ts +165 -0
- package/src/fileBrowser/fileBrowser.tsx +8 -1
- package/src/highlight/index.ts +251 -0
- package/src/index.ts +15 -1
- package/src/launcher/agents.ts +25 -0
- package/src/launcher/editorRegistry.ts +8 -4
- package/src/launcher/editors.ts +18 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/schemaDefaults.ts +53 -0
- package/src/omnibox/files.ts +68 -0
- package/src/omnibox/index.ts +105 -0
- package/src/omnibox/model.ts +210 -0
- package/src/omnibox/tokens.ts +29 -0
- package/src/omnibox/widget.tsx +322 -0
- package/src/searchReplace/index.ts +86 -0
- package/src/showOutput/index.ts +199 -0
- package/src/terminalNotifications/index.ts +295 -0
- package/src/terminals/index.ts +79 -16
- package/src/terminals/model.ts +304 -3
- package/src/terminals/widget.tsx +21 -4
- package/src/walkthrough/index.ts +202 -0
- package/src/walkthrough/panel.ts +152 -0
- package/style/commandBar.css +104 -0
- package/style/highlight.css +11 -0
- package/style/index.css +5 -0
- package/style/index.js +5 -0
- package/style/omnibox.css +120 -0
- package/style/showOutput.css +11 -0
- package/style/terminals.css +54 -10
- package/style/walkthrough.css +80 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ILabShell } from '@jupyterlab/application';
|
|
2
|
+
import { ICommandPalette } from '@jupyterlab/apputils';
|
|
3
|
+
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
4
|
+
const PLUGIN_ID = 'xtralab:search-replace';
|
|
5
|
+
const COMMAND_ID = 'xtralab:activate-search-replace';
|
|
6
|
+
/**
|
|
7
|
+
* Widget id of the panel contributed by `jupyterlab-search-replace`. The
|
|
8
|
+
* extension adds it to the left sidebar but registers no command of its own,
|
|
9
|
+
* so there is nothing to bind a shortcut to out of the box — this plugin adds
|
|
10
|
+
* the missing command.
|
|
11
|
+
*/
|
|
12
|
+
const SEARCH_REPLACE_WIDGET_ID = 'jp-search-replace';
|
|
13
|
+
/**
|
|
14
|
+
* Bind Accel+Shift+F (Cmd+Shift+F on macOS, Ctrl+Shift+F elsewhere) to the
|
|
15
|
+
* project-wide Search and Replace panel, matching the equivalent shortcut in
|
|
16
|
+
* editors like VS Code.
|
|
17
|
+
*
|
|
18
|
+
* Two pieces are needed because that chord is already taken: JupyterLab core
|
|
19
|
+
* binds Accel+Shift+F to `filebrowser:toggle-main`.
|
|
20
|
+
*
|
|
21
|
+
* 1. The chord is freed declaratively. `SettingRegistry.reconcileShortcuts`
|
|
22
|
+
* keys collisions and disables on (keys, selector) — not on command — so the
|
|
23
|
+
* `@jupyterlab/shortcuts-extension:shortcuts` override in
|
|
24
|
+
* `default_setting_overrides.d/00-xtralab.json` disables the file-browser
|
|
25
|
+
* binding. The file browser stays reachable from its sidebar tab and the
|
|
26
|
+
* View menu; only the keyboard shortcut moves.
|
|
27
|
+
*
|
|
28
|
+
* 2. The new binding is added imperatively below rather than as another shipped
|
|
29
|
+
* default. A `disabled` default and a re-binding default land on the same
|
|
30
|
+
* (keys, selector) slot, and the disable suppresses every default on that
|
|
31
|
+
* slot — including the re-binding. `commands.addKeyBinding` runs outside the
|
|
32
|
+
* reconcile pass, so it is unaffected and wins cleanly once the file-browser
|
|
33
|
+
* binding is gone.
|
|
34
|
+
*/
|
|
35
|
+
const plugin = {
|
|
36
|
+
id: PLUGIN_ID,
|
|
37
|
+
description: 'Activate the Search and Replace panel with Accel+Shift+F (Cmd/Ctrl+Shift+F).',
|
|
38
|
+
autoStart: true,
|
|
39
|
+
requires: [ILabShell],
|
|
40
|
+
optional: [ICommandPalette, ITranslator],
|
|
41
|
+
activate: (app, labShell, palette, translator) => {
|
|
42
|
+
const trans = (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab');
|
|
43
|
+
const { commands } = app;
|
|
44
|
+
commands.addCommand(COMMAND_ID, {
|
|
45
|
+
label: trans.__('Search and Replace'),
|
|
46
|
+
caption: trans.__('Show the Search and Replace panel'),
|
|
47
|
+
execute: () => {
|
|
48
|
+
// `activateById` expands the left sidebar when collapsed, selects the
|
|
49
|
+
// tab, and the widget focuses its search input from `onAfterShow`, so
|
|
50
|
+
// the user can start typing a query immediately. It no-ops if
|
|
51
|
+
// `jupyterlab-search-replace` is unavailable.
|
|
52
|
+
labShell.activateById(SEARCH_REPLACE_WIDGET_ID);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
commands.addKeyBinding({
|
|
56
|
+
command: COMMAND_ID,
|
|
57
|
+
keys: ['Accel Shift F'],
|
|
58
|
+
selector: 'body'
|
|
59
|
+
});
|
|
60
|
+
if (palette) {
|
|
61
|
+
palette.addItem({
|
|
62
|
+
command: COMMAND_ID,
|
|
63
|
+
category: trans.__('File Operations')
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
export default plugin;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { JupyterFrontEndPlugin } from '@jupyterlab/application';
|
|
2
|
+
/**
|
|
3
|
+
* Contribute `xtralab:show`: render a single MIME bundle into a main-area
|
|
4
|
+
* panel using the application's render-mime registry.
|
|
5
|
+
*
|
|
6
|
+
* This is the kernel-free counterpart to running a notebook. A coding agent
|
|
7
|
+
* generates the content itself (a Vega-Lite spec, a Markdown explainer, an
|
|
8
|
+
* HTML fragment, an SVG, a base64 image) and hands it to this command, which
|
|
9
|
+
* renders it with the same renderers JupyterLab uses for cell output and docks
|
|
10
|
+
* the result beside the code. Nothing is written to disk and no kernel is
|
|
11
|
+
* required, so it works even where the kernel has no plotting libraries.
|
|
12
|
+
*
|
|
13
|
+
* Repeated calls with the same `id` reuse one panel, so a walkthrough can
|
|
14
|
+
* refresh the view in place instead of piling up tabs.
|
|
15
|
+
*/
|
|
16
|
+
declare const plugin: JupyterFrontEndPlugin<void>;
|
|
17
|
+
export default plugin;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { ILabShell } from '@jupyterlab/application';
|
|
2
|
+
import { ICommandPalette } from '@jupyterlab/apputils';
|
|
3
|
+
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
|
|
4
|
+
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
5
|
+
import { Panel } from '@lumino/widgets';
|
|
6
|
+
const PLUGIN_ID = 'xtralab:show-output';
|
|
7
|
+
/** Render arbitrary rich content into a panel, with no notebook and no kernel. */
|
|
8
|
+
const SHOW_COMMAND = 'xtralab:show';
|
|
9
|
+
/**
|
|
10
|
+
* Parse a JSON-typed value that may have arrived as a string. Agents and the
|
|
11
|
+
* MCP bridge sometimes serialize a spec (e.g. a Vega-Lite object) to a string;
|
|
12
|
+
* for `application/...json` MIME types we accept either and parse the string.
|
|
13
|
+
*/
|
|
14
|
+
function coerceData(mimeType, data) {
|
|
15
|
+
if (typeof data === 'string' && /\+?json$/.test(mimeType)) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(data);
|
|
18
|
+
}
|
|
19
|
+
catch (_a) {
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return data;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Contribute `xtralab:show`: render a single MIME bundle into a main-area
|
|
27
|
+
* panel using the application's render-mime registry.
|
|
28
|
+
*
|
|
29
|
+
* This is the kernel-free counterpart to running a notebook. A coding agent
|
|
30
|
+
* generates the content itself (a Vega-Lite spec, a Markdown explainer, an
|
|
31
|
+
* HTML fragment, an SVG, a base64 image) and hands it to this command, which
|
|
32
|
+
* renders it with the same renderers JupyterLab uses for cell output and docks
|
|
33
|
+
* the result beside the code. Nothing is written to disk and no kernel is
|
|
34
|
+
* required, so it works even where the kernel has no plotting libraries.
|
|
35
|
+
*
|
|
36
|
+
* Repeated calls with the same `id` reuse one panel, so a walkthrough can
|
|
37
|
+
* refresh the view in place instead of piling up tabs.
|
|
38
|
+
*/
|
|
39
|
+
const plugin = {
|
|
40
|
+
id: PLUGIN_ID,
|
|
41
|
+
description: 'Render rich content (Markdown, Vega-Lite, HTML, images) into a panel.',
|
|
42
|
+
autoStart: true,
|
|
43
|
+
requires: [IRenderMimeRegistry, ILabShell],
|
|
44
|
+
optional: [ICommandPalette, ITranslator],
|
|
45
|
+
activate: (app, rendermime, labShell, palette, translator) => {
|
|
46
|
+
const { commands } = app;
|
|
47
|
+
const trans = (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab');
|
|
48
|
+
// One reusable panel per `id`, so repeated shows refresh in place.
|
|
49
|
+
const panels = new Map();
|
|
50
|
+
commands.addCommand(SHOW_COMMAND, {
|
|
51
|
+
label: trans.__('Show Rich Output'),
|
|
52
|
+
caption: trans.__('Render Markdown, a Vega-Lite chart, HTML, or an image in a panel'),
|
|
53
|
+
describedBy: {
|
|
54
|
+
args: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
required: ['mimeType', 'data'],
|
|
57
|
+
properties: {
|
|
58
|
+
mimeType: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
description: 'MIME type to render, e.g. "text/markdown", "application/vnd.vegalite.v5+json", "text/html", "image/svg+xml", "image/png".'
|
|
61
|
+
},
|
|
62
|
+
data: {
|
|
63
|
+
description: 'The content. A string for text/image types (base64 for "image/png"); an object for JSON types such as a Vega-Lite spec.'
|
|
64
|
+
},
|
|
65
|
+
label: {
|
|
66
|
+
type: 'string',
|
|
67
|
+
description: 'Title for the panel tab. Defaults to "Output".'
|
|
68
|
+
},
|
|
69
|
+
id: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description: 'Panel identifier. Calls sharing an `id` reuse one panel; defaults to "default".'
|
|
72
|
+
},
|
|
73
|
+
area: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
description: 'Where the panel docks: "right" (default) puts it in the side area so the editor stays full width; "main" puts it in the document area (split with `mode`).'
|
|
76
|
+
},
|
|
77
|
+
mode: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
description: 'Only when `area` is "main": placement relative to the active tab, e.g. "split-right" (default), "split-bottom", "tab-after".'
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
execute: async (args) => {
|
|
85
|
+
const mimeType = typeof args['mimeType'] === 'string' ? args['mimeType'] : undefined;
|
|
86
|
+
if (!mimeType || args['data'] === undefined) {
|
|
87
|
+
throw new Error('xtralab:show requires "mimeType" and "data"');
|
|
88
|
+
}
|
|
89
|
+
const data = coerceData(mimeType, args['data']);
|
|
90
|
+
const label = typeof args['label'] === 'string'
|
|
91
|
+
? args['label']
|
|
92
|
+
: trans.__('Output');
|
|
93
|
+
const id = typeof args['id'] === 'string' ? args['id'] : 'default';
|
|
94
|
+
const area = args['area'] === 'main' ? 'main' : 'right';
|
|
95
|
+
const mode = typeof args['mode'] === 'string' ? args['mode'] : 'split-right';
|
|
96
|
+
const bundle = { [mimeType]: data };
|
|
97
|
+
const chosen = rendermime.preferredMimeType(bundle, 'any');
|
|
98
|
+
if (!chosen) {
|
|
99
|
+
throw new Error(`xtralab:show: no renderer for MIME type "${mimeType}"`);
|
|
100
|
+
}
|
|
101
|
+
const model = rendermime.createModel({ data: bundle, trusted: true });
|
|
102
|
+
const renderer = rendermime.createRenderer(chosen);
|
|
103
|
+
// A scrollable Panel, not a MainAreaWidget: MainAreaWidget does not lay
|
|
104
|
+
// out its content in the side area, and the panel needs to scroll when
|
|
105
|
+
// the content is taller than it (style/showOutput.css).
|
|
106
|
+
const widget = new Panel();
|
|
107
|
+
widget.addClass('jp-xtralab-ShowPanel');
|
|
108
|
+
widget.id = `xtralab-show-${id}`;
|
|
109
|
+
widget.title.label = label;
|
|
110
|
+
widget.title.closable = true;
|
|
111
|
+
widget.addWidget(renderer);
|
|
112
|
+
// Replace any existing panel with this id so shows refresh in place.
|
|
113
|
+
const existing = panels.get(id);
|
|
114
|
+
if (existing && !existing.isDisposed) {
|
|
115
|
+
existing.dispose();
|
|
116
|
+
}
|
|
117
|
+
panels.set(id, widget);
|
|
118
|
+
widget.disposed.connect(() => {
|
|
119
|
+
if (panels.get(id) === widget) {
|
|
120
|
+
panels.delete(id);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
// Attach before rendering so renderers that need layout (Vega) size
|
|
124
|
+
// correctly. Default to the side area so the editor keeps full width;
|
|
125
|
+
// only split the document area when `area` is "main".
|
|
126
|
+
if (area === 'main') {
|
|
127
|
+
labShell.add(widget, 'main', {
|
|
128
|
+
mode: mode,
|
|
129
|
+
activate: true
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
labShell.add(widget, 'right', { rank: 900 });
|
|
134
|
+
}
|
|
135
|
+
// Reveal the panel (the side area does not open from `add` alone).
|
|
136
|
+
labShell.activateById(widget.id);
|
|
137
|
+
try {
|
|
138
|
+
await renderer.renderModel(model);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
widget.dispose();
|
|
142
|
+
throw error instanceof Error
|
|
143
|
+
? error
|
|
144
|
+
: new Error(`xtralab:show: failed to render "${mimeType}"`);
|
|
145
|
+
}
|
|
146
|
+
return trans.__('Rendered %1 in "%2"', mimeType, label);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
if (palette) {
|
|
150
|
+
palette.addItem({ command: SHOW_COMMAND, category: trans.__('Other') });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
export default plugin;
|
|
@@ -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/lib/terminals/index.d.ts
CHANGED
|
@@ -9,6 +9,10 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
|
|
|
9
9
|
* JupyterLab's built-in "Running Terminals and Kernels" panel lists the
|
|
10
10
|
* same sessions but only by their `terminals/<n>` name, so this panel
|
|
11
11
|
* resolves and caches the published title to show something meaningful.
|
|
12
|
+
* Rows running a coding agent carry a smaller second line below the
|
|
13
|
+
* title with the agent's latest line of output, so the panel shows what
|
|
14
|
+
* each agent is doing at a glance — on by default, and switchable off
|
|
15
|
+
* through the `showAgentActivity` setting.
|
|
12
16
|
*
|
|
13
17
|
* Clicking a row activates the existing tab if one is open, or reopens
|
|
14
18
|
* the session in a fresh terminal widget (`terminal:open`). The inline
|
package/lib/terminals/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ILayoutRestorer } from '@jupyterlab/application';
|
|
2
2
|
import { Dialog, showDialog } from '@jupyterlab/apputils';
|
|
3
|
+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
3
4
|
import { ITerminalTracker } from '@jupyterlab/terminal';
|
|
4
5
|
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
5
6
|
import { MenuSvg, terminalIcon } from '@jupyterlab/ui-components';
|
|
@@ -19,6 +20,10 @@ const PLUGIN_ID = 'xtralab:terminals';
|
|
|
19
20
|
* JupyterLab's built-in "Running Terminals and Kernels" panel lists the
|
|
20
21
|
* same sessions but only by their `terminals/<n>` name, so this panel
|
|
21
22
|
* resolves and caches the published title to show something meaningful.
|
|
23
|
+
* Rows running a coding agent carry a smaller second line below the
|
|
24
|
+
* title with the agent's latest line of output, so the panel shows what
|
|
25
|
+
* each agent is doing at a glance — on by default, and switchable off
|
|
26
|
+
* through the `showAgentActivity` setting.
|
|
22
27
|
*
|
|
23
28
|
* Clicking a row activates the existing tab if one is open, or reopens
|
|
24
29
|
* the session in a fresh terminal widget (`terminal:open`). The inline
|
|
@@ -43,26 +48,30 @@ const plugin = {
|
|
|
43
48
|
ITranslator,
|
|
44
49
|
IAgentRegistry,
|
|
45
50
|
IEditorRegistry,
|
|
46
|
-
IAgentSessions
|
|
51
|
+
IAgentSessions,
|
|
52
|
+
ISettingRegistry
|
|
47
53
|
],
|
|
48
|
-
activate: (app, tracker, restorer, translator, agentRegistry, editorRegistry, agentSessions) => {
|
|
54
|
+
activate: (app, tracker, restorer, translator, agentRegistry, editorRegistry, agentSessions, settingRegistry) => {
|
|
49
55
|
const trans = (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab');
|
|
50
|
-
// Resolve a
|
|
51
|
-
// logo, the matching editor's logo (Neovim/Vim), or the plain
|
|
52
|
-
// icon when nothing recognised is running.
|
|
53
|
-
//
|
|
56
|
+
// Resolve a running agent/editor identifier to an icon: the matching
|
|
57
|
+
// agent's logo, the matching editor's logo (Neovim/Vim), or the plain
|
|
58
|
+
// terminal icon when nothing recognised is running. Detection reports
|
|
59
|
+
// either the configured `command` or the canonical `id` (see
|
|
60
|
+
// `detectCommands`), so a row whose command is an alias — e.g. `ccm` for
|
|
61
|
+
// `claude` — still resolves to the right logo via its id. Reads the
|
|
62
|
+
// registries live so it tracks settings changes. Used to badge each row.
|
|
54
63
|
const iconForCommand = (command) => {
|
|
55
64
|
var _a;
|
|
56
65
|
if (!command) {
|
|
57
66
|
return terminalIcon;
|
|
58
67
|
}
|
|
59
|
-
const agent = agentRegistry === null || agentRegistry === void 0 ? void 0 : agentRegistry.agents.find(a => a.command === command);
|
|
68
|
+
const agent = agentRegistry === null || agentRegistry === void 0 ? void 0 : agentRegistry.agents.find(a => a.command === command || a.id === command);
|
|
60
69
|
if (agent) {
|
|
61
70
|
return agent.icon;
|
|
62
71
|
}
|
|
63
72
|
// A terminal running an editor (Neovim/Vim, or a user-configured one) is
|
|
64
73
|
// badged with its logo, the same as an agent.
|
|
65
|
-
const editor = editorRegistry === null || editorRegistry === void 0 ? void 0 : editorRegistry.editors.find(e => e.command === command);
|
|
74
|
+
const editor = editorRegistry === null || editorRegistry === void 0 ? void 0 : editorRegistry.editors.find(e => e.command === command || e.id === command);
|
|
66
75
|
return (_a = editor === null || editor === void 0 ? void 0 : editor.icon) !== null && _a !== void 0 ? _a : terminalIcon;
|
|
67
76
|
};
|
|
68
77
|
const registry = new SessionRegistry({
|
|
@@ -73,16 +82,32 @@ const plugin = {
|
|
|
73
82
|
// nothing while a notebook or other widget is current instead).
|
|
74
83
|
shell: app.shell,
|
|
75
84
|
agentSessions,
|
|
76
|
-
// The
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
85
|
+
// The names the server should look for in each terminal's process tree:
|
|
86
|
+
// for every agent and editor, its configured `command` plus its canonical
|
|
87
|
+
// `id`. The id is the real CLI name (e.g. `claude`, `nvim`), so an agent
|
|
88
|
+
// whose command points at an alias — e.g. `ccm` running `claude` — is
|
|
89
|
+
// still recognised by the process it actually spawns. Read live so the
|
|
90
|
+
// list tracks settings changes; deduplicated since command and id usually
|
|
91
|
+
// coincide.
|
|
80
92
|
detectCommands: () => {
|
|
81
93
|
var _a, _b;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
const names = new Set();
|
|
95
|
+
for (const agent of (_a = agentRegistry === null || agentRegistry === void 0 ? void 0 : agentRegistry.agents) !== null && _a !== void 0 ? _a : []) {
|
|
96
|
+
names.add(agent.command);
|
|
97
|
+
names.add(agent.id);
|
|
98
|
+
}
|
|
99
|
+
for (const editor of (_b = editorRegistry === null || editorRegistry === void 0 ? void 0 : editorRegistry.editors) !== null && _b !== void 0 ? _b : []) {
|
|
100
|
+
names.add(editor.command);
|
|
101
|
+
names.add(editor.id);
|
|
102
|
+
}
|
|
103
|
+
return Array.from(names);
|
|
104
|
+
},
|
|
105
|
+
// Only coding agents (not editors) get a latest-activity line, so the
|
|
106
|
+
// registry asks this whether a detected command/id is an agent before
|
|
107
|
+
// reading its output buffer — editor terminals stay badge-only.
|
|
108
|
+
isAgentCommand: (command) => {
|
|
109
|
+
var _a;
|
|
110
|
+
return (_a = agentRegistry === null || agentRegistry === void 0 ? void 0 : agentRegistry.agents.some(a => a.command === command || a.id === command)) !== null && _a !== void 0 ? _a : false;
|
|
86
111
|
}
|
|
87
112
|
});
|
|
88
113
|
// Mirror the agent/editor detected in each open terminal onto its main-area
|
|
@@ -189,6 +214,30 @@ const plugin = {
|
|
|
189
214
|
if (restorer) {
|
|
190
215
|
restorer.add(panel, panel.id);
|
|
191
216
|
}
|
|
217
|
+
// The latest-activity line under each agent row is on by default; honour
|
|
218
|
+
// the `showAgentActivity` setting so it can be turned off. Read on load and
|
|
219
|
+
// re-applied on every change.
|
|
220
|
+
if (settingRegistry) {
|
|
221
|
+
settingRegistry
|
|
222
|
+
.load(PLUGIN_ID)
|
|
223
|
+
.then(settings => {
|
|
224
|
+
const applyActivitySetting = () => {
|
|
225
|
+
registry.setActivityEnabled(boolOption(settings.composite.showAgentActivity, true));
|
|
226
|
+
};
|
|
227
|
+
applyActivitySetting();
|
|
228
|
+
// Bind the slot to the panel so disposing the panel (which clears
|
|
229
|
+
// its signal data) drops the connection — the disposed registry is
|
|
230
|
+
// then never reached on a later settings change.
|
|
231
|
+
settings.changed.connect(applyActivitySetting, panel);
|
|
232
|
+
})
|
|
233
|
+
.catch(reason => {
|
|
234
|
+
console.error(`xtralab: failed to load ${PLUGIN_ID} settings`, reason);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
192
237
|
}
|
|
193
238
|
};
|
|
239
|
+
/** Read a boolean setting value, falling back when it is missing or invalid. */
|
|
240
|
+
function boolOption(value, fallback) {
|
|
241
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
242
|
+
}
|
|
194
243
|
export default plugin;
|