xtralab 0.5.0 → 0.7.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 (71) hide show
  1. package/README.md +138 -76
  2. package/lib/agentSessions.d.ts +50 -0
  3. package/lib/agentSessions.js +45 -0
  4. package/lib/fileBrowser/index.js +5 -1
  5. package/lib/git/imageDiff.js +80 -8
  6. package/lib/index.d.ts +3 -2
  7. package/lib/index.js +11 -6
  8. package/lib/launcher/agents.d.ts +1 -1
  9. package/lib/launcher/agents.js +22 -13
  10. package/lib/launcher/commands.d.ts +32 -6
  11. package/lib/launcher/commands.js +81 -57
  12. package/lib/launcher/dashboard.d.ts +15 -0
  13. package/lib/launcher/dashboard.js +139 -10
  14. package/lib/launcher/editorRegistry.d.ts +43 -0
  15. package/lib/launcher/editorRegistry.js +96 -0
  16. package/lib/launcher/editors.d.ts +78 -0
  17. package/lib/launcher/editors.js +110 -0
  18. package/lib/launcher/icons.d.ts +25 -6
  19. package/lib/launcher/icons.js +116 -19
  20. package/lib/launcher/index.d.ts +2 -22
  21. package/lib/launcher/index.js +48 -25
  22. package/lib/launcher/registry.d.ts +21 -0
  23. package/lib/launcher/registry.js +28 -0
  24. package/lib/launcher/tokens.d.ts +41 -0
  25. package/lib/launcher/tokens.js +17 -0
  26. package/lib/terminals/detection.d.ts +11 -0
  27. package/lib/terminals/detection.js +50 -0
  28. package/lib/terminals/index.d.ts +27 -0
  29. package/lib/terminals/index.js +194 -0
  30. package/lib/terminals/model.d.ts +191 -0
  31. package/lib/terminals/model.js +418 -0
  32. package/lib/terminals/widget.d.ts +72 -0
  33. package/lib/terminals/widget.js +93 -0
  34. package/lib/topBar/icons.d.ts +13 -0
  35. package/lib/topBar/icons.js +27 -0
  36. package/lib/topBar/index.d.ts +16 -0
  37. package/lib/topBar/index.js +98 -0
  38. package/package.json +36 -33
  39. package/schema/launcher.json +56 -1
  40. package/src/agentSessions.ts +94 -0
  41. package/src/fileBrowser/index.ts +5 -1
  42. package/src/git/imageDiff.tsx +127 -26
  43. package/src/index.ts +11 -6
  44. package/src/launcher/agents.ts +23 -14
  45. package/src/launcher/commands.ts +98 -60
  46. package/src/launcher/dashboard.tsx +227 -9
  47. package/src/launcher/editorRegistry.ts +149 -0
  48. package/src/launcher/editors.ts +183 -0
  49. package/src/launcher/icons.ts +120 -20
  50. package/src/launcher/index.ts +62 -27
  51. package/src/launcher/registry.ts +33 -0
  52. package/src/launcher/tokens.ts +51 -0
  53. package/src/terminals/detection.ts +65 -0
  54. package/src/terminals/index.ts +222 -0
  55. package/src/terminals/model.ts +497 -0
  56. package/src/terminals/widget.tsx +240 -0
  57. package/src/topBar/icons.ts +30 -0
  58. package/src/topBar/index.ts +134 -0
  59. package/style/git.css +109 -0
  60. package/style/index.css +2 -1
  61. package/style/index.js +2 -1
  62. package/style/launcher.css +124 -2
  63. package/style/terminals.css +235 -0
  64. package/style/topBar.css +38 -0
  65. package/lib/statusBar/index.d.ts +0 -14
  66. package/lib/statusBar/index.js +0 -89
  67. package/lib/statusBar/widget.d.ts +0 -121
  68. package/lib/statusBar/widget.js +0 -237
  69. package/src/statusBar/index.ts +0 -115
  70. package/src/statusBar/widget.tsx +0 -318
  71. package/style/statusBar.css +0 -95
@@ -1,16 +1,10 @@
1
1
  import { DisposableSet } from '@lumino/disposable';
2
2
  import { buildAgentInvocation } from './invocation';
3
+ import { agentCommandId } from './tokens';
3
4
  /**
4
5
  * The command id for opening the launcher.
5
6
  */
6
7
  export const CREATE_LAUNCHER_COMMAND = 'launcher:create';
