xtralab 0.4.0 → 0.6.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 (96) hide show
  1. package/README.md +110 -73
  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/commands.d.ts +12 -13
  6. package/lib/git/commands.js +7 -28
  7. package/lib/git/diffProvider.d.ts +8 -0
  8. package/lib/git/diffProvider.js +314 -0
  9. package/lib/git/diffSurface.d.ts +115 -0
  10. package/lib/git/diffSurface.js +342 -0
  11. package/lib/git/diffWidget.d.ts +8 -13
  12. package/lib/git/diffWidget.js +65 -311
  13. package/lib/git/imageDiff.d.ts +23 -0
  14. package/lib/git/imageDiff.js +256 -0
  15. package/lib/git/index.d.ts +10 -8
  16. package/lib/git/index.js +37 -52
  17. package/lib/index.d.ts +4 -2
  18. package/lib/index.js +14 -8
  19. package/lib/launcher/agents.d.ts +1 -1
  20. package/lib/launcher/agents.js +22 -13
  21. package/lib/launcher/commands.d.ts +32 -6
  22. package/lib/launcher/commands.js +81 -57
  23. package/lib/launcher/dashboard.d.ts +15 -0
  24. package/lib/launcher/dashboard.js +119 -5
  25. package/lib/launcher/editorRegistry.d.ts +43 -0
  26. package/lib/launcher/editorRegistry.js +96 -0
  27. package/lib/launcher/editors.d.ts +78 -0
  28. package/lib/launcher/editors.js +110 -0
  29. package/lib/launcher/icons.d.ts +25 -6
  30. package/lib/launcher/icons.js +116 -19
  31. package/lib/launcher/index.d.ts +2 -22
  32. package/lib/launcher/index.js +48 -25
  33. package/lib/launcher/registry.d.ts +21 -0
  34. package/lib/launcher/registry.js +28 -0
  35. package/lib/launcher/tokens.d.ts +41 -0
  36. package/lib/launcher/tokens.js +17 -0
  37. package/lib/terminals/detection.d.ts +11 -0
  38. package/lib/terminals/detection.js +50 -0
  39. package/lib/terminals/index.d.ts +27 -0
  40. package/lib/terminals/index.js +194 -0
  41. package/lib/terminals/model.d.ts +191 -0
  42. package/lib/terminals/model.js +418 -0
  43. package/lib/terminals/widget.d.ts +72 -0
  44. package/lib/terminals/widget.js +93 -0
  45. package/lib/topBar/icons.d.ts +13 -0
  46. package/lib/topBar/icons.js +27 -0
  47. package/lib/topBar/index.d.ts +16 -0
  48. package/lib/topBar/index.js +86 -0
  49. package/package.json +52 -35
  50. package/schema/launcher.json +56 -1
  51. package/src/agentSessions.ts +94 -0
  52. package/src/fileBrowser/index.ts +5 -1
  53. package/src/git/commands.ts +12 -38
  54. package/src/git/diffProvider.tsx +462 -0
  55. package/src/git/diffSurface.tsx +588 -0
  56. package/src/git/diffWidget.tsx +105 -465
  57. package/src/git/imageDiff.tsx +469 -0
  58. package/src/git/index.ts +38 -68
  59. package/src/index.ts +14 -8
  60. package/src/launcher/agents.ts +23 -14
  61. package/src/launcher/commands.ts +98 -60
  62. package/src/launcher/dashboard.tsx +205 -3
  63. package/src/launcher/editorRegistry.ts +149 -0
  64. package/src/launcher/editors.ts +183 -0
  65. package/src/launcher/icons.ts +120 -20
  66. package/src/launcher/index.ts +62 -27
  67. package/src/launcher/registry.ts +33 -0
  68. package/src/launcher/tokens.ts +51 -0
  69. package/src/terminals/detection.ts +65 -0
  70. package/src/terminals/index.ts +222 -0
  71. package/src/terminals/model.ts +497 -0
  72. package/src/terminals/widget.tsx +240 -0
  73. package/src/topBar/icons.ts +30 -0
  74. package/src/topBar/index.ts +121 -0
  75. package/style/git.css +267 -266
  76. package/style/index.css +2 -1
  77. package/style/index.js +2 -1
  78. package/style/launcher.css +124 -2
  79. package/style/terminals.css +235 -0
  80. package/style/topBar.css +38 -0
  81. package/lib/git/icons.d.ts +0 -14
  82. package/lib/git/icons.js +0 -19
  83. package/lib/git/panel.d.ts +0 -29
  84. package/lib/git/panel.js +0 -42
  85. package/lib/git/panelComponent.d.ts +0 -28
  86. package/lib/git/panelComponent.js +0 -320
  87. package/lib/statusBar/index.d.ts +0 -14
  88. package/lib/statusBar/index.js +0 -89
  89. package/lib/statusBar/widget.d.ts +0 -121
  90. package/lib/statusBar/widget.js +0 -237
  91. package/src/git/icons.ts +0 -20
  92. package/src/git/panel.tsx +0 -56
  93. package/src/git/panelComponent.tsx +0 -575
  94. package/src/statusBar/index.ts +0 -115
  95. package/src/statusBar/widget.tsx +0 -318
  96. package/style/statusBar.css +0 -95
