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.
- package/README.md +110 -73
- package/lib/agentSessions.d.ts +50 -0
- package/lib/agentSessions.js +45 -0
- package/lib/fileBrowser/index.js +5 -1
- package/lib/git/commands.d.ts +12 -13
- package/lib/git/commands.js +7 -28
- package/lib/git/diffProvider.d.ts +8 -0
- package/lib/git/diffProvider.js +314 -0
- package/lib/git/diffSurface.d.ts +115 -0
- package/lib/git/diffSurface.js +342 -0
- package/lib/git/diffWidget.d.ts +8 -13
- package/lib/git/diffWidget.js +65 -311
- package/lib/git/imageDiff.d.ts +23 -0
- package/lib/git/imageDiff.js +256 -0
- package/lib/git/index.d.ts +10 -8
- package/lib/git/index.js +37 -52
- package/lib/index.d.ts +4 -2
- package/lib/index.js +14 -8
- package/lib/launcher/agents.d.ts +1 -1
- package/lib/launcher/agents.js +22 -13
- package/lib/launcher/commands.d.ts +32 -6
- package/lib/launcher/commands.js +81 -57
- package/lib/launcher/dashboard.d.ts +15 -0
- package/lib/launcher/dashboard.js +119 -5
- package/lib/launcher/editorRegistry.d.ts +43 -0
- package/lib/launcher/editorRegistry.js +96 -0
- package/lib/launcher/editors.d.ts +78 -0
- package/lib/launcher/editors.js +110 -0
- package/lib/launcher/icons.d.ts +25 -6
- package/lib/launcher/icons.js +116 -19
- package/lib/launcher/index.d.ts +2 -22
- package/lib/launcher/index.js +48 -25
- package/lib/launcher/registry.d.ts +21 -0
- package/lib/launcher/registry.js +28 -0
- package/lib/launcher/tokens.d.ts +41 -0
- package/lib/launcher/tokens.js +17 -0
- package/lib/terminals/detection.d.ts +11 -0
- package/lib/terminals/detection.js +50 -0
- package/lib/terminals/index.d.ts +27 -0
- package/lib/terminals/index.js +194 -0
- package/lib/terminals/model.d.ts +191 -0
- package/lib/terminals/model.js +418 -0
- package/lib/terminals/widget.d.ts +72 -0
- package/lib/terminals/widget.js +93 -0
- package/lib/topBar/icons.d.ts +13 -0
- package/lib/topBar/icons.js +27 -0
- package/lib/topBar/index.d.ts +16 -0
- package/lib/topBar/index.js +86 -0
- package/package.json +52 -35
- package/schema/launcher.json +56 -1
- package/src/agentSessions.ts +94 -0
- package/src/fileBrowser/index.ts +5 -1
- package/src/git/commands.ts +12 -38
- package/src/git/diffProvider.tsx +462 -0
- package/src/git/diffSurface.tsx +588 -0
- package/src/git/diffWidget.tsx +105 -465
- package/src/git/imageDiff.tsx +469 -0
- package/src/git/index.ts +38 -68
- package/src/index.ts +14 -8
- package/src/launcher/agents.ts +23 -14
- package/src/launcher/commands.ts +98 -60
- package/src/launcher/dashboard.tsx +205 -3
- package/src/launcher/editorRegistry.ts +149 -0
- package/src/launcher/editors.ts +183 -0
- package/src/launcher/icons.ts +120 -20
- package/src/launcher/index.ts +62 -27
- package/src/launcher/registry.ts +33 -0
- package/src/launcher/tokens.ts +51 -0
- package/src/terminals/detection.ts +65 -0
- package/src/terminals/index.ts +222 -0
- package/src/terminals/model.ts +497 -0
- package/src/terminals/widget.tsx +240 -0
- package/src/topBar/icons.ts +30 -0
- package/src/topBar/index.ts +121 -0
- package/style/git.css +267 -266
- package/style/index.css +2 -1
- package/style/index.js +2 -1
- package/style/launcher.css +124 -2
- package/style/terminals.css +235 -0
- package/style/topBar.css +38 -0
- package/lib/git/icons.d.ts +0 -14
- package/lib/git/icons.js +0 -19
- package/lib/git/panel.d.ts +0 -29
- package/lib/git/panel.js +0 -42
- package/lib/git/panelComponent.d.ts +0 -28
- package/lib/git/panelComponent.js +0 -320
- package/lib/statusBar/index.d.ts +0 -14
- package/lib/statusBar/index.js +0 -89
- package/lib/statusBar/widget.d.ts +0 -121
- package/lib/statusBar/widget.js +0 -237
- package/src/git/icons.ts +0 -20
- package/src/git/panel.tsx +0 -56
- package/src/git/panelComponent.tsx +0 -575
- package/src/statusBar/index.ts +0 -115
- package/src/statusBar/widget.tsx +0 -318
- package/style/statusBar.css +0 -95
package/src/launcher/agents.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
79
|
-
* first-class personas
|
|
80
|
-
* is also where
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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: '
|
|
108
|
-
label: '
|
|
109
|
-
caption: '
|
|
110
|
-
command: '
|
|
111
|
-
icon: BUILTIN_AGENT_ICONS.
|
|
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
|
-
|
|
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',
|
package/src/launcher/commands.ts
CHANGED
|
@@ -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
|
-
|
|
47
|
-
cwd
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
main.content.title.label = agent.label;
|
|
78
|
-
};
|
|
98
|
+
const session = main.content.session;
|
|
99
|
+
onSession?.(session.name);
|
|
79
100
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
98
|
-
|
|
108
|
+
const sendCommand = (): void => {
|
|
109
|
+
session.send({
|
|
110
|
+
type: 'stdin',
|
|
111
|
+
content: [invocation + '\r']
|
|
99
112
|
});
|
|
100
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
+
};
|