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,24 +1,19 @@
1
1
  import type { JupyterFrontEnd } from '@jupyterlab/application';
2
2
  import type { MainAreaWidget } from '@jupyterlab/apputils';
3
3
  import type { ITerminal } from '@jupyterlab/terminal';
4
+ import type { CommandRegistry } from '@lumino/commands';
4
5
  import { DisposableSet, type IDisposable } from '@lumino/disposable';
5
6
 
7
+ import type { IAgentSessions } from '../agentSessions';
6
8
  import type { IAgent } from './agents';
7
9
  import { buildAgentInvocation } from './invocation';
10
+ import { agentCommandId } from './tokens';
8
11
 
9
12
  /**
10
13
  * The command id for opening the launcher.
11
14
  */
12
15
  export const CREATE_LAUNCHER_COMMAND = 'launcher:create';
13
16
 
14
- /**
15
- * Build a JupyterLab command id for a given agent. Centralised so the
16
- * plugin and the launcher both use the same names.
17
- */
18
- export function agentCommandId(agentId: string): string {
19
- return `xtralab:start-agent:${agentId}`;
20
- }
21
-
22
17
  /**
23
18
  * Register a JupyterLab command per agent. Each command opens a new
24
19
  * terminal via `terminal:create-new` and feeds the agent's shell command
@@ -27,10 +22,16 @@ export function agentCommandId(agentId: string): string {
27
22
  * Returns a single disposable that tears down every registered command —
28
23
  * the launcher disposes the previous set before re-registering on settings
29
24
  * changes so the command palette stays in sync with the configured agents.
25
+ *
26
+ * When an `agentSessions` registry is supplied, each launch tags its terminal
27
+ * session with the agent's command, so the terminals panel can badge the row
28
+ * with the agent's logo immediately (before server-side detection confirms
29
+ * it).
30
30
  */
31
31
  export function registerAgentCommands(
32
32
  app: JupyterFrontEnd,
33
- agents: IAgent[]
33
+ agents: IAgent[],
34
+ agentSessions: IAgentSessions | null = null
34
35
  ): IDisposable {
35
36
  const disposables = new DisposableSet();
36
37
  for (const agent of agents) {
@@ -43,61 +44,98 @@ export function registerAgentCommands(
43
44
  const promptArg = args['prompt'];
44
45
  const prompt = typeof promptArg === 'string' ? promptArg : '';
45
46
  const invocation = buildAgentInvocation(agent, prompt);
46
- const main = (await app.commands.execute('terminal:create-new', {
47
- cwd
48
- })) as MainAreaWidget<ITerminal.ITerminal>;
49
- // `MainAreaWidget.revealed` chains off the `reveal` promise the
50
- // terminal extension passes in (the xterm.js widget's own `ready`
51
- // promise), so awaiting it guarantees the Terminal widget has
52
- // finished its constructor and connected its session listeners.
53
- await main.revealed;
47
+ return launchInTerminal(app.commands, {
48
+ cwd,
49
+ invocation,
50
+ label: agent.label,
51
+ // Optimistically tag the session with this agent so the terminals
52
+ // panel can show its logo right away; server-side detection takes
53
+ // over as the source of truth on its next poll.
54
+ onSession: name => agentSessions?.set(name, agent.command)
55
+ });
56
+ }
57
+ });
58
+ disposables.add(disposable);
59
+ }
60
+ return disposables;
61
+ }
54
62
 