@@ -26,7 +26,7 @@ export interface IAgent {
26
26
  * the user types one into the launcher's prompt box. The semantics:
27
27
  *
28
28
  * - `[]` → prompt is appended as a positional argument:
29
- * `<command> 'PROMPT'`. (Used by claude, codex, gemini, vibe.)
29
+ * `<command> 'PROMPT'`. (Used by claude, codex, vibe.)
30
30
  * - `['-i']` / `['--prompt']` → the prompt is preceded by a flag:
31
31
  * `<command> -i 'PROMPT'`. (Used by copilot, opencode.)
32
32
  * - `undefined` → the agent does not accept an initial prompt. The
@@ -75,12 +75,15 @@ export interface IAgentSettings {
75
75
  }
76
76
 
77
77
  /**
78
- * Default agent cards shipped with xtralab. The list mirrors the eight
79
- * first-class personas in `jupyter-ai-contrib/jupyter-ai-acp-client` (which
80
- * is also where the icons come from), but uses the bare CLI names a user
81
- * would type in a terminal rather than the ACP wrapper binaries that
82
- * project spawns. Anything not on the user's `$PATH` is filtered out at
83
- * activation time, so the wider list is harmless.
78
+ * Default agent cards shipped with xtralab. Seven of these (Claude, Codex,
79
+ * Copilot, Goose, OpenCode, Kiro, Mistral Vibe) mirror first-class personas
80
+ * in `jupyter-ai-contrib/jupyter-ai-acp-client` (which is also where their
81
+ * icons come from), but use the bare CLI names a user would type in a
82
+ * terminal rather than the ACP wrapper binaries that project spawns.
83
+ * Antigravity (Google's agent-first IDE/CLI, `agy`) takes the Google slot —
84
+ * Gemini CLI was dropped because Google is sunsetting it for consumer
85
+ * accounts on 2026-06-18. Anything not on the user's `$PATH` is filtered
86
+ * out at activation time, so the wider list is harmless.
84
87
  */
85
88
  const DEFAULTS: IAgent[] = [
86
89
  {
@@ -104,14 +107,20 @@ const DEFAULTS: IAgent[] = [
104
107
  promptArgs: []
105
108
  },
106
109
  {
107
- id: 'gemini',
108
- label: 'Gemini',
109
- caption: 'Start the Gemini CLI in a new terminal.',
110
- command: 'gemini',
111
- icon: BUILTIN_AGENT_ICONS.gemini,
110
+ id: 'antigravity',
111
+ label: 'Antigravity',
112
+ caption: 'Open Google Antigravity from a new terminal.',
113
+ command: 'agy',
114
+ icon: BUILTIN_AGENT_ICONS.antigravity,
112
115
  rank: 2,
113
- requireAvailable: true,
114
- promptArgs: []
116
+ requireAvailable: true
117
+ // `agy` is the Antigravity launcher binary — a VS Code-derived editor
118
+ // CLI that opens the Agent Manager / workspace, not a terminal REPL.
119
+ // Its positional args are file/folder paths, so there is no inline
120
+ // natural-language prompt form; like goose/kiro we omit promptArgs so a
121
+ // typed prompt dims the button instead of being mangled into a path.
122
+ // The command stays the bare binary (no `agy .`) so the server-side
123
+ // `shutil.which` availability check still resolves it.
115
124
  },
116
125
  {
117
126
  id: 'copilot',
@@ -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
  }
@@ -13,6 +13,7 @@ import {
13
13
  } from '@jupyterlab/ui-components';
14
14
  import type { Widget } from '@lumino/widgets';
15
15
 
16
+ import type { IAgentSessions } from '../agentSessions';
16
17
  import { expandStatusFiles, status } from '../git/api';
17
18
  import {
18
19
  CommandIDs as GitCommandIDs,
@@ -25,7 +26,9 @@ import type {
25
26
  } from '../git/tokens';
26
27
 
27
28
  import type { IAgent } from './agents';
28
- import { agentCommandId } from './commands';
29
+ import { launchInTerminal } from './commands';
30
+ import type { IEditor } from './editors';
31
+ import { agentCommandId } from './tokens';
29
32
 
30
33
  /**
31
34
  * Public construction options for the launcher dashboard widget. The
@@ -41,6 +44,19 @@ export interface ILauncherDashboardOptions {
41
44
  * rank; the widget renders them as-is.
42
45
  */
43
46
  agents: IAgent[];
47
+ /**
48
+ * The terminal editor to offer in the "Open" section (Neovim preferred over
49
+ * Vim), or `null` when neither is installed. Resolved by the plugin from the
50
+ * same availability probe that filters the agents.
51
+ */
52
+ editor: IEditor | null;
53
+ /**
54
+ * Shared launch-tag registry. When the editor tile launches, it records the
55
+ * session here so the terminals panel badges it with the editor's logo right
56
+ * away, the same as agent launches do. `null` when the provider plugin is
57
+ * unavailable.
58
+ */
59
+ agentSessions: IAgentSessions | null;
44
60
  /**
45
61
  * Called after an agent command has returned its top-level widget. The
46
62
  * plugin uses this to swap the new terminal into the launcher's tab.
@@ -101,7 +117,15 @@ const CHANGES_POLL_MAX_MS = 300_000;
101
117
  function LauncherDashboardComponent(
102
118
  props: ILauncherDashboardOptions
103
119
  ): React.ReactElement {
104
- const { commands, agents, onAgentLaunch, repoPath, cwd } = props;
120
+ const {
121
+ commands,
122
+ agents,
123
+ editor,
124
+ agentSessions,
125
+ onAgentLaunch,
126
+ repoPath,
127
+ cwd
128
+ } = props;
105
129
  const [prompt, setPrompt] = React.useState('');
106
130
  const [git, setGit] = React.useState<IGitState>({
107
131
  loading: true,
@@ -189,6 +213,23 @@ function LauncherDashboardComponent(
189
213
  }
190
214
  }, [commands, cwd, onAgentLaunch]);
191
215
 
216
+ const launchEditor = React.useCallback(async () => {
217
+ if (!editor) {
218
+ return;
219
+ }
220
+ // An editor is "open a terminal and type its command" — the agent launch
221
+ // minus the prompt — so it shares `launchInTerminal` rather than the native
222
+ // `terminal:create-new` call the tiles above use, and tags the session the
223
+ // same way so the terminals panel badges it with the editor's logo.
224
+ const result = await launchInTerminal(commands, {
225
+ cwd,
226
+ invocation: editor.command,
227
+ label: editor.label,
228
+ onSession: name => agentSessions?.set(name, editor.command)
229
+ });
230
+ onAgentLaunch(result);
231
+ }, [commands, cwd, editor, agentSessions, onAgentLaunch]);
232
+
192
233
  const openDiff = React.useCallback(
193
234
  (change: IFileChange) => {
194
235
  const args: GitCommandArguments.IOpenDiff = { repoPath, change };
@@ -208,10 +249,13 @@ function LauncherDashboardComponent(
208
249
  onPromptChange={setPrompt}
209
250
  onLaunch={launch}
210
251
  />
252
+ <McpSection agents={agents} />
211
253
  <OpenSection
254
+ editor={editor}
212
255
  onLaunchTerminal={launchTerminal}
213
256
  onLaunchNotebook={launchNotebook}
214
257
  onLaunchConsole={launchConsole}
258
+ onLaunchEditor={launchEditor}
215
259
  />
216
260
  <ChangesSection
217
261
  git={git}
@@ -410,19 +454,159 @@ function AgentSection(props: {
410
454
  );
411
455
  }
412
456
 
457
+ /**
458
+ * Launcher agents whose CLI has a clean, non-interactive `mcp add` that takes
459
+ * `<name> -- <command>`. The other agents (Goose, OpenCode, Kiro, Mistral
460
+ * Vibe, Antigravity) register MCP through their own config files or UI, so
461
+ * there is no one-line command to surface for them.
462
+ */
463
+ const MCP_ADD_AGENT_IDS = new Set(['claude', 'codex', 'copilot']);
464
+
465
+ /** The stdio proxy console script that bridges an agent to the MCP server. */
466
+ const MCP_PROXY_COMMAND = 'jupyter-server-mcp-proxy';
467
+
468
+ /**
469
+ * Copy `text` to the clipboard via a hidden, selected `<textarea>` and the
470
+ * legacy `execCommand('copy')`. Deprecated, but still the most reliable path
471
+ * under a user gesture when the async Clipboard API is unavailable or blocked
472
+ * (the desktop app's Electron window can deny clipboard writes by policy).
473
+ * Returns whether the copy succeeded.
474
+ */
475
+ function legacyCopy(text: string): boolean {
476
+ try {
477
+ const textarea = document.createElement('textarea');
478
+ textarea.value = text;
479
+ textarea.setAttribute('readonly', '');
480
+ textarea.style.position = 'fixed';
481
+ textarea.style.top = '-1000px';
482
+ textarea.style.opacity = '0';
483
+ document.body.appendChild(textarea);
484
+ textarea.select();
485
+ const ok = document.execCommand('copy');
486
+ document.body.removeChild(textarea);
487
+ return ok;
488
+ } catch {
489
+ return false;
490
+ }
491
+ }
492
+
493
+ /**
494
+ * A collapsed-by-default hint listing the one-line command that registers
495
+ * xtralab's running Jupyter MCP server with each installed agent that
496
+ * supports `mcp add`. Copy-only: the user runs it in their own terminal,
497
+ * where the server is reachable with no port to configure — the desktop
498
+ * supervisor exports `JUPYTER_SERVER_MCP_URL`, and `pip` installs let the
499
+ * proxy discover the server from its runtime file. Renders nothing when none
500
+ * of those agents are on `$PATH`.
501
+ */
502
+ function McpSection(props: { agents: IAgent[] }): React.ReactElement | null {
503
+ const [copiedId, setCopiedId] = React.useState<string | null>(null);
504
+ const registerable = props.agents.filter(agent =>
505
+ MCP_ADD_AGENT_IDS.has(agent.id)
506
+ );
507
+ if (registerable.length === 0) {
508
+ return null;
509
+ }
510
+
511
+ const flagCopied = (id: string): void => {
512
+ setCopiedId(id);
513
+ window.setTimeout(
514
+ () => setCopiedId(current => (current === id ? null : current)),
515
+ 1500
516
+ );
517
+ };
518
+
519
+ const copy = (command: string, id: string): void => {
520
+ // Prefer the async Clipboard API; fall back to `legacyCopy` when it's
521
+ // missing or rejects (Electron can block it by policy). Flag "Copied" only
522
+ // once a copy actually succeeds.
523
+ const clipboard = navigator.clipboard;
524
+ if (clipboard) {
525
+ void clipboard.writeText(command).then(
526
+ () => flagCopied(id),
527
+ () => {
528
+ if (legacyCopy(command)) {
529
+ flagCopied(id);
530
+ }
531
+ }
532
+ );
533
+ } else if (legacyCopy(command)) {
534
+ flagCopied(id);
535
+ }
536
+ };
537
+
538
+ return (
539
+ <details className="jp-xtralab-Launcher-section jp-xtralab-Launcher-mcp-section">
540
+ <summary className="jp-xtralab-Launcher-section-summary">
541
+ <span className="jp-xtralab-Launcher-section-title">
542
+ Connect agents to JupyterLab (MCP)
543
+ </span>
544
+ </summary>
545
+ <div className="jp-xtralab-Launcher-section-body">
546
+ <p className="jp-xtralab-Launcher-agent-hint">
547
+ Run once per agent to let it drive this JupyterLab over MCP. No port
548
+ needed — the proxy finds this server on its own.
549
+ </p>
550
+ <ul className="jp-xtralab-Launcher-mcp-list">
551
+ {registerable.map(agent => {
552
+ const command = `${agent.command} mcp add jupyter -- ${MCP_PROXY_COMMAND}`;
553
+ return (
554
+ <li key={agent.id} className="jp-xtralab-Launcher-mcp-item">
555
+ <code className="jp-xtralab-Launcher-mcp-command">
556
+ {command}
557
+ </code>
558
+ <button
559
+ type="button"
560
+ className={
561
+ copiedId === agent.id
562
+ ? 'jp-xtralab-Launcher-mcp-copy jp-mod-copied'
563
+ : 'jp-xtralab-Launcher-mcp-copy'
564
+ }
565
+ aria-label={`Copy the ${agent.label} command`}
566
+ onClick={() => copy(command, agent.id)}
567
+ >
568
+ {copiedId === agent.id ? 'Copied!' : 'Copy'}
569
+ </button>
570
+ </li>
571
+ );
572
+ })}
573
+ </ul>
574
+ <p className="jp-xtralab-Launcher-mcp-note">
575
+ Other agents (Goose, OpenCode, Kiro, Mistral Vibe, Antigravity)
576
+ register MCP through their own config — point them at the same{' '}
577
+ <code>{MCP_PROXY_COMMAND}</code> command.
578
+ </p>
579
+ </div>
580
+ </details>
581
+ );
582
+ }
583
+
413
584
  /**
414
585
  * The non-agent launch tiles — a plain terminal, a new notebook, and a
415
586
  * new console. They sit in a separate section below the agent grid so
416
587
  * they don't compete with the AI agents visually, and they're always
417
588
  * available regardless of the agent prompt textarea (which doesn't
418
589
  * apply to them).
590
+ *
591
+ * A terminal editor (Neovim, else Vim) joins the row when one is installed —
592
+ * it opens in a terminal like the agents do, so it lives here rather than in
593
+ * the agent grid. When neither is on `$PATH`, `editor` is `null` and no tile
594
+ * renders.
419
595
  */
420
596
  function OpenSection(props: {
597
+ editor: IEditor | null;
421
598
  onLaunchTerminal: () => void;
422
599
  onLaunchNotebook: () => void;
423
600
  onLaunchConsole: () => void;
601
+ onLaunchEditor: () => void;
424
602
  }): React.ReactElement {
425
- const { onLaunchTerminal, onLaunchNotebook, onLaunchConsole } = props;
603
+ const {
604
+ editor,
605
+ onLaunchTerminal,
606
+ onLaunchNotebook,
607
+ onLaunchConsole,
608
+ onLaunchEditor
609
+ } = props;
426
610
  return (
427
611
  <section className="jp-xtralab-Launcher-section">
428
612
  <h2 className="jp-xtralab-Launcher-section-title">Open</h2>
@@ -469,6 +653,24 @@ function OpenSection(props: {
469
653
  />
470
654
  <span className="jp-xtralab-Launcher-agent-label">Console</span>
471
655
  </button>
656
+ {editor && (
657
+ <button
658
+ type="button"
659
+ className="jp-xtralab-Launcher-agent"
660
+ title={editor.caption}
661
+ aria-label={editor.caption}
662
+ onClick={onLaunchEditor}
663
+ >
664
+ <LabIcon.resolveReact
665
+ icon={editor.icon}
666
+ tag="span"
667
+ className="jp-xtralab-Launcher-agent-icon"
668
+ />
669
+ <span className="jp-xtralab-Launcher-agent-label">
670
+ {editor.label}
671
+ </span>
672
+ </button>
673
+ )}
472
674
  </div>
473
675
  </section>
474
676
  );
@@ -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
+ };