xtralab 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +86 -0
  2. package/lib/fileBrowser/fileBrowser.js +71 -1
  3. package/lib/fileBrowser/gitignore.d.ts +29 -0
  4. package/lib/fileBrowser/gitignore.js +74 -0
  5. package/lib/fileBrowser/index.js +1 -1
  6. package/lib/git/api.d.ts +11 -0
  7. package/lib/git/api.js +19 -0
  8. package/lib/git/commands.d.ts +3 -1
  9. package/lib/git/commands.js +10 -4
  10. package/lib/git/diffWidget.d.ts +11 -0
  11. package/lib/git/diffWidget.js +90 -9
  12. package/lib/git/index.js +23 -7
  13. package/lib/git/panelComponent.d.ts +3 -1
  14. package/lib/git/panelComponent.js +6 -3
  15. package/lib/index.js +9 -1
  16. package/lib/launcher/agents.d.ts +78 -0
  17. package/lib/launcher/agents.js +168 -0
  18. package/lib/launcher/availability.d.ts +12 -0
  19. package/lib/launcher/availability.js +53 -0
  20. package/lib/launcher/commands.d.ts +22 -0
  21. package/lib/launcher/commands.js +89 -0
  22. package/lib/launcher/dashboard.d.ts +46 -0
  23. package/lib/launcher/dashboard.js +164 -0
  24. package/lib/launcher/icons.d.ts +33 -0
  25. package/lib/launcher/icons.js +113 -0
  26. package/lib/launcher/index.d.ts +23 -0
  27. package/lib/launcher/index.js +191 -0
  28. package/lib/launcher/invocation.d.ts +29 -0
  29. package/lib/launcher/invocation.js +42 -0
  30. package/lib/menus/index.d.ts +14 -0
  31. package/lib/menus/index.js +102 -0
  32. package/lib/sidebar/index.d.ts +18 -0
  33. package/lib/sidebar/index.js +155 -0
  34. package/lib/statusBar/index.d.ts +14 -0
  35. package/lib/statusBar/index.js +89 -0
  36. package/lib/statusBar/widget.d.ts +121 -0
  37. package/lib/statusBar/widget.js +237 -0
  38. package/package.json +10 -2
  39. package/schema/launcher.json +70 -0
  40. package/schema/sidebar.json +57 -0
  41. package/src/fileBrowser/fileBrowser.tsx +75 -1
  42. package/src/fileBrowser/gitignore.ts +86 -0
  43. package/src/fileBrowser/index.ts +1 -1
  44. package/src/git/api.ts +20 -0
  45. package/src/git/commands.ts +19 -5
  46. package/src/git/diffWidget.tsx +109 -13
  47. package/src/git/index.ts +32 -6
  48. package/src/git/panelComponent.tsx +17 -5
  49. package/src/index.ts +9 -1
  50. package/src/launcher/agents.ts +248 -0
  51. package/src/launcher/availability.ts +73 -0
  52. package/src/launcher/commands.ts +103 -0
  53. package/src/launcher/dashboard.tsx +356 -0
  54. package/src/launcher/icons.ts +123 -0
  55. package/src/launcher/index.ts +228 -0
  56. package/src/launcher/invocation.ts +45 -0
  57. package/src/menus/index.ts +124 -0
  58. package/src/sidebar/index.ts +195 -0
  59. package/src/statusBar/index.ts +115 -0
  60. package/src/statusBar/widget.tsx +318 -0
  61. package/style/git.css +4 -8
  62. package/style/index.css +2 -0
  63. package/style/index.js +2 -0
  64. package/style/launcher.css +323 -0
  65. package/style/statusBar.css +95 -0
@@ -1,7 +1,7 @@
1
1
  import * as React from 'react';
2
2
  import { MainAreaWidget } from '@jupyterlab/apputils';
3
3
  import { PathExt } from '@jupyterlab/coreutils';
4
- import { ReactWidget, undoIcon } from '@jupyterlab/ui-components';
4
+ import { ReactWidget, ToolbarButton, launchIcon, undoIcon } from '@jupyterlab/ui-components';
5
5
  import { Signal } from '@lumino/signaling';
6
6
  import { FileDiff } from '@pierre/diffs/react';
7
7
  import { diffAcceptRejectHunk, parseDiffFromFile } from '@pierre/diffs';
@@ -13,6 +13,10 @@ import { buildNotebookDiff, NotebookDiffView } from './notebookDiff';
13
13
  * style the embedded `@pierre/diffs` viewer hang off this class.