55
- const session = main.content.session;
56
- const sendCommand = (): void => {
57
- session.send({
58
- type: 'stdin',
59
- content: [invocation + '\r']
60
- });
61
- // Give the tab and the status-bar list a meaningful default
62
- // label. The XTerm widget's `_initialConnection` listener
63
- // overwrites the title with `Terminal {N}` when the WebSocket
64
- // first reports `connected`; we connect *after* that listener
65
- // (and call `applyAgentLabel` after `sendCommand`), so by the
66
- // time we run the upstream listener has already had its turn.
67
- // Programs that publish a real xterm title escape sequence
68
- // still win — `XTerm.onTitleChange` resets the label whenever
69
- // it fires.
70
- applyAgentLabel();
71
- };
63
+ /**
64
+ * Open a fresh terminal, type `invocation` into it as if the user had, and
65
+ * label the tab. Shared by the per-agent launch commands and the launcher's
66
+ * editor tile so the fiddly "wait for the websocket, then write stdin"
67
+ * sequence lives in one place.
68
+ *
69
+ * `onSession`, when given, is called once with the new session's name right
70
+ * after the terminal is revealed. Callers use it to optimistically tag the
71
+ * session so the terminals panel can badge it with the agent's (or editor's)
72
+ * logo before server-side detection confirms the running process.
73
+ *
74
+ * Resolves with the host `MainAreaWidget` so the caller can place it — the
75
+ * launcher swaps it into its own tab.
76
+ */
77
+ export async function launchInTerminal(
78
+ commands: CommandRegistry,
79
+ options: {
80
+ cwd?: string;
81
+ /** The literal command line typed into the fresh terminal. */
82
+ invocation: string;
83
+ /** Tab/title label applied once the command is sent. */
84
+ label: string;
85
+ onSession?: (sessionName: string) => void;
86
+ }
87
+ ): Promise<MainAreaWidget<ITerminal.ITerminal>> {
88
+ const { cwd, invocation, label, onSession } = options;
89
+ const main = (await commands.execute('terminal:create-new', {
90
+ cwd
91
+ })) as MainAreaWidget<ITerminal.ITerminal>;
92
+ // `MainAreaWidget.revealed` chains off the `reveal` promise the terminal
93
+ // extension passes in (the xterm.js widget's own `ready` promise), so
94
+ // awaiting it guarantees the Terminal widget has finished its constructor
95
+ // and connected its session listeners.
96
+ await main.revealed;
72
97
 
73
- const applyAgentLabel = (): void => {
74
- if (main.isDisposed || main.content.isDisposed) {
75
- return;
76
- }
77
- main.content.title.label = agent.label;
78
- };
98
+ const session = main.content.session;
99
+ onSession?.(session.name);
79
100
 
80
- // Match how the Terminal widget itself sends its `initialCommand`:
81
- // either fire immediately if the session is already connected, or
82
- // wait for the next `connectionStatusChanged` that flips it to
83
- // `connected`. Without this the stdin write races the websocket
84
- // handshake and the agent command silently disappears.
85
- if (session.connectionStatus === 'connected') {
86
- sendCommand();
87
- } else {
88
- const onStatus = (): void => {
89
- if (session.connectionStatus === 'connected') {
90
- session.connectionStatusChanged.disconnect(onStatus);
91
- sendCommand();
92
- }
93
- };
94
- session.connectionStatusChanged.connect(onStatus);
95
- }
101
+ const applyLabel = (): void => {
102
+ if (main.isDisposed || main.content.isDisposed) {
103
+ return;
104
+ }
105
+ main.content.title.label = label;
106
+ };
96
107
 
97
- return main;
98
- }
108
+ const sendCommand = (): void => {
109
+ session.send({
110
+ type: 'stdin',
111
+ content: [invocation + '\r']
99
112
  });
100
- disposables.add(disposable);
113
+ // Give the tab and the status-bar list a meaningful default label. The
114
+ // XTerm widget's `_initialConnection` listener overwrites the title with
115
+ // `Terminal {N}` when the WebSocket first reports `connected`; we connect
116
+ // *after* that listener (and call `applyLabel` after `sendCommand`), so by
117
+ // the time we run the upstream listener has already had its turn. Programs
118
+ // that publish a real xterm title escape sequence still win —
119
+ // `XTerm.onTitleChange` resets the label whenever it fires.
120
+ applyLabel();
121
+ };
122
+
123
+ // Match how the Terminal widget itself sends its `initialCommand`: either
124
+ // fire immediately if the session is already connected, or wait for the next
125
+ // `connectionStatusChanged` that flips it to `connected`. Without this the
126
+ // stdin write races the websocket handshake and the command silently
127
+ // disappears.
128
+ if (session.connectionStatus === 'connected') {
129
+ sendCommand();
130
+ } else {
131
+ const onStatus = (): void => {
132
+ if (session.connectionStatus === 'connected') {
133
+ session.connectionStatusChanged.disconnect(onStatus);
134
+ sendCommand();
135
+ }
136
+ };
137
+ session.connectionStatusChanged.connect(onStatus);
101
138
  }