7
- /**
8
- * Build a JupyterLab command id for a given agent. Centralised so the
9
- * plugin and the launcher both use the same names.
10
- */
11
- export function agentCommandId(agentId) {
12
- return `xtralab:start-agent:${agentId}`;
13
- }
14
8
  /**
15
9
  * Register a JupyterLab command per agent. Each command opens a new
16
10
  * terminal via `terminal:create-new` and feeds the agent's shell command
@@ -19,8 +13,13 @@ export function agentCommandId(agentId) {
19
13
  * Returns a single disposable that tears down every registered command —
20
14
  * the launcher disposes the previous set before re-registering on settings
21
15
  * changes so the command palette stays in sync with the configured agents.
16
+ *
17
+ * When an `agentSessions` registry is supplied, each launch tags its terminal
18
+ * session with the agent's command, so the terminals panel can badge the row
19
+ * with the agent's logo immediately (before server-side detection confirms
20
+ * it).
22
21
  */
23
- export function registerAgentCommands(app, agents) {
22
+ export function registerAgentCommands(app, agents, agentSessions = null) {
24
23
  const disposables = new DisposableSet();
25
24
  for (const agent of agents) {
26
25
  const disposable = app.commands.addCommand(agentCommandId(agent.id), {
@@ -32,58 +31,83 @@ export function registerAgentCommands(app, agents) {
32
31
  const promptArg = args['prompt'];
33
32
  const prompt = typeof promptArg === 'string' ? promptArg : '';
34
33
  const invocation = buildAgentInvocation(agent, prompt);
35
- const main = (await app.commands.execute('terminal:create-new', {
36
- cwd
37
- }));
38
- // `MainAreaWidget.revealed` chains off the `reveal` promise the
39
- // terminal extension passes in (the xterm.js widget's own `ready`
40
- // promise), so awaiting it guarantees the Terminal widget has
41
- // finished its constructor and connected its session listeners.
42
- await main.revealed;
43
- const session = main.content.session;
44
- const sendCommand = () => {
45
- session.send({
46
- type: 'stdin',
47
- content: [invocation + '\r']
48
- });
49
- // Give the tab and the status-bar list a meaningful default
50
- // label. The XTerm widget's `_initialConnection` listener
51
- // overwrites the title with `Terminal {N}` when the WebSocket
52
- // first reports `connected`; we connect *after* that listener
53
- // (and call `applyAgentLabel` after `sendCommand`), so by the
54
- // time we run the upstream listener has already had its turn.
55
- // Programs that publish a real xterm title escape sequence
56
- // still win — `XTerm.onTitleChange` resets the label whenever
57
- // it fires.
58
- applyAgentLabel();
59
- };
60
- const applyAgentLabel = () => {
61
- if (main.isDisposed || main.content.isDisposed) {
62
- return;
63
- }
64
- main.content.title.label = agent.label;
65
- };
66
- // Match how the Terminal widget itself sends its `initialCommand`:
67
- // either fire immediately if the session is already connected, or
68
- // wait for the next `connectionStatusChanged` that flips it to
69
- // `connected`. Without this the stdin write races the websocket
70
- // handshake and the agent command silently disappears.
71
- if (session.connectionStatus === 'connected') {
72
- sendCommand();
73
- }
74
- else {
75
- const onStatus = () => {
76
- if (session.connectionStatus === 'connected') {
77
- session.connectionStatusChanged.disconnect(onStatus);
78
- sendCommand();
79
- }
80
- };
81
- session.connectionStatusChanged.connect(onStatus);
82
- }
83
- return main;
34
+ return launchInTerminal(app.commands, {
35
+ cwd,
36
+ invocation,
37
+ label: agent.label,
38
+ // Optimistically tag the session with this agent so the terminals
39
+ // panel can show its logo right away; server-side detection takes
40
+ // over as the source of truth on its next poll.
41
+ onSession: name => agentSessions === null || agentSessions === void 0 ? void 0 : agentSessions.set(name, agent.command)
42
+ });
84
43
  }
85
44
  });
86
45
  disposables.add(disposable);
87
46
  }
88
47
  return disposables;
89
48
  }
49
+ /**
50
+ * Open a fresh terminal, type `invocation` into it as if the user had, and
51
+ * label the tab. Shared by the per-agent launch commands and the launcher's
52
+ * editor tile so the fiddly "wait for the websocket, then write stdin"
53
+ * sequence lives in one place.
54
+ *
55
+ * `onSession`, when given, is called once with the new session's name right
56
+ * after the terminal is revealed. Callers use it to optimistically tag the
57
+ * session so the terminals panel can badge it with the agent's (or editor's)
58
+ * logo before server-side detection confirms the running process.
59
+ *
60
+ * Resolves with the host `MainAreaWidget` so the caller can place it — the
61
+ * launcher swaps it into its own tab.
62
+ */
63
+ export async function launchInTerminal(commands, options) {
64
+ const { cwd, invocation, label, onSession } = options;
65
+ const main = (await commands.execute('terminal:create-new', {
66
+ cwd
67
+ }));
68
+ // `MainAreaWidget.revealed` chains off the `reveal` promise the terminal
69
+ // extension passes in (the xterm.js widget's own `ready` promise), so
70
+ // awaiting it guarantees the Terminal widget has finished its constructor
71
+ // and connected its session listeners.
72
+ await main.revealed;
73
+ const session = main.content.session;
74
+ onSession === null || onSession === void 0 ? void 0 : onSession(session.name);
75
+ const applyLabel = () => {
76
+ if (main.isDisposed || main.content.isDisposed) {
77
+ return;
78
+ }
79
+ main.content.title.label = label;
80
+ };
81
+ const sendCommand = () => {
82
+ session.send({
83
+ type: 'stdin',
84
+ content: [invocation + '\r']
85
+ });
86
+ // Give the tab and the status-bar list a meaningful default label. The
87
+ // XTerm widget's `_initialConnection` listener overwrites the title with
88
+ // `Terminal {N}` when the WebSocket first reports `connected`; we connect
89
+ // *after* that listener (and call `applyLabel` after `sendCommand`), so by
90
+ // the time we run the upstream listener has already had its turn. Programs
91
+ // that publish a real xterm title escape sequence still win —
92
+ // `XTerm.onTitleChange` resets the label whenever it fires.
93
+ applyLabel();
94
+ };
95
+ // Match how the Terminal widget itself sends its `initialCommand`: either
96
+ // fire immediately if the session is already connected, or wait for the next
97
+ // `connectionStatusChanged` that flips it to `connected`. Without this the
98
+ // stdin write races the websocket handshake and the command silently
99
+ // disappears.
100
+ if (session.connectionStatus === 'connected') {
101
+ sendCommand();
102
+ }
103
+ else {
104
+ const onStatus = () => {
105
+ if (session.connectionStatus === 'connected') {
106
+ session.connectionStatusChanged.disconnect(onStatus);
107
+ sendCommand();
108
+ }
109
+ };
110
+ session.connectionStatusChanged.connect(onStatus);
111
+ }
112
+ return main;
113
+ }
@@ -2,7 +2,9 @@ import * as React from 'react';
2
2
  import type { CommandRegistry } from '@lumino/commands';
3
3
  import { ReactWidget } from '@jupyterlab/ui-components';
4
4
  import type { Widget } from '@lumino/widgets';
5
+ import type { IAgentSessions } from '../agentSessions';
5
6
  import type { IAgent } from './agents';
7
+ import type { IEditor } from './editors';
6
8
  /**
7
9
  * Public construction options for the launcher dashboard widget. The
8
10
  * plugin supplies an `onAgentLaunch` callback that performs the same
@@ -17,6 +19,19 @@ export interface ILauncherDashboardOptions {
17
19
  * rank; the widget renders them as-is.
18
20
  */
19
21
  agents: IAgent[];
22
+ /**
23
+ * The terminal editor to offer in the "Open" section (Neovim preferred over
24
+ * Vim), or `null` when neither is installed. Resolved by the plugin from the
25
+ * same availability probe that filters the agents.
26
+ */
27
+ editor: IEditor | null;
28
+ /**
29
+ * Shared launch-tag registry. When the editor tile launches, it records the
30
+ * session here so the terminals panel badges it with the editor's logo right
31
+ * away, the same as agent launches do. `null` when the provider plugin is
32
+ * unavailable.
33
+ */
34
+ agentSessions: IAgentSessions | null;
20
35
  /**
21
36
  * Called after an agent command has returned its top-level widget. The
22
37
  * plugin uses this to swap the new terminal into the launcher's tab.
@@ -3,7 +3,8 @@ import { Poll } from '@lumino/polling';
3
3
  import { LabIcon, ReactWidget, consoleIcon, notebookIcon, refreshIcon, terminalIcon } from '@jupyterlab/ui-components';
4
4
  import { expandStatusFiles, status } from '../git/api';
5
5
  import { CommandIDs as GitCommandIDs } from '../git/commands';
6
- import { agentCommandId } from './commands';
6
+ import { launchInTerminal } from './commands';
7
+ import { agentCommandId } from './tokens';
7
8
  /**
8
9
  * Lumino host for the React-based launcher dashboard. Mirrors the
9
10
  * `ReactWidget` pattern used by the git panel so the rest of the plugin
@@ -32,7 +33,7 @@ const CHANGES_POLL_INTERVAL_MS = 8000;
32
33
  */
33
34
  const CHANGES_POLL_MAX_MS = 300000;
34
35
  function LauncherDashboardComponent(props) {
35
- const { commands, agents, onAgentLaunch, repoPath, cwd } = props;
36
+ const { commands, agents, editor, agentSessions, onAgentLaunch, repoPath, cwd } = props;
36
37
  const [prompt, setPrompt] = React.useState('');
37
38
  const [git, setGit] = React.useState({
38
39
  loading: true,
@@ -90,13 +91,28 @@ function LauncherDashboardComponent(props) {
90
91
  }
91
92
  }, [commands, cwd, onAgentLaunch]);
92
93
  const launchNotebook = React.useCallback(async () => {
93
- // No `kernelName` — let the notebook extension fall back to the default
94
- // kernel or prompt the user, matching the stock launcher's notebook
95
- // tile behavior.
96
- const result = (await commands.execute('notebook:create-new', {
97
- cwd
94
+ // Open a fresh notebook with no kernel attached — equivalent to clicking
95
+ // "No Kernel" in the Select Kernel dialog. `notebook:create-new` always
96
+ // routes through that dialog when no kernelName is given, so drive the
97
+ // underlying docmanager steps directly and pass `shouldStart: false` to
98
+ // suppress kernel selection. The user picks a kernel from the notebook
99
+ // toolbar when they're ready.
100
+ const file = (await commands.execute('docmanager:new-untitled', {
101
+ path: cwd,
102
+ type: 'notebook'
103
+ }));
104
+ if (!file) {
105
+ return;
106
+ }
107
+ const result = (await commands.execute('docmanager:open', {
108
+ path: file.path,
109
+ factory: 'Notebook',
110
+ kernelPreference: { shouldStart: false, canStart: true }
98
111
  }));
99
112
  if (result) {
113
+ // Match `notebook:create-new`'s post-open marker so the first manual
114
+ // save offers a rename instead of overwriting the auto-generated name.
115
+ result.isUntitled = true;
100
116
  onAgentLaunch(result);
101
117
  }
102
118
  }, [commands, cwd, onAgentLaunch]);
@@ -108,13 +124,30 @@ function LauncherDashboardComponent(props) {
108
124
  onAgentLaunch(result);
109
125
  }
110
126
  }, [commands, cwd, onAgentLaunch]);
127
+ const launchEditor = React.useCallback(async () => {
128
+ if (!editor) {
129
+ return;
130
+ }
131
+ // An editor is "open a terminal and type its command" — the agent launch
132
+ // minus the prompt — so it shares `launchInTerminal` rather than the native
133
+ // `terminal:create-new` call the tiles above use, and tags the session the
134
+ // same way so the terminals panel badges it with the editor's logo.
135
+ const result = await launchInTerminal(commands, {
136
+ cwd,
137
+ invocation: editor.command,
138
+ label: editor.label,
139
+ onSession: name => agentSessions === null || agentSessions === void 0 ? void 0 : agentSessions.set(name, editor.command)
140
+ });
141
+ onAgentLaunch(result);
142
+ }, [commands, cwd, editor, agentSessions, onAgentLaunch]);
111
143
  const openDiff = React.useCallback((change) => {
112
144
  const args = { repoPath, change };
113
145
  void commands.execute(GitCommandIDs.openDiff, args);
114
146
  }, [commands, repoPath]);
115
147
  return (React.createElement("div", { className: "jp-xtralab-Launcher-body" },
116
148
  React.createElement(AgentSection, { agents: agents, prompt: prompt, onPromptChange: setPrompt, onLaunch: launch }),
117
- React.createElement(OpenSection, { onLaunchTerminal: launchTerminal, onLaunchNotebook: launchNotebook, onLaunchConsole: launchConsole }),
149
+ React.createElement(McpSection, { agents: agents }),
150
+ React.createElement(OpenSection, { editor: editor, onLaunchTerminal: launchTerminal, onLaunchNotebook: launchNotebook, onLaunchConsole: launchConsole, onLaunchEditor: launchEditor }),
118
151
  React.createElement(ChangesSection, { git: git, onOpen: openDiff, onRefresh: () => void refresh() })));
119
152
  }
120
153
  function ChangesSection(props) {
@@ -202,15 +235,108 @@ function AgentSection(props) {
202
235
  React.createElement("span", { className: "jp-xtralab-Launcher-agent-label" }, agent.label)));
203
236
  }))));