14
14
  */
15
15
  export const DIFF_WIDGET_CSS_CLASS = 'jp-xtralab-DiffWidget';
16
+ export const PREVIEW_DIFF_WIDGET_ID = 'xtralab:diff:preview';
17
+ export function pinnedDiffWidgetId(change) {
18
+ return `xtralab:diff:pinned:${change.group}:${change.path}`;
19
+ }
16
20
  /**
17
21
  * `localStorage` key for the user's last-chosen split ratio. Persisted so
18
22
  * the preference survives reloads — the library has no built-in way to
@@ -100,6 +104,9 @@ function writeStoredNotebookViewMode(mode) {
100
104
  export function diffWidgetId(change) {
101
105
  return `xtralab:diff:${change.group}:${change.path}`;
102
106
  }
107
+ function toServerPath(repoPath, filePath) {
108
+ return repoPath.length > 0 ? PathExt.join(repoPath, filePath) : filePath;
109
+ }
103
110
  /**
104
111
  * Resolve the pair of git references whose `content` should appear on the
105
112
  * left and right side of the diff for a given file change. Mirrors VS
@@ -128,6 +135,7 @@ export function resolveReferences(change) {
128
135
  * read-only main-area widgets in the JupyterLab ecosystem.
129
136
  */
130
137
  export function createGitDiffWidget(options) {
138
+ var _a;
131
139
  // `widget` is captured by the `onEmpty` closure we pass into the content,
132
140
  // but doesn't exist yet at construction time — declare it up front so the
133
141
  // closure can read it once the widget is wired.
@@ -143,38 +151,81 @@ export function createGitDiffWidget(options) {
143
151
  }
144
152
  });
145
153
  widget = new MainAreaWidget({ content });
146
- widget.id = diffWidgetId(options.change);
154
+ widget.id =
155
+ options.pinned === true
156
+ ? pinnedDiffWidgetId(options.change)
157
+ : PREVIEW_DIFF_WIDGET_ID;
147
158
  widget.title.label = formatTitle(options.change);
148
159
  widget.title.caption = options.change.path;
149
160
  widget.title.closable = true;
150
161
  widget.title.icon = getTreeIcon(options.change.path);
162
+ widget.title.className = options.pinned === true ? '' : 'jp-mod-preview';
163
+ const onPinned = (_a = options.onPinned) !== null && _a !== void 0 ? _a : (() => { });
151
164
  widget.addClass(DIFF_WIDGET_CSS_CLASS);
152
165
  // Wire the notebook view-mode selector into the `MainAreaWidget` toolbar
153
166
  // — the proper home for document chrome controls. The toolbar starts
154
167
  // hidden and becomes visible once the React content reports a parsed
155
168
  // notebook diff via `setHasNotebookView`. Plain text / code diffs never
156
- // flip that flag, so their toolbar bar stays collapsed and the
157
- // resulting layout matches what other read-only main-area documents
158
- // look like in JupyterLab.
169
+ // flip that flag, and pin state can hide the toolbar again, so the host
170
+ // chrome tracks the active mode.
171
+ const pinButton = new ToolbarButton({
172
+ icon: launchIcon,
173
+ tooltip: 'Pin tab',
174
+ onClick: () => content.pin()
175
+ });
176
+ if (content.pinned) {
177
+ pinButton.hide();
178
+ }
179
+ widget.toolbar.addItem('pin', pinButton);
159
180
  widget.toolbar.addItem('notebook-view-mode', new NotebookViewModeToolbarItem(content));
