xtralab 0.1.1 → 0.2.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.
Files changed (65) hide show
  1. package/README.md +86 -0
  2. package/lib/fileBrowser/fileBrowser.js +71 -1
  3. package/lib/fileBrowser/gitignore.d.ts +29 -0
  4. package/lib/fileBrowser/gitignore.js +74 -0
  5. package/lib/fileBrowser/index.js +1 -1
  6. package/lib/git/api.d.ts +11 -0
  7. package/lib/git/api.js +19 -0
  8. package/lib/git/commands.d.ts +3 -1
  9. package/lib/git/commands.js +10 -4
  10. package/lib/git/diffWidget.d.ts +11 -0
  11. package/lib/git/diffWidget.js +90 -9
  12. package/lib/git/index.js +23 -7
  13. package/lib/git/panelComponent.d.ts +3 -1
  14. package/lib/git/panelComponent.js +6 -3
  15. package/lib/index.js +9 -1
  16. package/lib/launcher/agents.d.ts +78 -0
  17. package/lib/launcher/agents.js +168 -0
  18. package/lib/launcher/availability.d.ts +12 -0
  19. package/lib/launcher/availability.js +53 -0
  20. package/lib/launcher/commands.d.ts +22 -0
  21. package/lib/launcher/commands.js +89 -0
  22. package/lib/launcher/dashboard.d.ts +46 -0
  23. package/lib/launcher/dashboard.js +164 -0
  24. package/lib/launcher/icons.d.ts +33 -0
  25. package/lib/launcher/icons.js +113 -0
  26. package/lib/launcher/index.d.ts +23 -0
  27. package/lib/launcher/index.js +191 -0
  28. package/lib/launcher/invocation.d.ts +29 -0
  29. package/lib/launcher/invocation.js +42 -0
  30. package/lib/menus/index.d.ts +14 -0
  31. package/lib/menus/index.js +102 -0
  32. package/lib/sidebar/index.d.ts +18 -0
  33. package/lib/sidebar/index.js +155 -0
  34. package/lib/statusBar/index.d.ts +14 -0
  35. package/lib/statusBar/index.js +89 -0
  36. package/lib/statusBar/widget.d.ts +121 -0
  37. package/lib/statusBar/widget.js +237 -0
  38. package/package.json +10 -2
  39. package/schema/launcher.json +70 -0
  40. package/schema/sidebar.json +57 -0
  41. package/src/fileBrowser/fileBrowser.tsx +75 -1
  42. package/src/fileBrowser/gitignore.ts +86 -0
  43. package/src/fileBrowser/index.ts +1 -1
  44. package/src/git/api.ts +20 -0
  45. package/src/git/commands.ts +19 -5
  46. package/src/git/diffWidget.tsx +109 -13
  47. package/src/git/index.ts +32 -6
  48. package/src/git/panelComponent.tsx +17 -5
  49. package/src/index.ts +9 -1
  50. package/src/launcher/agents.ts +248 -0
  51. package/src/launcher/availability.ts +73 -0
  52. package/src/launcher/commands.ts +103 -0
  53. package/src/launcher/dashboard.tsx +356 -0
  54. package/src/launcher/icons.ts +123 -0
  55. package/src/launcher/index.ts +228 -0
  56. package/src/launcher/invocation.ts +45 -0
  57. package/src/menus/index.ts +124 -0
  58. package/src/sidebar/index.ts +195 -0
  59. package/src/statusBar/index.ts +115 -0
  60. package/src/statusBar/widget.tsx +318 -0
  61. package/style/git.css +4 -8
  62. package/style/index.css +2 -0
  63. package/style/index.js +2 -0
  64. package/style/launcher.css +323 -0
  65. package/style/statusBar.css +95 -0