102
- return disposables;
139
+
140
+ return main;
103
141
  }
@@ -3,6 +3,7 @@ import * as React from 'react';
3
3
  import type { CommandRegistry } from '@lumino/commands';
4
4
  import type { ReadonlyPartialJSONObject } from '@lumino/coreutils';
5
5
  import { Poll } from '@lumino/polling';
6
+ import type { IDocumentWidget } from '@jupyterlab/docregistry';
6
7
  import {
7
8
  LabIcon,
8
9
  ReactWidget,
@@ -13,6 +14,7 @@ import {
13
14
  } from '@jupyterlab/ui-components';
14
15
  import type { Widget } from '@lumino/widgets';
15
16
 
17
+ import type { IAgentSessions } from '../agentSessions';
16
18
  import { expandStatusFiles, status } from '../git/api';
17
19
  import {
18
20
  CommandIDs as GitCommandIDs,
@@ -25,7 +27,9 @@ import type {
25
27
  } from '../git/tokens';
26
28
 
27
29
  import type { IAgent } from './agents';
28
- import { agentCommandId } from './commands';
30
+ import { launchInTerminal } from './commands';
31
+ import type { IEditor } from './editors';
32
+ import { agentCommandId } from './tokens';
29
33
 
30
34
  /**
31
35
  * Public construction options for the launcher dashboard widget. The
@@ -41,6 +45,19 @@ export interface ILauncherDashboardOptions {
41
45
  * rank; the widget renders them as-is.
42
46
  */
43
47
  agents: IAgent[];
48
+ /**
49
+ * The terminal editor to offer in the "Open" section (Neovim preferred over
50
+ * Vim), or `null` when neither is installed. Resolved by the plugin from the
51
+ * same availability probe that filters the agents.
52
+ */
53
+ editor: IEditor | null;
54
+ /**
55
+ * Shared launch-tag registry. When the editor tile launches, it records the
56
+ * session here so the terminals panel badges it with the editor's logo right
57
+ * away, the same as agent launches do. `null` when the provider plugin is
58
+ * unavailable.
59
+ */
60
+ agentSessions: IAgentSessions | null;
44
61
  /**
45
62
  * Called after an agent command has returned its top-level widget. The
46
63
  * plugin uses this to swap the new terminal into the launcher's tab.
@@ -101,7 +118,15 @@ const CHANGES_POLL_MAX_MS = 300_000;
101
118
  function LauncherDashboardComponent(
102
119
  props: ILauncherDashboardOptions
103
120
  ): React.ReactElement {
104
- const { commands, agents, onAgentLaunch, repoPath, cwd } = props;
121
+ const {
122
+ commands,
123
+ agents,
124
+ editor,
125
+ agentSessions,
126
+ onAgentLaunch,
127
+ repoPath,
128
+ cwd
129
+ } = props;
105
130
  const [prompt, setPrompt] = React.useState('');
106
131
  const [git, setGit] = React.useState<IGitState>({
107
132
  loading: true,
@@ -169,13 +194,28 @@ function LauncherDashboardComponent(
169
194
  }, [commands, cwd, onAgentLaunch]);
170
195
 
171
196
  const launchNotebook = React.useCallback(async () => {
172
- // No `kernelName` — let the notebook extension fall back to the default
173
- // kernel or prompt the user, matching the stock launcher's notebook
174
- // tile behavior.
175
- const result = (await commands.execute('notebook:create-new', {
176
- cwd
177
- })) as Widget | undefined;
197
+ // Open a fresh notebook with no kernel attached — equivalent to clicking
198
+ // "No Kernel" in the Select Kernel dialog. `notebook:create-new` always
199
+ // routes through that dialog when no kernelName is given, so drive the
200
+ // underlying docmanager steps directly and pass `shouldStart: false` to
201
+ // suppress kernel selection. The user picks a kernel from the notebook
202
+ // toolbar when they're ready.
203
+ const file = (await commands.execute('docmanager:new-untitled', {
204
+ path: cwd,
205
+ type: 'notebook'
206
+ })) as { path: string } | undefined;
207
+ if (!file) {
208
+ return;
209
+ }
210
+ const result = (await commands.execute('docmanager:open', {
211
+ path: file.path,
212
+ factory: 'Notebook',
213
+ kernelPreference: { shouldStart: false, canStart: true }
214
+ })) as IDocumentWidget | undefined;
178
215
  if (result) {
216
+ // Match `notebook:create-new`'s post-open marker so the first manual
217
+ // save offers a rename instead of overwriting the auto-generated name.
218
+ result.isUntitled = true;
179
219
  onAgentLaunch(result);
180
220
  }
181
221
  }, [commands, cwd, onAgentLaunch]);
@@ -189,6 +229,23 @@ function LauncherDashboardComponent(
189
229
  }
190
230
  }, [commands, cwd, onAgentLaunch]);
191
231
 
232
+ const launchEditor = React.useCallback(async () => {
233
+ if (!editor) {
234
+ return;
235
+ }
236
+ // An editor is "open a terminal and type its command" — the agent launch
237
+ // minus the prompt — so it shares `launchInTerminal` rather than the native
238
+ // `terminal:create-new` call the tiles above use, and tags the session the
239
+ // same way so the terminals panel badges it with the editor's logo.
240
+ const result = await launchInTerminal(commands, {
241
+ cwd,
242
+ invocation: editor.command,
243
+ label: editor.label,
244
+ onSession: name => agentSessions?.set(name, editor.command)
245
+ });
246
+ onAgentLaunch(result);
247
+ }, [commands, cwd, editor, agentSessions, onAgentLaunch]);
248
+
192
249
  const openDiff = React.useCallback(
193
250
  (change: IFileChange) => {
194
251
  const args: GitCommandArguments.IOpenDiff = { repoPath, change };
@@ -208,10 +265,13 @@ function LauncherDashboardComponent(
208
265
  onPromptChange={setPrompt}
209
266
  onLaunch={launch}
210
267
  />
268
+ <McpSection agents={agents} />
211
269
  <OpenSection
270
+ editor={editor}
212
271
  onLaunchTerminal={launchTerminal}
213
272
  onLaunchNotebook={launchNotebook}
214
273
  onLaunchConsole={launchConsole}
274
+ onLaunchEditor={launchEditor}
215
275
  />
216
276
  <ChangesSection
217
277
  git={git}
@@ -410,19 +470,159 @@ function AgentSection(props: {
410
470
  );
411
471
  }
412
472
 
473
+ /**
474
+ * Launcher agents whose CLI has a clean, non-interactive `mcp add` that takes
475
+ * `<name> -- <command>`. The other agents (Goose, OpenCode, Kiro, Mistral
476
+ * Vibe, Antigravity) register MCP through their own config files or UI, so
477
+ * there is no one-line command to surface for them.
478
+ */
479
+ const MCP_ADD_AGENT_IDS = new Set(['claude', 'codex', 'copilot']);
480
+
481
+ /** The stdio proxy console script that bridges an agent to the MCP server. */
482
+ const MCP_PROXY_COMMAND = 'jupyter-server-mcp-proxy';
483
+
484
+ /**
485
+ * Copy `text` to the clipboard via a hidden, selected `<textarea>` and the
486
+ * legacy `execCommand('copy')`. Deprecated, but still the most reliable path
487
+ * under a user gesture when the async Clipboard API is unavailable or blocked
488
+ * (the desktop app's Electron window can deny clipboard writes by policy).
489
+ * Returns whether the copy succeeded.
490
+ */
491
+ function legacyCopy(text: string): boolean {
492
+ try {
493
+ const textarea = document.createElement('textarea');
494
+ textarea.value = text;
495
+ textarea.setAttribute('readonly', '');
496
+ textarea.style.position = 'fixed';
497
+ textarea.style.top = '-1000px';
498
+ textarea.style.opacity = '0';
499
+ document.body.appendChild(textarea);
500
+ textarea.select();
501
+ const ok = document.execCommand('copy');
502
+ document.body.removeChild(textarea);
503
+ return ok;
504
+ } catch {
505
+ return false;
506
+ }
507
+ }
508
+
509
+ /**
510
+ * A collapsed-by-default hint listing the one-line command that registers
511
+ * xtralab's running Jupyter MCP server with each installed agent that
512
+ * supports `mcp add`. Copy-only: the user runs it in their own terminal,
513
+ * where the server is reachable with no port to configure — the desktop
514
+ * supervisor exports `JUPYTER_SERVER_MCP_URL`, and `pip` installs let the
515
+ * proxy discover the server from its runtime file. Renders nothing when none
516
+ * of those agents are on `$PATH`.
517
+ */
518
+ function McpSection(props: { agents: IAgent[] }): React.ReactElement | null {
519
+ const [copiedId, setCopiedId] = React.useState<string | null>(null);
520
+ const registerable = props.agents.filter(agent =>
521
+ MCP_ADD_AGENT_IDS.has(agent.id)
522
+ );
523
+ if (registerable.length === 0) {
524
+ return null;
525
+ }
526
+
527
+ const flagCopied = (id: string): void => {
528
+ setCopiedId(id);
529
+ window.setTimeout(
530
+ () => setCopiedId(current => (current === id ? null : current)),
531
+ 1500
532
+ );
533
+ };
534
+
535
+ const copy = (command: string, id: string): void => {
536
+ // Prefer the async Clipboard API; fall back to `legacyCopy` when it's
537
+ // missing or rejects (Electron can block it by policy). Flag "Copied" only
538
+ // once a copy actually succeeds.
539
+ const clipboard = navigator.clipboard;
540
+ if (clipboard) {
541
+ void clipboard.writeText(command).then(
542
+ () => flagCopied(id),
543
+ () => {
544
+ if (legacyCopy(command)) {
545
+ flagCopied(id);
546
+ }
547
+ }
548
+ );
549
+ } else if (legacyCopy(command)) {
550
+ flagCopied(id);
551
+ }
552
+ };
553
+
554
+ return (
555
+ <details className="jp-xtralab-Launcher-section jp-xtralab-Launcher-mcp-section">
556
+ <summary className="jp-xtralab-Launcher-section-summary">
557
+ <span className="jp-xtralab-Launcher-section-title">
558
+ Connect agents to JupyterLab (MCP)
559
+ </span>
560
+ </summary>
561
+ <div className="jp-xtralab-Launcher-section-body">
562
+ <p className="jp-xtralab-Launcher-agent-hint">
563
+ Run once per agent to let it drive this JupyterLab over MCP. No port
564
+ needed — the proxy finds this server on its own.
565
+ </p>
566
+ <ul className="jp-xtralab-Launcher-mcp-list">
567
+ {registerable.map(agent => {
568
+ const command = `${agent.command} mcp add jupyter -- ${MCP_PROXY_COMMAND}`;
569
+ return (
570
+ <li key={agent.id} className="jp-xtralab-Launcher-mcp-item">
571
+ <code className="jp-xtralab-Launcher-mcp-command">
572
+ {command}
573
+ </code>
574
+ <button
575
+ type="button"
576
+ className={
577
+ copiedId === agent.id
578
+ ? 'jp-xtralab-Launcher-mcp-copy jp-mod-copied'
579
+ : 'jp-xtralab-Launcher-mcp-copy'
580
+ }
581
+ aria-label={`Copy the ${agent.label} command`}
582
+ onClick={() => copy(command, agent.id)}
583
+ >
584
+ {copiedId === agent.id ? 'Copied!' : 'Copy'}
585
+ </button>
586
+ </li>
587
+ );
588
+ })}
589
+ </ul>
590
+ <p className="jp-xtralab-Launcher-mcp-note">
591
+ Other agents (Goose, OpenCode, Kiro, Mistral Vibe, Antigravity)
592
+ register MCP through their own config — point them at the same{' '}
593
+ <code>{MCP_PROXY_COMMAND}</code> command.
594
+ </p>
595
+ </div>
596
+ </details>
597
+ );
598
+ }
599
+
413
600
  /**
414
601
  * The non-agent launch tiles — a plain terminal, a new notebook, and a
415
602
  * new console. They sit in a separate section below the agent grid so
416
603
  * they don't compete with the AI agents visually, and they're always
417
604
  * available regardless of the agent prompt textarea (which doesn't
418
605
  * apply to them).
606
+ *
607
+ * A terminal editor (Neovim, else Vim) joins the row when one is installed —
608
+ * it opens in a terminal like the agents do, so it lives here rather than in
609
+ * the agent grid. When neither is on `$PATH`, `editor` is `null` and no tile
610
+ * renders.
419
611
  */
420
612
  function OpenSection(props: {
613
+ editor: IEditor | null;
421
614
  onLaunchTerminal: () => void;
422
615
  onLaunchNotebook: () => void;
423
616
  onLaunchConsole: () => void;
617
+ onLaunchEditor: () => void;
424
618
  }): React.ReactElement {
425
- const { onLaunchTerminal, onLaunchNotebook, onLaunchConsole } = props;
619
+ const {
620
+ editor,
621
+ onLaunchTerminal,
622
+ onLaunchNotebook,
623
+ onLaunchConsole,
624
+ onLaunchEditor
625
+ } = props;
426
626
  return (
427
627
  <section className="jp-xtralab-Launcher-section">
428
628
  <h2 className="jp-xtralab-Launcher-section-title">Open</h2>
@@ -469,6 +669,24 @@ function OpenSection(props: {
469
669
  />
470
670
  <span className="jp-xtralab-Launcher-agent-label">Console</span>
471
671
  </button>
672
+ {editor && (
673
+ <button
674
+ type="button"
675
+ className="jp-xtralab-Launcher-agent"
676
+ title={editor.caption}
677
+ aria-label={editor.caption}
678
+ onClick={onLaunchEditor}
679
+ >
680
+ <LabIcon.resolveReact
681
+ icon={editor.icon}
682
+ tag="span"
683
+ className="jp-xtralab-Launcher-agent-icon"
684
+ />
685
+ <span className="jp-xtralab-Launcher-agent-label">
686
+ {editor.label}
687
+ </span>
688
+ </button>
689
+ )}
472
690
  </div>
473
691
  </section>
474
692
  );
@@ -0,0 +1,149 @@
1
+ import {
2
+ JupyterFrontEnd,
3
+ JupyterFrontEndPlugin
4
+ } from '@jupyterlab/application';
5
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
6
+ import { Token } from '@lumino/coreutils';
7
+ import { ISignal, Signal } from '@lumino/signaling';
8
+
9
+ import { fetchAvailableCommands } from './availability';
10
+ import {
11
+ mergeEditors,
12
+ resolveEditor,
13
+ type IEditor,
14
+ type IEditorSettings
15
+ } from './editors';
16
+
17
+ /**
18
+ * A read-only, observable view of the launcher's terminal editors: xtralab's
19
+ * built-ins (Neovim, Vim) merged with the user's `editors` setting, sorted by
20
+ * rank, with disabled entries removed.
21
+ */
22
+ export interface IEditorRegistry {
23
+ /**
24
+ * The full merged editor list (disabled entries already removed). The
25
+ * terminals panel uses it to badge a row for any configured editor that is
26
+ * running, and to tell the server which editor commands to detect.
27
+ */
28
+ readonly editors: IEditor[];
29
+
30
+ /**
31
+ * The single editor tile the launcher shows: the first editor, by rank, whose
32
+ * command is on `$PATH` (Neovim before Vim by default), or `null` when none
33
+ * qualifies.
34
+ */
35
+ readonly current: IEditor | null;
36
+
37
+ /**
38
+ * Emitted whenever {@link editors} or {@link current} changes (the user
39
+ * edited the `editors` setting). Consumers that read on demand can ignore it.
40
+ */
41
+ readonly changed: ISignal<IEditorRegistry, void>;
42
+ }
43
+
44
+ /**
45
+ * DI token for {@link IEditorRegistry}. Provided by `xtralab:editor-registry`
46
+ * and consumed — optionally — by `xtralab:launcher` (for its tile) and
47
+ * `xtralab:terminals` (to badge running editors with their logo).
48
+ */
49
+ export const IEditorRegistry = new Token<IEditorRegistry>(
50
+ 'xtralab:IEditorRegistry',
51
+ '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.'
52
+ );
53
+
54
+ /**
55
+ * Concrete {@link IEditorRegistry}. The provider plugin is the only writer, so
56
+ * the write side ({@link set}) is kept off the shared token.
57
+ */
58
+ class EditorRegistry implements IEditorRegistry {
59
+ get editors(): IEditor[] {
60
+ return this._editors;
61
+ }
62
+
63
+ get current(): IEditor | null {
64
+ return this._current;
65
+ }
66
+
67
+ get changed(): ISignal<IEditorRegistry, void> {
68
+ return this._changed;
69
+ }
70
+
71
+ /** Replace both lists (recomputed together) and notify observers. */
72
+ set(editors: IEditor[], current: IEditor | null): void {
73
+ this._editors = editors;
74
+ this._current = current;
75
+ this._changed.emit();
76
+ }
77
+
78
+ private _editors: IEditor[] = [];
79
+ private _current: IEditor | null = null;
80
+ private _changed = new Signal<IEditorRegistry, void>(this);
81
+ }
82
+
83
+ /** Schema id the `editors` setting lives under (shared with the launcher). */
84
+ const LAUNCHER_SETTINGS_ID = 'xtralab:launcher';
85
+
86
+ /**
87
+ * Provides {@link IEditorRegistry}: merges xtralab's built-in editors with the
88
+ * user's `editors` setting, probes the server's `$PATH` to resolve the launcher
89
+ * tile, and re-runs both on a settings change.
90
+ *
91
+ * `activate` awaits the first probe before returning, so a consumer reading
92
+ * {@link IEditorRegistry.current} on activation sees a resolved tile.
93
+ */
94
+ export const editorRegistryPlugin: JupyterFrontEndPlugin<IEditorRegistry> = {
95
+ id: 'xtralab:editor-registry',
96
+ description:
97
+ "Provides the launcher's terminal-editor list (Neovim, Vim, or user-configured), resolved against the server PATH.",
98
+ autoStart: true,
99
+ provides: IEditorRegistry,
100
+ optional: [ISettingRegistry],
101
+ activate: async (
102
+ app: JupyterFrontEnd,
103
+ settingRegistry: ISettingRegistry | null
104
+ ): Promise<IEditorRegistry> => {
105
+ const registry = new EditorRegistry();
106
+
107
+ const apply = async (overrides: IEditorSettings[]): Promise<void> => {
108
+ const editors = mergeEditors(overrides);
109
+ // Probe only the editors that opt into the availability check; entries
110
+ // with `requireAvailable: false` (e.g. a shell alias) are shown
111
+ // regardless and don't need a `which` lookup.
112
+ const probe = editors
113
+ .filter(editor => editor.requireAvailable)
114
+ .map(editor => editor.command);
115
+ const available = await fetchAvailableCommands(probe);
116
+ registry.set(editors, resolveEditor(editors, available));
117
+ };
118
+
119
+ const readOverrides = (
120
+ settings: ISettingRegistry.ISettings
121
+ ): IEditorSettings[] => {
122
+ const raw = settings.composite.editors;
123
+ return Array.isArray(raw) ? (raw as IEditorSettings[]) : [];
124
+ };
125
+
126
+ if (settingRegistry) {
127
+ try {
128
+ const settings = await settingRegistry.load(LAUNCHER_SETTINGS_ID);
129
+ await apply(readOverrides(settings));
130
+ settings.changed.connect(async () => {
131
+ try {
132
+ await apply(readOverrides(settings));
133
+ } catch (reason) {
134
+ console.error('xtralab: failed to reapply editor settings', reason);
135
+ }
136
+ });
137
+ } catch (reason) {
138
+ console.error('xtralab: failed to load editor settings', reason);
139
+ // Settings load failed — fall back to the built-in editors so the
140
+ // launcher tile still resolves instead of going silent.
141
+ await apply([]);
142
+ }
143
+ } else {
144
+ await apply([]);
145
+ }
146
+
147
+ return registry;
148
+ }
149
+ };