160
- widget.toolbar.hide();
161
- const onHasNotebookViewChanged = (_sender, value) => {
181
+ const syncToolbarVisibility = () => {
162
182
  if (widget === null || widget.isDisposed) {
163
183
  return;
164
184
  }
165
- if (value) {
185
+ if (!content.pinned || content.hasNotebookView) {
166
186
  widget.toolbar.show();
167
187
  }
168
188
  else {
169
189
  widget.toolbar.hide();
170
190
  }
171
191
  };
192
+ const onHasNotebookViewChanged = () => {
193
+ if (widget === null || widget.isDisposed) {
194
+ return;
195
+ }
196
+ syncToolbarVisibility();
197
+ };
198
+ const onPinnedChanged = (sender, value) => {
199
+ if (widget === null || widget.isDisposed) {
200
+ return;
201
+ }
202
+ void sender;
203
+ if (value) {
204
+ pinButton.hide();
205
+ // Keep toolbar state and title styling consistent for promoted tabs.
206
+ onPinned(widget);
207
+ if (widget.isDisposed) {
208
+ return;
209
+ }
210
+ widget.title.className = '';
211
+ }
212
+ syncToolbarVisibility();
213
+ };
172
214
  content.hasNotebookViewChanged.connect(onHasNotebookViewChanged);
215
+ content.pinnedChanged.connect(onPinnedChanged);
216
+ syncToolbarVisibility();
173
217
  widget.disposed.connect(() => {
174
218
  content.hasNotebookViewChanged.disconnect(onHasNotebookViewChanged);
219
+ content.pinnedChanged.disconnect(onPinnedChanged);
175
220
  });
176
221
  return widget;
177
222
  }
223
+ export function updateGitDiffWidget(widget, change) {
224
+ widget.content.setChange(change);
225
+ widget.title.label = formatTitle(change);
226
+ widget.title.caption = change.path;
227
+ widget.title.icon = getTreeIcon(change.path);
228
+ }
178
229
  /**
179
230
  * The actual React-rendered region of the diff viewer. Held in its own
180
231
  * `ReactWidget` so the surrounding `MainAreaWidget` can manage chrome
@@ -193,7 +244,9 @@ export class DiffContentWidget extends ReactWidget {
193
244
  this._notebookViewModeChanged = new Signal(this);
194
245
  this._hasNotebookView = false;
195
246
  this._hasNotebookViewChanged = new Signal(this);
247
+ this._pinnedChanged = new Signal(this);
196
248
  this._options = options;
249
+ this._pinned = options.pinned === true;
197
250
  this._notebookViewMode = readStoredNotebookViewMode();
198
251
  }
199
252
  /** The change this widget renders, used by callers to identify the widget. */
@@ -236,6 +289,23 @@ export class DiffContentWidget extends ReactWidget {
236
289
  get hasNotebookViewChanged() {
237
290
  return this._hasNotebookViewChanged;
238
291
  }
292
+ get pinned() {
293
+ return this._pinned;
294
+ }
295
+ pin() {
296
+ if (this._pinned) {
297
+ return;
298
+ }
299
+ this._pinned = true;
300
+ this._pinnedChanged.emit(true);
301
+ }
302
+ get pinnedChanged() {
303
+ return this._pinnedChanged;
304
+ }
305
+ setChange(change) {
306
+ this._options = { ...this._options, change };
307
+ this.update();
308
+ }
239
309
  render() {
240
310
  return React.createElement(DiffViewer, { ...this._options, widget: this });
241
311
  }
@@ -355,8 +425,16 @@ function DiffViewer(props) {
355
425
  }, [themeManager]);
356
426
  React.useEffect(() => {
357
427
  let cancelled = false;
428
+ const serverPath = toServerPath(repoPath, change.path);
429
+ setState({
430
+ loading: true,
431
+ metadata: null,
432
+ notebookDiff: null,
433
+ serverPath,
434
+ isBinary: false,
435
+ error: null
436
+ });
358
437
  const { oldRef, newRef } = resolveReferences(change);
359
- const serverPath = repoPath.length > 0 ? PathExt.join(repoPath, change.path) : change.path;
360
438
  void (async () => {
361
439
  var _a, _b, _c, _d;
362
440
  try {
@@ -612,6 +690,9 @@ function NotebookViewModeControl(props) {
612
690
  content.notebookViewModeChanged.disconnect(handler);
613
691
  };
614
692
  }, [content]);
693
+ if (!content.hasNotebookView) {
694
+ return React.createElement(React.Fragment, null);
695
+ }
615
696
  return (React.createElement("div", { className: "jp-xtralab-DiffWidget-segmented", role: "tablist", "aria-label": "Notebook diff view mode" },
616
697
  React.createElement("button", { type: "button", role: "tab", "aria-selected": mode === 'notebook', "data-active": mode === 'notebook', className: "jp-xtralab-DiffWidget-segmentedButton", onClick: () => content.setNotebookViewMode('notebook') }, "Notebook"),
617
698
  React.createElement("button", { type: "button", role: "tab", "aria-selected": mode === 'json', "data-active": mode === 'json', className: "jp-xtralab-DiffWidget-segmentedButton", onClick: () => content.setNotebookViewMode('json') }, "JSON")));
package/lib/git/index.js CHANGED
@@ -2,7 +2,7 @@ import { ILayoutRestorer } from '@jupyterlab/application';
2
2
  import { Dialog, ICommandPalette, IThemeManager, WidgetTracker, showDialog, showErrorMessage } from '@jupyterlab/apputils';
3
3
  import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
4
4
  import { CommandIDs, registerGitCommands } from './commands';
5
- import { diffWidgetId } from './diffWidget';
5
+ import { PREVIEW_DIFF_WIDGET_ID, pinnedDiffWidgetId } from './diffWidget';
6
6
  import { GitPanel } from './panel';
7
7
  const GIT_PLUGIN_ID = 'xtralab:git';
8
8
  const GIT_DIFF_TRACKER_NAMESPACE = 'xtralab-git-diff';
@@ -34,14 +34,17 @@ const plugin = {
34
34
  const tracker = new WidgetTracker({
35
35
  namespace: GIT_DIFF_TRACKER_NAMESPACE
36
36
  });
37
- const findDiff = (change) => {
38
- var _a;
39
- const id = diffWidgetId(change);
40
- return (_a = tracker.find(widget => widget.id === id)) !== null && _a !== void 0 ? _a : undefined;
37
+ const findDiff = (change, pin = false) => {
38
+ const id = pin ? pinnedDiffWidgetId(change) : PREVIEW_DIFF_WIDGET_ID;
39
+ const existing = tracker.find(widget => !widget.isDisposed && widget.id === id);
40
+ return existing !== null && existing !== void 0 ? existing : undefined;
41
41
  };
42
42
  const panel = new GitPanel({
43
- openDiff: change => {
43
+ openDiff: (change, options) => {
44
44
  const args = { repoPath, change };
45
+ if ((options === null || options === void 0 ? void 0 : options.pin) === true) {
46
+ args.pin = true;
47
+ }
45
48
  void app.commands.execute(CommandIDs.openDiff, args);
46
49
  },
47
50
  confirm: async (title, body, accept) => {
@@ -54,7 +57,7 @@ const plugin = {
54
57
  },
55
58
  showError: showErrorMessage
56
59
  });
57
- app.shell.add(panel, 'left', { rank: 800 });
60
+ app.shell.add(panel, 'left', { rank: 60 });
58
61
  if (restorer !== null) {
59
62
  restorer.add(panel, panel.id);
60
63
  }
@@ -67,6 +70,19 @@ const plugin = {
67
70
  rendermime,
68
71
  onChanged: () => panel.refresh(),
69
72
  trackDiff: widget => tracker.add(widget),
73
+ onPinned: current => {
74
+ const existing = findDiff(current.content.change, true);
75
+ if (existing !== undefined &&
76
+ existing !== current &&
77
+ !existing.isDisposed) {
78
+ app.shell.activateById(existing.id);
79
+ current.close();
80
+ return;
81
+ }
82
+ current.id = pinnedDiffWidgetId(current.content.change);
83
+ current.title.className = '';
84
+ app.shell.activateById(current.id);
85
+ },
70
86
  findDiff
71
87
  });
72
88
  }
@@ -8,7 +8,9 @@ import { IFileChange } from './tokens';
8
8
  */
9
9
  export interface IGitPanelHandlers {
10
10
  /** Open a diff viewer for the given file in the main area. */
11
- openDiff(change: IFileChange): void;
11
+ openDiff(change: IFileChange, options?: {
12
+ pin?: boolean;
13
+ }): void;
12
14
  /** Show a confirmation dialog. Returns true when the user accepted. */
13
15
  confirm(title: string, body: string, accept: string): Promise<boolean>;
14
16
  /** Show an error dialog. */
@@ -169,7 +169,9 @@ export function GitPanelComponent(props) {
169
169
  onClick: onUnstageAll
170
170
  }
171
171
  ]
172
- : undefined }, staged.map(change => (React.createElement(ChangeRow, { key: `${change.group}:${change.path}`, change: change, pendingAction: pendingActions[`${change.group}:${change.path}`], onClick: () => handlers.openDiff(change), actions: [
172
+ : undefined }, staged.map(change => (React.createElement(ChangeRow, { key: `${change.group}:${change.path}`, change: change, pendingAction: pendingActions[`${change.group}:${change.path}`], onClick: event => handlers.openDiff(change, {
173
+ pin: event.ctrlKey || event.metaKey
174
+ }), actions: [
173
175
  {
174
176
  label: 'Unstage changes',
175
177
  symbol: '−',
@@ -184,7 +186,9 @@ export function GitPanelComponent(props) {
184
186
  onClick: onStageAll
185
187
  }
186
188
  ]
187
- : undefined }, unstaged.map(change => (React.createElement(ChangeRow, { key: `${change.group}:${change.path}`, change: change, pendingAction: pendingActions[`${change.group}:${change.path}`], onClick: () => handlers.openDiff(change), actions: [
189
+ : undefined }, unstaged.map(change => (React.createElement(ChangeRow, { key: `${change.group}:${change.path}`, change: change, pendingAction: pendingActions[`${change.group}:${change.path}`], onClick: event => handlers.openDiff(change, {
190
+ pin: event.ctrlKey || event.metaKey
191
+ }), actions: [
188
192
  {
189
193
  label: 'Discard changes',
190
194
  symbol: '⟲',
@@ -209,7 +213,6 @@ function PanelHeader(props) {
209
213
  : null;
210
214
  return (React.createElement("div", { className: "jp-xtralab-GitPanel-header" },
211
215
  React.createElement("div", { className: "jp-xtralab-GitPanel-headerInfo" },
212
- React.createElement("div", { className: "jp-xtralab-GitPanel-title" }, "Source Control"),
213
216
  repoName !== null ? (React.createElement("div", { className: "jp-xtralab-GitPanel-repo", title: topLevel !== null && topLevel !== void 0 ? topLevel : '' }, repoName)) : null,
214
217
  (result === null || result === void 0 ? void 0 : result.branch) !== undefined && (result === null || result === void 0 ? void 0 : result.branch) !== null ? (React.createElement("div", { className: "jp-xtralab-GitPanel-branch" },
215
218
  result.branch,
package/lib/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import fileBrowserPlugin from './fileBrowser';
2
2
  import gitPlugin from './git';
3
+ import launcherPlugin from './launcher';
4
+ import menusPlugin from './menus';
5
+ import sidebarPlugin from './sidebar';
6
+ import statusBarPlugin from './statusBar';
3
7
  /**
4
8
  * Every plugin contributed by `xtralab`. The entry point of the
5
9
  * labextension is an array because the package bundles several independent
@@ -9,6 +13,10 @@ import gitPlugin from './git';
9
13
  */
10
14
  const plugins = [
11
15
  fileBrowserPlugin,
12
- gitPlugin
16
+ gitPlugin,
17
+ launcherPlugin,
18
+ menusPlugin,
19
+ sidebarPlugin,
20
+ statusBarPlugin
13
21
  ];
14
22
  export default plugins;
@@ -0,0 +1,78 @@
1
+ import { LabIcon } from '@jupyterlab/ui-components';
2
+ /**
3
+ * The shape of an agent card on the launcher. The `command` is the literal
4
+ * text typed into the new terminal session — interactive shells expand
5
+ * aliases, so users can point at `claude`, `cl`, or whatever runs their
6
+ * preferred wrapper.
7
+ */
8
+ export interface IAgent {
9
+ id: string;
10
+ label: string;
11
+ caption: string;
12
+ command: string;
13
+ icon: LabIcon;
14
+ rank: number;
15
+ /**
16
+ * When false, the launcher skips the `which`-based availability check for
17
+ * this entry — useful for aliases or shell functions that aren't on PATH
18
+ * but still resolve when typed in a real terminal. Defaults to true.
19
+ */
20
+ requireAvailable: boolean;
21
+ /**
22
+ * Argv tokens spliced between `command` and a shell-quoted prompt when
23
+ * the user types one into the launcher's prompt box. The semantics:
24
+ *
25
+ * - `[]` → prompt is appended as a positional argument:
26
+ * `<command> 'PROMPT'`. (Used by claude, codex, gemini, vibe.)
27
+ * - `['-i']` / `['--prompt']` → the prompt is preceded by a flag:
28
+ * `<command> -i 'PROMPT'`. (Used by copilot, opencode.)
29
+ * - `undefined` → the agent does not accept an initial prompt. The
30
+ * launcher dims the agent's button while the prompt textarea is
31
+ * non-empty, so the user gets a clear signal rather than a silently
32
+ * dropped prompt.
33
+ *
34
+ * The prompt itself is always single-quoted with embedded single quotes
35
+ * escaped, so multi-line prompts and shell metacharacters are safe.
36
+ */
37
+ promptArgs?: string[];
38
+ }
39
+ /**
40
+ * The settings-side shape: every field except `id` is optional, so a user
41
+ * can override a single field on a default agent (e.g. swap the command for
42
+ * an alias) without restating the whole entry. New ids define brand-new
43
+ * agent cards.
44
+ */
45
+ export interface IAgentSettings {
46
+ id: string;
47
+ label?: string;
48
+ caption?: string;
49
+ command?: string;
50
+ /**
51
+ * Inline SVG for a custom agent's icon. Required for new ids whose icon
52
+ * isn't shipped with xtralab; ignored for default ids unless explicitly
53
+ * set (in which case it overrides the built-in).
54
+ */
55
+ iconSvg?: string;
56
+ rank?: number;
57
+ /**
58
+ * When false, the agent is hidden from the launcher and the command
59
+ * palette. Defaults to true.
60
+ */
61
+ enabled?: boolean;
62
+ /** See `IAgent.requireAvailable`. */
63
+ requireAvailable?: boolean;
64
+ /**
65
+ * See `IAgent.promptArgs`. Pass an empty array to mark the agent as
66
+ * accepting a positional prompt; pass an array like `["-p"]` to use a
67
+ * flag. Pass `null` to explicitly turn off prompt support for an agent
68
+ * that has it on by default.
69
+ */
70
+ promptArgs?: string[] | null;
71
+ }
72
+ /**
73
+ * Merge xtralab's defaults with the user's settings. Default entries keep
74
+ * their built-in fields unless explicitly overridden; user-only entries are
75
+ * appended. `enabled: false` filters an entry out of the result entirely
76
+ * (so callers don't need to check the flag again).
77
+ */
78
+ export declare function mergeAgents(overrides: IAgentSettings[]): IAgent[];
@@ -0,0 +1,168 @@
1
+ import { LabIcon, terminalIcon } from '@jupyterlab/ui-components';
2
+ import { BUILTIN_AGENT_ICONS } from './icons';
3
+ /**
4
+ * Default agent cards shipped with xtralab. The list mirrors the eight
5
+ * first-class personas in `jupyter-ai-contrib/jupyter-ai-acp-client` (which
6
+ * is also where the icons come from), but uses the bare CLI names a user
7
+ * would type in a terminal rather than the ACP wrapper binaries that
8
+ * project spawns. Anything not on the user's `$PATH` is filtered out at
9
+ * activation time, so the wider list is harmless.
10
+ */
11
+ const DEFAULTS = [
12
+ {
13
+ id: 'claude',
14
+ label: 'Claude',
15
+ caption: 'Start Claude Code in a new terminal.',
16
+ command: 'claude',
17
+ icon: BUILTIN_AGENT_ICONS.claude,
18
+ rank: 0,
19
+ requireAvailable: true,
20
+ promptArgs: []
21
+ },
22
+ {
23
+ id: 'codex',
24
+ label: 'Codex',
25
+ caption: 'Start the Codex CLI in a new terminal.',
26
+ command: 'codex',
27
+ icon: BUILTIN_AGENT_ICONS.codex,
28
+ rank: 1,
29
+ requireAvailable: true,
30
+ promptArgs: []
31
+ },
32
+ {
33
+ id: 'gemini',
34
+ label: 'Gemini',
35
+ caption: 'Start the Gemini CLI in a new terminal.',
36
+ command: 'gemini',
37
+ icon: BUILTIN_AGENT_ICONS.gemini,
38
+ rank: 2,
39
+ requireAvailable: true,
40
+ promptArgs: []
41
+ },
42
+ {
43
+ id: 'copilot',
44
+ label: 'Copilot',
45
+ caption: 'Start the GitHub Copilot CLI in a new terminal.',
46
+ command: 'copilot',
47
+ icon: BUILTIN_AGENT_ICONS.copilot,
48
+ rank: 3,
49
+ requireAvailable: true,
50
+ // `-i "PROMPT"` is the documented way to start interactive mode and
51
+ // auto-execute the prompt; `-p` exits after responding (non-interactive).
52
+ promptArgs: ['-i']
53
+ },
54
+ {
55
+ id: 'goose',
56
+ label: 'Goose',
57
+ caption: 'Start Goose in a new terminal.',
58
+ command: 'goose',
59
+ icon: BUILTIN_AGENT_ICONS.goose,
60
+ rank: 4,
61
+ requireAvailable: true
62
+ // `goose` does not accept an inline interactive prompt; users would
63
+ // need to type the prompt after `goose session` starts.
64
+ },
65
+ {
66
+ id: 'opencode',
67
+ label: 'OpenCode',
68
+ caption: 'Start OpenCode in a new terminal.',
69
+ command: 'opencode',
70
+ icon: BUILTIN_AGENT_ICONS.opencode,
71
+ rank: 5,
72
+ requireAvailable: true,
73
+ promptArgs: ['--prompt']
74
+ },
75
+ {
76
+ id: 'kiro',
77
+ label: 'Kiro',
78
+ caption: 'Start the Kiro CLI in a new terminal.',
79
+ command: 'kiro',
80
+ icon: BUILTIN_AGENT_ICONS.kiro,
81
+ rank: 6,
82
+ requireAvailable: true
83
+ // The Kiro chat session only takes initial prompts via the in-session
84
+ // `/chat new <prompt>` slash command, not a CLI argument.
85
+ },
86
+ {
87
+ id: 'mistral-vibe',
88
+ label: 'Mistral Vibe',
89
+ caption: 'Start Mistral Vibe in a new terminal.',
90
+ command: 'vibe',
91
+ icon: BUILTIN_AGENT_ICONS['mistral-vibe'],
92
+ rank: 7,
93
+ requireAvailable: true,
94
+ promptArgs: []
95
+ }
96
+ ];
97
+ function resolveIcon(id, iconSvg) {
98
+ var _a;
99
+ if (iconSvg) {
100
+ return new LabIcon({ name: `xtralab:agent-custom-${id}`, svgstr: iconSvg });
101
+ }
102
+ return (_a = BUILTIN_AGENT_ICONS[id]) !== null && _a !== void 0 ? _a : terminalIcon;
103
+ }
104
+ /**
105
+ * Merge xtralab's defaults with the user's settings. Default entries keep
106
+ * their built-in fields unless explicitly overridden; user-only entries are
107
+ * appended. `enabled: false` filters an entry out of the result entirely
108
+ * (so callers don't need to check the flag again).
109
+ */
110
+ export function mergeAgents(overrides) {
111
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
112
+ const overrideById = new Map(overrides.map(entry => [entry.id, entry]));
113
+ const merged = [];
114
+ for (const base of DEFAULTS) {
115
+ const override = overrideById.get(base.id);
116
+ if (!override) {
117
+ merged.push({ ...base });
118
+ continue;
119
+ }
120
+ overrideById.delete(base.id);
121
+ if (override.enabled === false) {
122
+ continue;
123
+ }
124
+ merged.push({
125
+ id: base.id,
126
+ label: (_a = override.label) !== null && _a !== void 0 ? _a : base.label,
127
+ caption: (_b = override.caption) !== null && _b !== void 0 ? _b : base.caption,
128
+ command: (_c = override.command) !== null && _c !== void 0 ? _c : base.command,
129
+ icon: override.iconSvg
130
+ ? resolveIcon(base.id, override.iconSvg)
131
+ : base.icon,
132
+ rank: (_d = override.rank) !== null && _d !== void 0 ? _d : base.rank,
133
+ requireAvailable: (_e = override.requireAvailable) !== null && _e !== void 0 ? _e : base.requireAvailable,
134
+ // `null` is the explicit way to opt out of an agent's default prompt
135
+ // support; an absent key keeps the default. `undefined` from `??` is
136
+ // pruned below.
137
+ promptArgs: override.promptArgs === null
138
+ ? undefined
139
+ : ((_f = override.promptArgs) !== null && _f !== void 0 ? _f : base.promptArgs)
140
+ });
141
+ }
142
+ // What remains in `overrideById` are entries with ids that don't match a
143
+ // default — treat them as new agents the user is adding. Skip silently
144
+ // when a required field is missing rather than throwing; the settings
145
+ // schema validates the shape, so this is just a defensive fallback.
146
+ let nextRank = merged.reduce((max, agent) => Math.max(max, agent.rank), -1) + 1;
147
+ for (const entry of overrideById.values()) {
148
+ if (entry.enabled === false) {
149
+ continue;
150
+ }
151
+ const label = (_g = entry.label) !== null && _g !== void 0 ? _g : entry.id;
152
+ const command = (_h = entry.command) !== null && _h !== void 0 ? _h : entry.id;
153
+ merged.push({
154
+ id: entry.id,
155
+ label,
156
+ caption: (_j = entry.caption) !== null && _j !== void 0 ? _j : `Start ${label} in a new terminal.`,
157
+ command,
158
+ icon: resolveIcon(entry.id, entry.iconSvg),
159
+ rank: (_k = entry.rank) !== null && _k !== void 0 ? _k : nextRank++,
160
+ requireAvailable: (_l = entry.requireAvailable) !== null && _l !== void 0 ? _l : true,
161
+ promptArgs: entry.promptArgs === null || entry.promptArgs === undefined
162
+ ? undefined
163
+ : entry.promptArgs
164
+ });
165
+ }
166
+ merged.sort((a, b) => a.rank - b.rank);
167
+ return merged;
168
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Hit the xtralab server extension's `which`-proxy endpoint and turn the
3
+ * response into a Set of commands that resolved to a real binary on the
4
+ * server's `$PATH`. The frontend uses this set to filter the launcher's
5
+ * agent cards.
6
+ *
7
+ * On any failure (server extension not loaded, network error, malformed
8
+ * response) the returned Set is `null` — callers should treat that as
9
+ * "availability unknown" and either fall back to showing all agents or
10
+ * surface the error, depending on context.
11
+ */
12
+ export declare function fetchAvailableCommands(commands: string[]): Promise<Set<string> | null>;
@@ -0,0 +1,53 @@
1
+ import { URLExt } from '@jupyterlab/coreutils';
2
+ import { ServerConnection } from '@jupyterlab/services';
3
+ /**
4
+ * Hit the xtralab server extension's `which`-proxy endpoint and turn the
5
+ * response into a Set of commands that resolved to a real binary on the
6
+ * server's `$PATH`. The frontend uses this set to filter the launcher's
7
+ * agent cards.
8
+ *
9
+ * On any failure (server extension not loaded, network error, malformed
10
+ * response) the returned Set is `null` — callers should treat that as
11
+ * "availability unknown" and either fall back to showing all agents or
12
+ * surface the error, depending on context.
13
+ */
14
+ export async function fetchAvailableCommands(commands) {
15
+ if (commands.length === 0) {
16
+ return new Set();
17
+ }
18
+ const settings = ServerConnection.makeSettings();
19
+ const url = URLExt.join(settings.baseUrl, 'xtralab', 'agents', 'availability');
20
+ let response;
21
+ try {
22
+ response = await ServerConnection.makeRequest(url, {
23
+ method: 'POST',
24
+ body: JSON.stringify({ commands })
25
+ }, settings);
26
+ }
27
+ catch (error) {
28
+ console.warn('xtralab: availability check failed', error);
29
+ return null;
30
+ }
31
+ if (!response.ok) {
32
+ console.warn(`xtralab: availability endpoint returned HTTP ${response.status}`);
33
+ return null;
34
+ }
35
+ let payload;
36
+ try {
37
+ payload = await response.json();
38
+ }
39
+ catch (error) {
40
+ console.warn('xtralab: availability response was not JSON', error);
41
+ return null;
42
+ }
43
+ if (!payload || typeof payload !== 'object') {
44
+ return null;
45
+ }
46
+ const available = new Set();
47
+ for (const [command, path] of Object.entries(payload)) {
48
+ if (typeof path === 'string' && path.length > 0) {
49
+ available.add(command);
50
+ }
51
+ }
52
+ return available;
53
+ }
@@ -0,0 +1,22 @@
1
+ import type { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import { type IDisposable } from '@lumino/disposable';
3
+ import type { IAgent } from './agents';
4
+ /**
5
+ * The command id for opening the launcher.
6
+ */
7
+ export declare const CREATE_LAUNCHER_COMMAND = "launcher:create";
8
+ /**
9
+ * Build a JupyterLab command id for a given agent. Centralised so the
10
+ * plugin and the launcher both use the same names.
11
+ */
12
+ export declare function agentCommandId(agentId: string): string;
13
+ /**
14
+ * Register a JupyterLab command per agent. Each command opens a new
15
+ * terminal via `terminal:create-new` and feeds the agent's shell command
16
+ * into the fresh session as if the user had typed it.
17
+ *
18
+ * Returns a single disposable that tears down every registered command —
19
+ * the launcher disposes the previous set before re-registering on settings
20
+ * changes so the command palette stays in sync with the configured agents.
21
+ */
22
+ export declare function registerAgentCommands(app: JupyterFrontEnd, agents: IAgent[]): IDisposable;