204
237
  }
238
+ /**
239
+ * Launcher agents whose CLI has a clean, non-interactive `mcp add` that takes
240
+ * `<name> -- <command>`. The other agents (Goose, OpenCode, Kiro, Mistral
241
+ * Vibe, Antigravity) register MCP through their own config files or UI, so
242
+ * there is no one-line command to surface for them.
243
+ */
244
+ const MCP_ADD_AGENT_IDS = new Set(['claude', 'codex', 'copilot']);
245
+ /** The stdio proxy console script that bridges an agent to the MCP server. */
246
+ const MCP_PROXY_COMMAND = 'jupyter-server-mcp-proxy';
247
+ /**
248
+ * Copy `text` to the clipboard via a hidden, selected `<textarea>` and the
249
+ * legacy `execCommand('copy')`. Deprecated, but still the most reliable path
250
+ * under a user gesture when the async Clipboard API is unavailable or blocked
251
+ * (the desktop app's Electron window can deny clipboard writes by policy).
252
+ * Returns whether the copy succeeded.
253
+ */
254
+ function legacyCopy(text) {
255
+ try {
256
+ const textarea = document.createElement('textarea');
257
+ textarea.value = text;
258
+ textarea.setAttribute('readonly', '');
259
+ textarea.style.position = 'fixed';
260
+ textarea.style.top = '-1000px';
261
+ textarea.style.opacity = '0';
262
+ document.body.appendChild(textarea);
263
+ textarea.select();
264
+ const ok = document.execCommand('copy');
265
+ document.body.removeChild(textarea);
266
+ return ok;
267
+ }
268
+ catch (_a) {
269
+ return false;
270
+ }
271
+ }
272
+ /**
273
+ * A collapsed-by-default hint listing the one-line command that registers
274
+ * xtralab's running Jupyter MCP server with each installed agent that
275
+ * supports `mcp add`. Copy-only: the user runs it in their own terminal,
276
+ * where the server is reachable with no port to configure — the desktop
277
+ * supervisor exports `JUPYTER_SERVER_MCP_URL`, and `pip` installs let the
278
+ * proxy discover the server from its runtime file. Renders nothing when none
279
+ * of those agents are on `$PATH`.
280
+ */
281
+ function McpSection(props) {
282
+ const [copiedId, setCopiedId] = React.useState(null);
283
+ const registerable = props.agents.filter(agent => MCP_ADD_AGENT_IDS.has(agent.id));
284
+ if (registerable.length === 0) {
285
+ return null;
286
+ }
287
+ const flagCopied = (id) => {
288
+ setCopiedId(id);
289
+ window.setTimeout(() => setCopiedId(current => (current === id ? null : current)), 1500);
290
+ };
291
+ const copy = (command, id) => {
292
+ // Prefer the async Clipboard API; fall back to `legacyCopy` when it's
293
+ // missing or rejects (Electron can block it by policy). Flag "Copied" only
294
+ // once a copy actually succeeds.
295
+ const clipboard = navigator.clipboard;
296
+ if (clipboard) {
297
+ void clipboard.writeText(command).then(() => flagCopied(id), () => {
298
+ if (legacyCopy(command)) {
299
+ flagCopied(id);
300
+ }
301
+ });
302
+ }
303
+ else if (legacyCopy(command)) {
304
+ flagCopied(id);
305
+ }
306
+ };
307
+ return (React.createElement("details", { className: "jp-xtralab-Launcher-section jp-xtralab-Launcher-mcp-section" },
308
+ React.createElement("summary", { className: "jp-xtralab-Launcher-section-summary" },
309
+ React.createElement("span", { className: "jp-xtralab-Launcher-section-title" }, "Connect agents to JupyterLab (MCP)")),
310
+ React.createElement("div", { className: "jp-xtralab-Launcher-section-body" },
311
+ React.createElement("p", { className: "jp-xtralab-Launcher-agent-hint" }, "Run once per agent to let it drive this JupyterLab over MCP. No port needed \u2014 the proxy finds this server on its own."),
312
+ React.createElement("ul", { className: "jp-xtralab-Launcher-mcp-list" }, registerable.map(agent => {
313
+ const command = `${agent.command} mcp add jupyter -- ${MCP_PROXY_COMMAND}`;
314
+ return (React.createElement("li", { key: agent.id, className: "jp-xtralab-Launcher-mcp-item" },
315
+ React.createElement("code", { className: "jp-xtralab-Launcher-mcp-command" }, command),
316
+ React.createElement("button", { type: "button", className: copiedId === agent.id
317
+ ? 'jp-xtralab-Launcher-mcp-copy jp-mod-copied'
318
+ : 'jp-xtralab-Launcher-mcp-copy', "aria-label": `Copy the ${agent.label} command`, onClick: () => copy(command, agent.id) }, copiedId === agent.id ? 'Copied!' : 'Copy')));
319
+ })),
320
+ React.createElement("p", { className: "jp-xtralab-Launcher-mcp-note" },
321
+ "Other agents (Goose, OpenCode, Kiro, Mistral Vibe, Antigravity) register MCP through their own config \u2014 point them at the same",
322
+ ' ',
323
+ React.createElement("code", null, MCP_PROXY_COMMAND),
324
+ " command."))));
325
+ }
205
326
  /**
206
327
  * The non-agent launch tiles — a plain terminal, a new notebook, and a
207
328
  * new console. They sit in a separate section below the agent grid so
208
329
  * they don't compete with the AI agents visually, and they're always
209
330
  * available regardless of the agent prompt textarea (which doesn't
210
331
  * apply to them).
332
+ *
333
+ * A terminal editor (Neovim, else Vim) joins the row when one is installed —
334
+ * it opens in a terminal like the agents do, so it lives here rather than in
335
+ * the agent grid. When neither is on `$PATH`, `editor` is `null` and no tile
336
+ * renders.
211
337
  */