@@ -0,0 +1,191 @@
1
+ import { ILabShell } from '@jupyterlab/application';
2
+ import { ICommandPalette, MainAreaWidget } from '@jupyterlab/apputils';
3
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
4
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
5
+ import { launcherIcon } from '@jupyterlab/ui-components';
6
+ import { find } from '@lumino/algorithm';
7
+ import { mergeAgents } from './agents';
8
+ import { fetchAvailableCommands } from './availability';
9
+ import { CREATE_LAUNCHER_COMMAND, registerAgentCommands } from './commands';
10
+ import { LauncherDashboard } from './dashboard';
11
+ const PLUGIN_ID = 'xtralab:launcher';
12
+ /**
13
+ * The xtralab launcher plugin. Replaces the stock JupyterLab launcher
14
+ * (which is disabled via `package.json`'s `jupyterlab.disabledExtensions`)
15
+ * with an agent-focused dashboard: an optional initial prompt, a row of
16
+ * agent buttons (Claude, Codex, Gemini, …), and a collapsible list of
17
+ * changed files (clickable into the diff viewer) below them. Clicking an
18
+ * agent opens a fresh terminal and pipes the agent's command into it; if
19
+ * the prompt textarea is non-empty, the prompt is shell-quoted and
20
+ * spliced into the launch line per the agent's `promptArgs` recipe.
21
+ *
22
+ * The agent list is the merge of xtralab's defaults with the user's
23
+ * `xtralab:launcher` settings, then filtered by a server-side `which`
24
+ * check so users only see agents that are actually installed. Agents with
25
+ * `requireAvailable: false` (e.g. shell aliases) skip the filter.
26
+ *
27
+ * The plugin deliberately does NOT provide the `ILauncher` token: other
28
+ * extensions register notebook/console/terminal cards on it as a side
29
+ * effect, which would defeat the point of the agent-only launcher. If an
30
+ * extension needs to surface itself, we'll add it here explicitly.
31
+ */
32
+ const plugin = {
33
+ id: PLUGIN_ID,
34
+ description: 'An agent-focused launcher that replaces the default JupyterLab launcher.',
35
+ autoStart: true,
36
+ optional: [ILabShell, ICommandPalette, ISettingRegistry, ITranslator],
37
+ activate: async (app, labShell, palette, settingRegistry, translator) => {
38
+ const { commands, shell } = app;
39
+ const trans = (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab');
40
+ // The currently active agent list. Updated whenever settings change so
41
+ // every freshly-created launcher widget renders the latest list.
42
+ let currentAgents = [];
43
+ // Track command registrations so a settings change can wipe them
44
+ // before re-registering — without this the command palette would
45
+ // accumulate stale entries when the agent list shrinks.
46
+ let registered = null;
47
+ const applyAgents = async (overrides) => {
48
+ const agents = mergeAgents(overrides);
49
+ const filtered = await filterByAvailability(agents);
50
+ registered === null || registered === void 0 ? void 0 : registered.dispose();
51
+ registered = registerAgentCommands(app, filtered);
52
+ currentAgents = filtered;
53
+ };
54
+ const readOverrides = (settings) => {
55
+ const raw = settings.composite.agents;
56
+ return Array.isArray(raw) ? raw : [];
57
+ };
58
+ if (settingRegistry) {
59
+ try {
60
+ const settings = await settingRegistry.load(PLUGIN_ID);
61
+ await applyAgents(readOverrides(settings));
62
+ settings.changed.connect(async () => {
63
+ try {
64
+ await applyAgents(readOverrides(settings));
65
+ }
66
+ catch (reason) {
67
+ console.error('xtralab: failed to reapply launcher settings', reason);
68
+ }
69
+ });
70
+ }
71
+ catch (reason) {
72
+ console.error('xtralab: failed to load launcher settings', reason);
73
+ // Settings load failed — fall back to defaults so the launcher
74
+ // still has cards instead of going silent.
75
+ await applyAgents([]);
76
+ }
77
+ }
78
+ else {
79
+ await applyAgents([]);
80
+ }
81
+ commands.addCommand(CREATE_LAUNCHER_COMMAND, {
82
+ label: trans.__('New Launcher'),
83
+ execute: (args) => {
84
+ const id = Private.nextId();
85
+ const onAgentLaunch = (item) => {
86
+ // When an agent command returns a Widget that ends up in the main
87
+ // area, slot it where this launcher used to sit so opening an
88
+ // agent feels like the launcher transformed into the terminal.
89
+ // Disposing the inner ReactWidget cascades to the MainAreaWidget
90
+ // host via its `content.disposed` connection.
91
+ if (find(shell.widgets('main'), w => w === item)) {
92
+ shell.add(item, 'main', { ref: id });
93
+ launcher.dispose();
94
+ }
95
+ };
96
+ const launcher = new LauncherDashboard({
97
+ commands,
98
+ agents: currentAgents,
99
+ onAgentLaunch,
100
+ // Empty repoPath/cwd matches the JupyterLab convention used by
101
+ // the git panel and the stock launcher: let the server resolve
102
+ // the working tree from its root directory.
103
+ repoPath: '',
104
+ cwd: ''
105
+ });
106
+ launcher.title.icon = launcherIcon;
107
+ launcher.title.label = trans.__('Launcher');
108
+ const main = new MainAreaWidget({ content: launcher });
109
+ // Hide the close button when the launcher is the only thing in the
110
+ // main area: closing it would leave the user staring at an empty
111
+ // shell with no way back.
112
+ main.title.closable = !!Array.from(shell.widgets('main')).length;
113
+ main.id = id;
114
+ shell.add(main, 'main', {
115
+ activate: args['activate'],
116
+ ref: args['ref']
117
+ });
118
+ if (labShell) {
119
+ labShell.layoutModified.connect(() => {
120
+ main.title.closable =
121
+ Array.from(labShell.widgets('main')).length > 1;
122
+ }, main);
123
+ }
124
+ return main;
125
+ }
126
+ });
127
+ if (palette) {
128
+ palette.addItem({
129
+ command: CREATE_LAUNCHER_COMMAND,
130
+ category: trans.__('Launcher')
131
+ });
132
+ }
133
+ if (labShell) {
134
+ void app.restored.then(() => {
135
+ const maybeCreate = () => {
136
+ if (labShell.isEmpty('main')) {
137
+ void commands.execute(CREATE_LAUNCHER_COMMAND);
138
+ }
139
+ };
140
+ labShell.layoutModified.connect(() => {
141
+ maybeCreate();
142
+ });
143
+ // Layout has settled by the time `app.restored` resolves; if it's
144
+ // empty (fresh start, no restored widgets) the connect above won't
145
+ // fire on its own — kick it off here.
146
+ maybeCreate();
147
+ });
148
+ labShell.addButtonEnabled = true;
149
+ labShell.addRequested.connect((sender, arg) => {
150
+ var _a;
151
+ const ref = ((_a = arg.currentTitle) === null || _a === void 0 ? void 0 : _a.owner.id) ||
152
+ arg.titles[arg.titles.length - 1].owner.id;
153
+ return commands.execute(CREATE_LAUNCHER_COMMAND, { ref });
154
+ });
155
+ }
156
+ }
157
+ };
158
+ /**
159
+ * Drop agents whose command isn't on `$PATH`, except for entries that
160
+ * explicitly opt out of the check via `requireAvailable: false` (typical
161
+ * use case: shell aliases the user wants surfaced regardless).
162
+ *
163
+ * If the availability endpoint can't be reached we fail open and return
164
+ * the input unchanged — better to show an unreachable agent than to hide
165
+ * the entire launcher because the server extension didn't load.
166
+ */
167
+ async function filterByAvailability(agents) {
168
+ const commands = agents
169
+ .filter(agent => agent.requireAvailable)
170
+ .map(agent => agent.command);
171
+ if (commands.length === 0) {
172
+ return agents;
173
+ }
174
+ const available = await fetchAvailableCommands(commands);
175
+ if (!available) {
176
+ return agents;
177
+ }
178
+ return agents.filter(agent => !agent.requireAvailable || available.has(agent.command));
179
+ }
180
+ var Private;
181
+ (function (Private) {
182
+ let counter = 0;
183
+ /**
184
+ * Returns the next unique launcher widget id.
185
+ */
186
+ function nextId() {
187
+ return `launcher-${counter++}`;
188
+ }
189
+ Private.nextId = nextId;
190
+ })(Private || (Private = {}));
191
+ export default plugin;
@@ -0,0 +1,29 @@
1
+ import type { IAgent } from './agents';
2
+ /**
3
+ * Quote a string so it can be safely passed as a single argument on a
4
+ * bash/zsh command line that is *typed into an interactive terminal* —
5
+ * not the usual file-parsing context.
6
+ *
7
+ * The distinction matters: an actual newline byte (0x0A) sent into a PTY
8
+ * is interpreted by the line discipline as Enter regardless of whether
9
+ * it sits inside `'...'` quotes, so the shell never sees the closing
10
+ * quote. We therefore use ANSI-C `$'...'` quoting and emit `\n` (and
11
+ * `\r`) as their two-character escape sequences; the shell expands them
12
+ * to real newlines after the line is submitted, when readline is no
13
+ * longer involved. Backslashes and single quotes are escaped first so
14
+ * the source text round-trips unchanged.
15
+ */
16
+ export declare function shellQuote(value: string): string;
17
+ /**
18
+ * Compose the literal command line typed into a fresh terminal for `agent`.
19
+ * When `prompt` is empty (or whitespace-only), this is just `agent.command`.
20
+ * When non-empty, the prompt is shell-quoted and spliced in using the
21
+ * agent's `promptArgs` recipe — see `IAgent.promptArgs` for the encoding.
22
+ *
23
+ * Returns `agent.command` unchanged when the agent declares no prompt
24
+ * support (`promptArgs === undefined`), so a stray prompt typed into the
25
+ * launcher does not silently mutate the launch line for that agent. The
26
+ * launcher UI separately prevents the click in that case so the dropped
27
+ * prompt is never surprising to the user.
28
+ */
29
+ export declare function buildAgentInvocation(agent: IAgent, prompt: string): string;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Quote a string so it can be safely passed as a single argument on a
3
+ * bash/zsh command line that is *typed into an interactive terminal* —
4
+ * not the usual file-parsing context.
5
+ *
6
+ * The distinction matters: an actual newline byte (0x0A) sent into a PTY
7
+ * is interpreted by the line discipline as Enter regardless of whether
8
+ * it sits inside `'...'` quotes, so the shell never sees the closing
9
+ * quote. We therefore use ANSI-C `$'...'` quoting and emit `\n` (and
10
+ * `\r`) as their two-character escape sequences; the shell expands them
11
+ * to real newlines after the line is submitted, when readline is no
12
+ * longer involved. Backslashes and single quotes are escaped first so
13
+ * the source text round-trips unchanged.
14
+ */
15
+ export function shellQuote(value) {
16
+ const escaped = value
17
+ .replace(/\\/g, '\\\\')
18
+ .replace(/'/g, "\\'")
19
+ .replace(/\n/g, '\\n')
20
+ .replace(/\r/g, '\\r');
21
+ return `$'${escaped}'`;
22
+ }
23
+ /**
24
+ * Compose the literal command line typed into a fresh terminal for `agent`.
25
+ * When `prompt` is empty (or whitespace-only), this is just `agent.command`.
26
+ * When non-empty, the prompt is shell-quoted and spliced in using the
27
+ * agent's `promptArgs` recipe — see `IAgent.promptArgs` for the encoding.
28
+ *
29
+ * Returns `agent.command` unchanged when the agent declares no prompt
30
+ * support (`promptArgs === undefined`), so a stray prompt typed into the
31
+ * launcher does not silently mutate the launch line for that agent. The
32
+ * launcher UI separately prevents the click in that case so the dropped
33
+ * prompt is never surprising to the user.
34
+ */
35
+ export function buildAgentInvocation(agent, prompt) {
36
+ const trimmed = prompt.trim();
37
+ if (trimmed.length === 0 || agent.promptArgs === undefined) {
38
+ return agent.command;
39
+ }
40
+ const tokens = [agent.command, ...agent.promptArgs, shellQuote(trimmed)];
41
+ return tokens.filter(token => token.length > 0).join(' ');
42
+ }
@@ -0,0 +1,14 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ /**
3
+ * Hide the "Run" and "Kernel" top-level menus while no kernel-using widget
4
+ * is open in the main area. The agent-first launcher does not use kernels,
5
+ * so showing those menus on a fresh workspace surfaces commands the user
6
+ * cannot meaningfully invoke; remove them until a notebook or console is
7
+ * opened, then restore them at their original ranks.
8
+ *
9
+ * Detection is duck-typed on `sessionContext`: NotebookPanel and ConsolePanel
10
+ * both expose it, which avoids pulling in `@jupyterlab/notebook` and
11
+ * `@jupyterlab/console` just to identify their widgets.
12
+ */
13
+ declare const plugin: JupyterFrontEndPlugin<void>;
14
+ export default plugin;
@@ -0,0 +1,102 @@
1
+ import { ILabShell } from '@jupyterlab/application';
2
+ import { IMainMenu } from '@jupyterlab/mainmenu';
3
+ const PLUGIN_ID = 'xtralab:menus';
4
+ /**
5
+ * Hide the "Run" and "Kernel" top-level menus while no kernel-using widget
6
+ * is open in the main area. The agent-first launcher does not use kernels,
7
+ * so showing those menus on a fresh workspace surfaces commands the user
8
+ * cannot meaningfully invoke; remove them until a notebook or console is
9
+ * opened, then restore them at their original ranks.
10
+ *
11
+ * Detection is duck-typed on `sessionContext`: NotebookPanel and ConsolePanel
12
+ * both expose it, which avoids pulling in `@jupyterlab/notebook` and
13
+ * `@jupyterlab/console` just to identify their widgets.
14
+ */
15
+ const plugin = {
16
+ id: PLUGIN_ID,
17
+ description: 'Toggle the Run and Kernel main menus based on whether any kernel-using widget is open.',
18
+ autoStart: true,
19
+ requires: [IMainMenu, ILabShell],
20
+ activate: (app, mainMenu, labShell) => {
21
+ var _a, _b;
22
+ // The interface types (IRunMenu, IKernelMenu) extend IRankedMenu, but
23
+ // the concrete instances are RankedMenu/Menu — cast so we can hand them
24
+ // to MenuBar.removeMenu and IMainMenu.addMenu, which expect a `Menu`.
25
+ const menuBar = mainMenu;
26
+ const runMenu = mainMenu.runMenu;
27
+ const kernelMenu = mainMenu.kernelMenu;
28
+ // Capture the schema-assigned ranks so the menus reappear in their
29
+ // original slots between View (rank 3) and Tabs (rank 500). Falling
30
+ // back to the hard-coded defaults from `MainMenu` keeps things sane
31
+ // if the schema ever omits the rank.
32
+ const runRank = (_a = mainMenu.runMenu.rank) !== null && _a !== void 0 ? _a : 4;
33
+ const kernelRank = (_b = mainMenu.kernelMenu.rank) !== null && _b !== void 0 ? _b : 5;
34
+ const isPresent = (menu) => Array.from(menuBar.menus).indexOf(menu) > -1;
35
+ const update = () => {
36
+ const show = hasKernelWidget(labShell);
37
+ if (show) {
38
+ // Don't go through `mainMenu.addMenu`: it picks the insertion index
39
+ // from a private `_items` array that `removeMenu` doesn't update,
40
+ // so re-adding lands the menu at the wrong position. Insert
41
+ // directly on the MenuBar by walking current menus and finding
42
+ // the first sibling with a higher rank.
43
+ if (!isPresent(runMenu)) {
44
+ insertByRank(menuBar, runMenu, runRank);
45
+ }
46
+ if (!isPresent(kernelMenu)) {
47
+ insertByRank(menuBar, kernelMenu, kernelRank);
48
+ }
49
+ }
50
+ else {
51
+ if (isPresent(runMenu)) {
52
+ menuBar.removeMenu(runMenu);
53
+ }
54
+ if (isPresent(kernelMenu)) {
55
+ menuBar.removeMenu(kernelMenu);
56
+ }
57
+ }
58
+ };
59
+ void app.restored.then(() => {
60
+ update();
61
+ labShell.layoutModified.connect(update);
62
+ });
63
+ }
64
+ };
65
+ /**
66
+ * True iff any widget in the main area exposes a `sessionContext` — the
67
+ * shared marker for NotebookPanel, ConsolePanel, and kernel-attached file
68
+ * editors. We don't import their concrete types because that would drag
69
+ * `@jupyterlab/notebook` and `@jupyterlab/console` into xtralab's bundle
70
+ * just to do an `instanceof` check.
71
+ */
72
+ function hasKernelWidget(labShell) {
73
+ for (const widget of labShell.widgets('main')) {
74
+ if (hasSessionContext(widget)) {
75
+ return true;
76
+ }
77
+ }
78
+ return false;
79
+ }
80
+ function hasSessionContext(widget) {
81
+ const candidate = widget;
82
+ return (candidate.sessionContext !== null && candidate.sessionContext !== undefined);
83
+ }
84
+ /**
85
+ * Insert `menu` into `menuBar` at the position dictated by `rank`, using
86
+ * the existing menus' ranks to find the slot. Menus without a numeric
87
+ * rank are treated as +Infinity so they sink to the end (matching the
88
+ * convention in `MenuFactory.createMenus`).
89
+ */
90
+ function insertByRank(menuBar, menu, rank) {
91
+ const menus = menuBar.menus;
92
+ for (let i = 0; i < menus.length; i++) {
93
+ const r = menus[i].rank;
94
+ const otherRank = typeof r === 'number' ? r : Number.POSITIVE_INFINITY;
95
+ if (otherRank > rank) {
96
+ menuBar.insertMenu(i, menu);
97
+ return;
98
+ }
99
+ }
100
+ menuBar.addMenu(menu);
101
+ }
102
+ export default plugin;
@@ -0,0 +1,18 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ /**
3
+ * Lets the user toggle individual left-sidebar tabs from
4
+ * View > Appearance > Left Sidebar. Removing a widget from a `LabShell`
5
+ * side area is done by setting `widget.parent = null`: the StackedPanel
6
+ * emits `widgetRemoved`, which the SideBarHandler responds to by removing
7
+ * the matching tab. The widget instance is preserved, so we can re-add it
8
+ * later via `labShell.add` with its original rank.
9
+ *
10
+ * The menu placement is contributed declaratively in
11
+ * `schema/sidebar.json`, so this plugin only registers commands and
12
+ * applies state — it does not touch `IMainMenu` directly.
13
+ *
14
+ * The preference is persisted to `xtralab:sidebar` settings so the choice
15
+ * survives reloads.
16
+ */
17
+ declare const plugin: JupyterFrontEndPlugin<void>;
18
+ export default plugin;
@@ -0,0 +1,155 @@
1
+ import { ILabShell } from '@jupyterlab/application';
2
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
3
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
4
+ const PLUGIN_ID = 'xtralab:sidebar';
5
+ const TARGETS = [
6
+ {
7
+ id: 'filebrowser',
8
+ rank: 100,
9
+ settingKey: 'showDefaultFileBrowser',
10
+ command: 'xtralab:toggle-default-filebrowser',
11
+ label: trans => trans.__('Default File Browser')
12
+ },
13
+ {
14
+ id: 'jp-running-sessions',
15
+ rank: 200,
16
+ settingKey: 'showRunningSessions',
17
+ command: 'xtralab:toggle-running-sessions',
18
+ label: trans => trans.__('Running Terminals and Kernels')
19
+ }
20
+ ];
21
+ /**
22
+ * Lets the user toggle individual left-sidebar tabs from
23
+ * View > Appearance > Left Sidebar. Removing a widget from a `LabShell`
24
+ * side area is done by setting `widget.parent = null`: the StackedPanel
25
+ * emits `widgetRemoved`, which the SideBarHandler responds to by removing
26
+ * the matching tab. The widget instance is preserved, so we can re-add it
27
+ * later via `labShell.add` with its original rank.
28
+ *
29
+ * The menu placement is contributed declaratively in
30
+ * `schema/sidebar.json`, so this plugin only registers commands and
31
+ * applies state — it does not touch `IMainMenu` directly.
32
+ *
33
+ * The preference is persisted to `xtralab:sidebar` settings so the choice
34
+ * survives reloads.
35
+ */
36
+ const plugin = {
37
+ id: PLUGIN_ID,
38
+ description: 'Toggle visibility of selected sidebar tabs (default file browser, running sessions) from the View menu.',
39
+ autoStart: true,
40
+ requires: [ILabShell],
41
+ optional: [ISettingRegistry, ITranslator],
42
+ activate: async (app, labShell, settingRegistry, translator) => {
43
+ const trans = (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab');
44
+ const { commands } = app;
45
+ // Cache widget instances so we can re-add them after `widget.parent =
46
+ // null` has detached them from the sidebar. The widget is no longer
47
+ // reachable through `labShell.widgets('left')` once removed, so a
48
+ // missing cache entry would leave us unable to bring the tab back.
49
+ const widgetCache = new Map();
50
+ const findInSidebar = (id) => {
51
+ for (const w of labShell.widgets('left')) {
52
+ if (w.id === id) {
53
+ return w;
54
+ }
55
+ }
56
+ return null;
57
+ };
58
+ const captureWidget = (id) => {
59
+ const cached = widgetCache.get(id);
60
+ if (cached && !cached.isDisposed) {
61
+ return cached;
62
+ }
63
+ const found = findInSidebar(id);
64
+ if (found) {
65
+ widgetCache.set(id, found);
66
+ }
67
+ return found;
68
+ };
69
+ let settings = null;
70
+ const readPreference = (target) => {
71
+ if (!settings) {
72
+ return true;
73
+ }
74
+ const value = settings.composite[target.settingKey];
75
+ return typeof value === 'boolean' ? value : true;
76
+ };
77
+ const apply = (target) => {
78
+ const widget = captureWidget(target.id);
79
+ if (!widget) {
80
+ // The upstream widget hasn't been added yet (or the extension is
81
+ // disabled). Nothing to do — the next time `apply` runs and finds
82
+ // it, the preference will take effect.
83
+ return;
84
+ }
85
+ const present = findInSidebar(target.id) !== null;
86
+ const wantPresent = readPreference(target);
87
+ if (wantPresent && !present) {
88
+ labShell.add(widget, 'left', { rank: target.rank });
89
+ }
90
+ else if (!wantPresent && present) {
91
+ // Setting `parent = null` triggers SideBarHandler's
92
+ // `_onWidgetRemoved`, which strips the tab while leaving the
93
+ // widget instance alive for a later re-add.
94
+ widget.parent = null;
95
+ }
96
+ };
97
+ const applyAll = () => {
98
+ for (const target of TARGETS) {
99
+ apply(target);
100
+ }
101
+ };
102
+ for (const target of TARGETS) {
103
+ commands.addCommand(target.command, {
104
+ label: target.label(trans),
105
+ isToggled: () => readPreference(target),
106
+ execute: async () => {
107
+ const next = !readPreference(target);
108
+ if (settings) {
109
+ try {
110
+ await settings.set(target.settingKey, next);
111
+ // `settings.changed` will fire and drive `apply` + the
112
+ // command-state refresh. Returning here keeps the toggle's
113
+ // single source of truth in the settings registry.
114
+ return;
115
+ }
116
+ catch (reason) {
117
+ console.error(`xtralab: failed to persist ${target.settingKey}`, reason);
118
+ }
119
+ }
120
+ // No settings registry available (or the write failed): fall
121
+ // back to applying the change in-memory so the toggle still
122
+ // works for the current session.
123
+ apply(target);
124
+ commands.notifyCommandChanged(target.command);
125
+ }
126
+ });
127
+ }
128
+ if (settingRegistry) {
129
+ try {
130
+ settings = await settingRegistry.load(PLUGIN_ID);
131
+ settings.changed.connect(() => {
132
+ applyAll();
133
+ for (const target of TARGETS) {
134
+ commands.notifyCommandChanged(target.command);
135
+ }
136
+ });
137
+ }
138
+ catch (reason) {
139
+ console.error('xtralab: failed to load sidebar settings', reason);
140
+ }
141
+ }
142
+ void app.restored.then(() => {
143
+ // Capture references first so a hidden-on-startup widget is still
144
+ // recoverable later. `apply` would also do the lookup, but only on
145
+ // the targets that need to stay visible — by capturing every target
146
+ // here we keep the cache populated for both directions of the
147
+ // toggle.
148
+ for (const target of TARGETS) {
149
+ captureWidget(target.id);
150
+ }
151
+ applyAll();
152
+ });
153
+ }
154
+ };
155
+ export default plugin;
@@ -0,0 +1,14 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ /**
3
+ * Surfaces every running terminal session as its own item on the left
4
+ * side of the status bar. The list is driven by
5
+ * `serviceManager.terminals.runningChanged`, not by the widget tracker,
6
+ * so a session that has its tab closed but is still running on the
7
+ * backend keeps its slot — the typical state for an agent the user has
8
+ * stepped away from. Clicking an item activates the existing tab if one
9
+ * is open, or reopens the session in a fresh terminal widget. The
10
+ * inline `×` button shuts the session down on the server, which
11
+ * cascades through the widget if it is currently open.
12
+ */
13
+ declare const plugin: JupyterFrontEndPlugin<void>;
14
+ export default plugin;
@@ -0,0 +1,89 @@
1
+ import { IStatusBar } from '@jupyterlab/statusbar';
2
+ import { ITerminalTracker } from '@jupyterlab/terminal';
3
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
4
+ import { DisposableSet } from '@lumino/disposable';
5
+ import { SessionRegistry, TerminalSessionItem } from './widget';
6
+ const PLUGIN_ID = 'xtralab:statusBar';
7
+ /**
8
+ * Surfaces every running terminal session as its own item on the left
9
+ * side of the status bar. The list is driven by
10
+ * `serviceManager.terminals.runningChanged`, not by the widget tracker,
11
+ * so a session that has its tab closed but is still running on the
12
+ * backend keeps its slot — the typical state for an agent the user has
13
+ * stepped away from. Clicking an item activates the existing tab if one
14
+ * is open, or reopens the session in a fresh terminal widget. The
15
+ * inline `×` button shuts the session down on the server, which
16
+ * cascades through the widget if it is currently open.
17
+ */
18
+ const plugin = {
19
+ id: PLUGIN_ID,
20
+ description: 'Surface each running terminal session as its own status bar item so users can jump to (or reopen) backgrounded agents from anywhere in the workspace.',
21
+ autoStart: true,
22
+ requires: [IStatusBar, ITerminalTracker],
23
+ optional: [ITranslator],
24
+ activate: (app, statusBar, tracker, translator) => {
25
+ const trans = (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab');
26
+ const registry = new SessionRegistry(app.serviceManager, tracker);
27
+ // One DisposableSet per session — registerStatusItem returns an
28
+ // IDisposable for the registration, and the item widget itself
29
+ // implements IDisposable. Bundling them lets a single `dispose()`
30
+ // tear the slot down completely when the session goes away.
31
+ const items = new Map();
32
+ const onActivate = (name) => {
33
+ // `terminal:open` is the upstream entry point that handles both
34
+ // sides of this: if a widget is already attached to the named
35
+ // session it activates that tab, otherwise it spins up a fresh
36
+ // widget connected to the live session. Reusing it keeps the
37
+ // path identical to a click in the running-sessions sidebar.
38
+ void app.commands.execute('terminal:open', { name });
39
+ };
40
+ const onShutdown = (name) => {
41
+ void app.serviceManager.terminals.shutdown(name).catch(reason => {
42
+ console.error(`xtralab: failed to shut down terminal ${name}: ${reason}`);
43
+ });
44
+ };
45
+ const sync = () => {
46
+ const live = new Set(registry.sessionNames());
47
+ // Add items for newly-seen sessions. We register each at its
48
+ // stable rank from the registry so re-creating an item between
49
+ // syncs (e.g. if we ever decide to swap representations) lands
50
+ // in the same slot.
51
+ for (const name of live) {
52
+ if (items.has(name)) {
53
+ continue;
54
+ }
55
+ const item = new TerminalSessionItem({
56
+ registry,
57
+ sessionName: name,
58
+ trans,
59
+ onActivate,
60
+ onShutdown
61
+ });
62
+ const registration = statusBar.registerStatusItem(`${PLUGIN_ID}:${name}`, {
63
+ item,
64
+ align: 'left',
65
+ rank: registry.rankFor(name)
66
+ });
67
+ const set = new DisposableSet();
68
+ set.add(registration);
69
+ set.add(item);
70
+ items.set(name, set);
71
+ }
72
+ // Remove items for sessions the server no longer knows about.
73
+ for (const [name, disposable] of Array.from(items.entries())) {
74
+ if (!live.has(name)) {
75
+ disposable.dispose();
76
+ items.delete(name);
77
+ }
78
+ }
79
+ };
80
+ registry.stateChanged.connect(sync);
81
+ void app.serviceManager.terminals.ready.then(() => {
82
+ // The first `runningChanged` may have fired before our connect,
83
+ // depending on how the manager initialises — refresh once after
84
+ // it is ready so any pre-existing sessions get their items.
85
+ sync();
86
+ });
87
+ }
88
+ };
89
+ export default plugin;