212
338
  function OpenSection(props) {
213
- const { onLaunchTerminal, onLaunchNotebook, onLaunchConsole } = props;
339
+ const { editor, onLaunchTerminal, onLaunchNotebook, onLaunchConsole, onLaunchEditor } = props;
214
340
  return (React.createElement("section", { className: "jp-xtralab-Launcher-section" },
215
341
  React.createElement("h2", { className: "jp-xtralab-Launcher-section-title" }, "Open"),
216
342
  React.createElement("div", { className: "jp-xtralab-Launcher-agents" },
@@ -222,5 +348,8 @@ function OpenSection(props) {
222
348
  React.createElement("span", { className: "jp-xtralab-Launcher-agent-label" }, "Notebook")),
223
349
  React.createElement("button", { type: "button", className: "jp-xtralab-Launcher-agent", title: "Create a new console.", "aria-label": "Create a new console", onClick: onLaunchConsole },
224
350
  React.createElement(LabIcon.resolveReact, { icon: consoleIcon, tag: "span", className: "jp-xtralab-Launcher-agent-icon" }),
225
- React.createElement("span", { className: "jp-xtralab-Launcher-agent-label" }, "Console")))));
351
+ React.createElement("span", { className: "jp-xtralab-Launcher-agent-label" }, "Console")),
352
+ editor && (React.createElement("button", { type: "button", className: "jp-xtralab-Launcher-agent", title: editor.caption, "aria-label": editor.caption, onClick: onLaunchEditor },
353
+ React.createElement(LabIcon.resolveReact, { icon: editor.icon, tag: "span", className: "jp-xtralab-Launcher-agent-icon" }),
354
+ React.createElement("span", { className: "jp-xtralab-Launcher-agent-label" }, editor.label))))));
226
355
  }
@@ -0,0 +1,43 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ import { Token } from '@lumino/coreutils';
3
+ import { ISignal } from '@lumino/signaling';
4
+ import { type IEditor } from './editors';
5
+ /**
6
+ * A read-only, observable view of the launcher's terminal editors: xtralab's
7
+ * built-ins (Neovim, Vim) merged with the user's `editors` setting, sorted by
8
+ * rank, with disabled entries removed.
9
+ */
10
+ export interface IEditorRegistry {
11
+ /**
12
+ * The full merged editor list (disabled entries already removed). The
13
+ * terminals panel uses it to badge a row for any configured editor that is
14
+ * running, and to tell the server which editor commands to detect.
15
+ */
16
+ readonly editors: IEditor[];
17
+ /**
18
+ * The single editor tile the launcher shows: the first editor, by rank, whose
19
+ * command is on `$PATH` (Neovim before Vim by default), or `null` when none
20
+ * qualifies.
21
+ */
22
+ readonly current: IEditor | null;
23
+ /**
24
+ * Emitted whenever {@link editors} or {@link current} changes (the user
25
+ * edited the `editors` setting). Consumers that read on demand can ignore it.
26
+ */
27
+ readonly changed: ISignal<IEditorRegistry, void>;
28
+ }
29
+ /**
30
+ * DI token for {@link IEditorRegistry}. Provided by `xtralab:editor-registry`
31
+ * and consumed — optionally — by `xtralab:launcher` (for its tile) and
32
+ * `xtralab:terminals` (to badge running editors with their logo).
33
+ */
34
+ export declare const IEditorRegistry: Token<IEditorRegistry>;
35
+ /**
36
+ * Provides {@link IEditorRegistry}: merges xtralab's built-in editors with the
37
+ * user's `editors` setting, probes the server's `$PATH` to resolve the launcher
38
+ * tile, and re-runs both on a settings change.
39
+ *
40
+ * `activate` awaits the first probe before returning, so a consumer reading
41
+ * {@link IEditorRegistry.current} on activation sees a resolved tile.
42
+ */
43
+ export declare const editorRegistryPlugin: JupyterFrontEndPlugin<IEditorRegistry>;
@@ -0,0 +1,96 @@
1
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
2
+ import { Token } from '@lumino/coreutils';
3
+ import { Signal } from '@lumino/signaling';
4
+ import { fetchAvailableCommands } from './availability';
5
+ import { mergeEditors, resolveEditor } from './editors';
6
+ /**
7
+ * DI token for {@link IEditorRegistry}. Provided by `xtralab:editor-registry`
8
+ * and consumed — optionally — by `xtralab:launcher` (for its tile) and
9
+ * `xtralab:terminals` (to badge running editors with their logo).
10
+ */
11
+ export const IEditorRegistry = new Token('xtralab:IEditorRegistry', 'A read-only, observable view of the launcher terminal editors, shared so the launcher tile and the terminals panel agree on the list and icons.');
12
+ /**
13
+ * Concrete {@link IEditorRegistry}. The provider plugin is the only writer, so
14
+ * the write side ({@link set}) is kept off the shared token.
15
+ */
16
+ class EditorRegistry {
17
+ constructor() {
18
+ this._editors = [];
19
+ this._current = null;
20
+ this._changed = new Signal(this);
21
+ }
22
+ get editors() {
23
+ return this._editors;
24
+ }
25
+ get current() {
26
+ return this._current;
27
+ }
28
+ get changed() {
29
+ return this._changed;
30
+ }
31
+ /** Replace both lists (recomputed together) and notify observers. */
32
+ set(editors, current) {
33
+ this._editors = editors;
34
+ this._current = current;
35
+ this._changed.emit();
36
+ }
37
+ }
38
+ /** Schema id the `editors` setting lives under (shared with the launcher). */
39
+ const LAUNCHER_SETTINGS_ID = 'xtralab:launcher';
40
+ /**
41
+ * Provides {@link IEditorRegistry}: merges xtralab's built-in editors with the
42
+ * user's `editors` setting, probes the server's `$PATH` to resolve the launcher
43
+ * tile, and re-runs both on a settings change.
44
+ *
45
+ * `activate` awaits the first probe before returning, so a consumer reading
46
+ * {@link IEditorRegistry.current} on activation sees a resolved tile.
47
+ */
48
+ export const editorRegistryPlugin = {
49
+ id: 'xtralab:editor-registry',
50
+ description: "Provides the launcher's terminal-editor list (Neovim, Vim, or user-configured), resolved against the server PATH.",
51
+ autoStart: true,
52
+ provides: IEditorRegistry,
53
+ optional: [ISettingRegistry],
54
+ activate: async (app, settingRegistry) => {
55
+ const registry = new EditorRegistry();
56
+ const apply = async (overrides) => {
57
+ const editors = mergeEditors(overrides);
58
+ // Probe only the editors that opt into the availability check; entries
59
+ // with `requireAvailable: false` (e.g. a shell alias) are shown
60
+ // regardless and don't need a `which` lookup.
61
+ const probe = editors
62
+ .filter(editor => editor.requireAvailable)
63
+ .map(editor => editor.command);
64
+ const available = await fetchAvailableCommands(probe);
65
+ registry.set(editors, resolveEditor(editors, available));
66
+ };
67
+ const readOverrides = (settings) => {
68
+ const raw = settings.composite.editors;
69
+ return Array.isArray(raw) ? raw : [];
70
+ };
71
+ if (settingRegistry) {
72
+ try {
73
+ const settings = await settingRegistry.load(LAUNCHER_SETTINGS_ID);
74
+ await apply(readOverrides(settings));
75
+ settings.changed.connect(async () => {
76
+ try {
77
+ await apply(readOverrides(settings));
78
+ }
79
+ catch (reason) {
80
+ console.error('xtralab: failed to reapply editor settings', reason);
81
+ }
82
+ });
83
+ }
84
+ catch (reason) {
85
+ console.error('xtralab: failed to load editor settings', reason);
86
+ // Settings load failed — fall back to the built-in editors so the
87
+ // launcher tile still resolves instead of going silent.
88
+ await apply([]);
89
+ }
90
+ }
91
+ else {
92
+ await apply([]);
93
+ }
94
+ return registry;
95
+ }
96
+ };
@@ -0,0 +1,78 @@
1
+ import { LabIcon } from '@jupyterlab/ui-components';
2
+ /**
3
+ * A terminal text editor surfaced on the launcher's "Open" section. An editor
4
+ * takes no initial prompt and is launched by typing its command into a fresh
5
+ * terminal (e.g. `nvim`). The launcher shows a single editor tile (the first
6
+ * available candidate; see {@link resolveEditor}); the full list is shared with
7
+ * the terminals panel to badge running editors.
8
+ */
9
+ export interface IEditor {
10
+ /** Stable id; also the default `$PATH` command probed for availability. */
11
+ id: string;
12
+ /** Tile label, e.g. "Neovim". */
13
+ label: string;
14
+ /** Tile tooltip. */
15
+ caption: string;
16
+ /** The literal command typed into the new terminal, e.g. `nvim`. */
17
+ command: string;
18
+ /** Brand icon for the tile. */
19
+ icon: LabIcon;
20
+ /**
21
+ * Preference order for the single launcher tile: the first candidate, by
22
+ * ascending rank, that qualifies wins (Neovim before Vim by default).
23
+ */
24
+ rank: number;
25
+ /**
26
+ * When false, the launcher skips the `which`-based availability check for
27
+ * this entry — useful for a shell alias or function that isn't on PATH but
28
+ * still resolves when typed in a real terminal. Defaults to true.
29
+ */
30
+ requireAvailable: boolean;
31
+ }
32
+ /**
33
+ * The settings-side shape: every field except `id` is optional, so a user can
34
+ * override a single field on a built-in editor (e.g. swap `vim`'s command for
35
+ * a wrapper) without restating the whole entry. New ids define brand-new
36
+ * editors. Mirrors `IAgentSettings` minus `promptArgs`.
37
+ */
38
+ export interface IEditorSettings {
39
+ id: string;
40
+ label?: string;
41
+ caption?: string;
42
+ command?: string;
43
+ /**
44
+ * Inline SVG for a custom editor's icon. Required for new ids whose icon
45
+ * isn't shipped with xtralab; ignored for built-in ids unless explicitly
46
+ * set (in which case it overrides the built-in).
47
+ */
48
+ iconSvg?: string;
49
+ rank?: number;
50
+ /**
51
+ * When false, the editor is hidden from the launcher's Open section (and no
52
+ * longer recognised by the terminals panel). Disable both built-ins to drop
53
+ * the editor tile entirely. Defaults to true.
54
+ */
55
+ enabled?: boolean;
56
+ /** See {@link IEditor.requireAvailable}. */
57
+ requireAvailable?: boolean;
58
+ }
59
+ /**
60
+ * Merge xtralab's built-in editors with the user's `editors` settings. Built-in
61
+ * entries keep their fields unless explicitly overridden; user-only entries are
62
+ * appended. `enabled: false` filters an entry out of the result entirely (so
63
+ * callers don't need to re-check the flag). The result is sorted by rank, which
64
+ * is also the launcher's tile-preference order. Mirrors `mergeAgents`.
65
+ */
66
+ export declare function mergeEditors(overrides: IEditorSettings[]): IEditor[];
67
+ /**
68
+ * Pick the single editor tile to show, given the merged editor list and the
69
+ * set of commands known to be on `$PATH` (from the launcher's availability
70
+ * probe). Returns the first candidate, by rank, that is either available or
71
+ * opts out of the check (`requireAvailable: false`) — Neovim before Vim by
72
+ * default — or `null` when none qualifies.
73
+ *
74
+ * When `available` is `null` (the endpoint couldn't be reached), a
75
+ * `requireAvailable` editor is not shown; an entry with
76
+ * `requireAvailable: false` is shown regardless.
77
+ */
78
+ export declare function resolveEditor(editors: IEditor[], available: Set<string> | null): IEditor | null;