xtralab 0.1.2 → 0.3.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 (75) hide show
  1. package/README.md +129 -46
  2. package/lib/fileBrowser/commands.d.ts +2 -2
  3. package/lib/fileBrowser/commands.js +2 -2
  4. package/lib/fileBrowser/fileBrowser.js +72 -2
  5. package/lib/fileBrowser/gitignore.d.ts +29 -0
  6. package/lib/fileBrowser/gitignore.js +74 -0
  7. package/lib/fileBrowser/icons.d.ts +4 -4
  8. package/lib/fileBrowser/icons.js +4 -4
  9. package/lib/fileBrowser/widget.d.ts +2 -2
  10. package/lib/fileBrowser/widget.js +2 -2
  11. package/lib/git/api.d.ts +11 -0
  12. package/lib/git/api.js +19 -0
  13. package/lib/git/commands.d.ts +3 -1
  14. package/lib/git/commands.js +10 -4
  15. package/lib/git/diffWidget.d.ts +11 -0
  16. package/lib/git/diffWidget.js +90 -9
  17. package/lib/git/icons.d.ts +1 -1
  18. package/lib/git/icons.js +1 -1
  19. package/lib/git/index.js +22 -6
  20. package/lib/git/panelComponent.d.ts +3 -1
  21. package/lib/git/panelComponent.js +6 -3
  22. package/lib/index.js +9 -1
  23. package/lib/launcher/agents.d.ts +78 -0
  24. package/lib/launcher/agents.js +168 -0
  25. package/lib/launcher/availability.d.ts +12 -0
  26. package/lib/launcher/availability.js +53 -0
  27. package/lib/launcher/commands.d.ts +22 -0
  28. package/lib/launcher/commands.js +89 -0
  29. package/lib/launcher/dashboard.d.ts +46 -0
  30. package/lib/launcher/dashboard.js +214 -0
  31. package/lib/launcher/icons.d.ts +33 -0
  32. package/lib/launcher/icons.js +113 -0
  33. package/lib/launcher/index.d.ts +23 -0
  34. package/lib/launcher/index.js +191 -0
  35. package/lib/launcher/invocation.d.ts +29 -0
  36. package/lib/launcher/invocation.js +42 -0
  37. package/lib/menus/index.d.ts +14 -0
  38. package/lib/menus/index.js +102 -0
  39. package/lib/sidebar/index.d.ts +18 -0
  40. package/lib/sidebar/index.js +155 -0
  41. package/lib/statusBar/index.d.ts +14 -0
  42. package/lib/statusBar/index.js +89 -0
  43. package/lib/statusBar/widget.d.ts +121 -0
  44. package/lib/statusBar/widget.js +237 -0
  45. package/package.json +25 -3
  46. package/schema/launcher.json +70 -0
  47. package/schema/sidebar.json +57 -0
  48. package/src/fileBrowser/commands.ts +2 -2
  49. package/src/fileBrowser/fileBrowser.tsx +76 -2
  50. package/src/fileBrowser/gitignore.ts +86 -0
  51. package/src/fileBrowser/icons.ts +4 -4
  52. package/src/fileBrowser/widget.tsx +3 -3
  53. package/src/git/api.ts +20 -0
  54. package/src/git/commands.ts +19 -5
  55. package/src/git/diffWidget.tsx +109 -13
  56. package/src/git/icons.ts +1 -1
  57. package/src/git/index.ts +31 -5
  58. package/src/git/panelComponent.tsx +17 -5
  59. package/src/index.ts +9 -1
  60. package/src/launcher/agents.ts +248 -0
  61. package/src/launcher/availability.ts +73 -0
  62. package/src/launcher/commands.ts +103 -0
  63. package/src/launcher/dashboard.tsx +462 -0
  64. package/src/launcher/icons.ts +123 -0
  65. package/src/launcher/index.ts +228 -0
  66. package/src/launcher/invocation.ts +45 -0
  67. package/src/menus/index.ts +124 -0
  68. package/src/sidebar/index.ts +195 -0
  69. package/src/statusBar/index.ts +115 -0
  70. package/src/statusBar/widget.tsx +318 -0
  71. package/style/git.css +4 -8
  72. package/style/index.css +2 -0
  73. package/style/index.js +2 -0
  74. package/style/launcher.css +323 -0
  75. package/style/statusBar.css +95 -0
@@ -21,7 +21,12 @@ import { FileChangeStatus, IFileChange, IGitStatusResult } from './tokens';
21
21
  */
22
22
  export interface IGitPanelHandlers {
23
23
  /** Open a diff viewer for the given file in the main area. */
24
- openDiff(change: IFileChange): void;
24
+ openDiff(
25
+ change: IFileChange,
26
+ options?: {
27
+ pin?: boolean;
28
+ }
29
+ ): void;
25
30
  /** Show a confirmation dialog. Returns true when the user accepted. */
26
31
  confirm(title: string, body: string, accept: string): Promise<boolean>;
27
32
  /** Show an error dialog. */
@@ -281,7 +286,11 @@ export function GitPanelComponent(props: {
281
286
  key={`${change.group}:${change.path}`}
282
287
  change={change}
283
288
  pendingAction={pendingActions[`${change.group}:${change.path}`]}
284
- onClick={() => handlers.openDiff(change)}
289
+ onClick={event =>
290
+ handlers.openDiff(change, {
291
+ pin: event.ctrlKey || event.metaKey
292
+ })
293
+ }
285
294
  actions={[
286
295
  {
287
296
  label: 'Unstage changes',
@@ -314,7 +323,11 @@ export function GitPanelComponent(props: {
314
323
  key={`${change.group}:${change.path}`}
315
324
  change={change}
316
325
  pendingAction={pendingActions[`${change.group}:${change.path}`]}
317
- onClick={() => handlers.openDiff(change)}
326
+ onClick={event =>
327
+ handlers.openDiff(change, {
328
+ pin: event.ctrlKey || event.metaKey
329
+ })
330
+ }
318
331
  actions={[
319
332
  {
320
333
  label: 'Discard changes',
@@ -365,7 +378,6 @@ function PanelHeader(props: {
365
378
  return (
366
379
  <div className="jp-xtralab-GitPanel-header">
367
380
  <div className="jp-xtralab-GitPanel-headerInfo">
368
- <div className="jp-xtralab-GitPanel-title">Source Control</div>
369
381
  {repoName !== null ? (
370
382
  <div className="jp-xtralab-GitPanel-repo" title={topLevel ?? ''}>
371
383
  {repoName}
@@ -442,7 +454,7 @@ function ChangeSection(props: {
442
454
  function ChangeRow(props: {
443
455
  change: IFileChange;
444
456
  pendingAction: string | undefined;
445
- onClick: () => void;
457
+ onClick: (event: React.MouseEvent<HTMLDivElement>) => void;
446
458
  actions: IRowAction[];
447
459
  }): React.ReactElement {
448
460
  const { change, pendingAction, onClick, actions } = props;
package/src/index.ts CHANGED
@@ -2,6 +2,10 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
2
 
3
3
  import fileBrowserPlugin from './fileBrowser';
4
4
  import gitPlugin from './git';
5
+ import launcherPlugin from './launcher';
6
+ import menusPlugin from './menus';
7
+ import sidebarPlugin from './sidebar';
8
+ import statusBarPlugin from './statusBar';
5
9
 
6
10
  /**
7
11
  * Every plugin contributed by `xtralab`. The entry point of the
@@ -12,7 +16,11 @@ import gitPlugin from './git';
12
16
  */
13
17
  const plugins: JupyterFrontEndPlugin<unknown>[] = [
14
18
  fileBrowserPlugin,
15
- gitPlugin
19
+ gitPlugin,
20
+ launcherPlugin,
21
+ menusPlugin,
22
+ sidebarPlugin,
23
+ statusBarPlugin
16
24
  ];
17
25
 
18
26
  export default plugins;
@@ -0,0 +1,248 @@
1
+ import { LabIcon, terminalIcon } from '@jupyterlab/ui-components';
2
+
3
+ import { BUILTIN_AGENT_ICONS } from './icons';
4
+
5
+ /**
6
+ * The shape of an agent card on the launcher. The `command` is the literal
7
+ * text typed into the new terminal session — interactive shells expand
8
+ * aliases, so users can point at `claude`, `cl`, or whatever runs their
9
+ * preferred wrapper.
10
+ */
11
+ export interface IAgent {
12
+ id: string;
13
+ label: string;
14
+ caption: string;
15
+ command: string;
16
+ icon: LabIcon;
17
+ rank: number;
18
+ /**
19
+ * When false, the launcher skips the `which`-based availability check for
20
+ * this entry — useful for aliases or shell functions that aren't on PATH
21
+ * but still resolve when typed in a real terminal. Defaults to true.
22
+ */
23
+ requireAvailable: boolean;
24
+ /**
25
+ * Argv tokens spliced between `command` and a shell-quoted prompt when
26
+ * the user types one into the launcher's prompt box. The semantics:
27
+ *
28
+ * - `[]` → prompt is appended as a positional argument:
29
+ * `<command> 'PROMPT'`. (Used by claude, codex, gemini, vibe.)
30
+ * - `['-i']` / `['--prompt']` → the prompt is preceded by a flag:
31
+ * `<command> -i 'PROMPT'`. (Used by copilot, opencode.)
32
+ * - `undefined` → the agent does not accept an initial prompt. The
33
+ * launcher dims the agent's button while the prompt textarea is
34
+ * non-empty, so the user gets a clear signal rather than a silently
35
+ * dropped prompt.
36
+ *
37
+ * The prompt itself is always single-quoted with embedded single quotes
38
+ * escaped, so multi-line prompts and shell metacharacters are safe.
39
+ */
40
+ promptArgs?: string[];
41
+ }
42
+
43
+ /**
44
+ * The settings-side shape: every field except `id` is optional, so a user
45
+ * can override a single field on a default agent (e.g. swap the command for
46
+ * an alias) without restating the whole entry. New ids define brand-new
47
+ * agent cards.
48
+ */
49
+ export interface IAgentSettings {
50
+ id: string;
51
+ label?: string;
52
+ caption?: string;
53
+ command?: string;
54
+ /**
55
+ * Inline SVG for a custom agent's icon. Required for new ids whose icon
56
+ * isn't shipped with xtralab; ignored for default ids unless explicitly
57
+ * set (in which case it overrides the built-in).
58
+ */
59
+ iconSvg?: string;
60
+ rank?: number;
61
+ /**
62
+ * When false, the agent is hidden from the launcher and the command
63
+ * palette. Defaults to true.
64
+ */
65
+ enabled?: boolean;
66
+ /** See `IAgent.requireAvailable`. */
67
+ requireAvailable?: boolean;
68
+ /**
69
+ * See `IAgent.promptArgs`. Pass an empty array to mark the agent as
70
+ * accepting a positional prompt; pass an array like `["-p"]` to use a
71
+ * flag. Pass `null` to explicitly turn off prompt support for an agent
72
+ * that has it on by default.
73
+ */
74
+ promptArgs?: string[] | null;
75
+ }
76
+
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.
84
+ */
85
+ const DEFAULTS: IAgent[] = [
86
+ {
87
+ id: 'claude',
88
+ label: 'Claude',
89
+ caption: 'Start Claude Code in a new terminal.',
90
+ command: 'claude',
91
+ icon: BUILTIN_AGENT_ICONS.claude,
92
+ rank: 0,
93
+ requireAvailable: true,
94
+ promptArgs: []
95
+ },
96
+ {
97
+ id: 'codex',
98
+ label: 'Codex',
99
+ caption: 'Start the Codex CLI in a new terminal.',
100
+ command: 'codex',
101
+ icon: BUILTIN_AGENT_ICONS.codex,
102
+ rank: 1,
103
+ requireAvailable: true,
104
+ promptArgs: []
105
+ },
106
+ {
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,
112
+ rank: 2,
113
+ requireAvailable: true,
114
+ promptArgs: []
115
+ },
116
+ {
117
+ id: 'copilot',
118
+ label: 'Copilot',
119
+ caption: 'Start the GitHub Copilot CLI in a new terminal.',
120
+ command: 'copilot',
121
+ icon: BUILTIN_AGENT_ICONS.copilot,
122
+ rank: 3,
123
+ requireAvailable: true,
124
+ // `-i "PROMPT"` is the documented way to start interactive mode and
125
+ // auto-execute the prompt; `-p` exits after responding (non-interactive).
126
+ promptArgs: ['-i']
127
+ },
128
+ {
129
+ id: 'goose',
130
+ label: 'Goose',
131
+ caption: 'Start Goose in a new terminal.',
132
+ command: 'goose',
133
+ icon: BUILTIN_AGENT_ICONS.goose,
134
+ rank: 4,
135
+ requireAvailable: true
136
+ // `goose` does not accept an inline interactive prompt; users would
137
+ // need to type the prompt after `goose session` starts.
138
+ },
139
+ {
140
+ id: 'opencode',
141
+ label: 'OpenCode',
142
+ caption: 'Start OpenCode in a new terminal.',
143
+ command: 'opencode',
144
+ icon: BUILTIN_AGENT_ICONS.opencode,
145
+ rank: 5,
146
+ requireAvailable: true,
147
+ promptArgs: ['--prompt']
148
+ },
149
+ {
150
+ id: 'kiro',
151
+ label: 'Kiro',
152
+ caption: 'Start the Kiro CLI in a new terminal.',
153
+ command: 'kiro',
154
+ icon: BUILTIN_AGENT_ICONS.kiro,
155
+ rank: 6,
156
+ requireAvailable: true
157
+ // The Kiro chat session only takes initial prompts via the in-session
158
+ // `/chat new <prompt>` slash command, not a CLI argument.
159
+ },
160
+ {
161
+ id: 'mistral-vibe',
162
+ label: 'Mistral Vibe',
163
+ caption: 'Start Mistral Vibe in a new terminal.',
164
+ command: 'vibe',
165
+ icon: BUILTIN_AGENT_ICONS['mistral-vibe'],
166
+ rank: 7,
167
+ requireAvailable: true,
168
+ promptArgs: []
169
+ }
170
+ ];
171
+
172
+ function resolveIcon(id: string, iconSvg: string | undefined): LabIcon {
173
+ if (iconSvg) {
174
+ return new LabIcon({ name: `xtralab:agent-custom-${id}`, svgstr: iconSvg });
175
+ }
176
+ return BUILTIN_AGENT_ICONS[id] ?? terminalIcon;
177
+ }
178
+
179
+ /**
180
+ * Merge xtralab's defaults with the user's settings. Default entries keep
181
+ * their built-in fields unless explicitly overridden; user-only entries are
182
+ * appended. `enabled: false` filters an entry out of the result entirely
183
+ * (so callers don't need to check the flag again).
184
+ */
185
+ export function mergeAgents(overrides: IAgentSettings[]): IAgent[] {
186
+ const overrideById = new Map(overrides.map(entry => [entry.id, entry]));
187
+ const merged: IAgent[] = [];
188
+
189
+ for (const base of DEFAULTS) {
190
+ const override = overrideById.get(base.id);
191
+ if (!override) {
192
+ merged.push({ ...base });
193
+ continue;
194
+ }
195
+ overrideById.delete(base.id);
196
+ if (override.enabled === false) {
197
+ continue;
198
+ }
199
+ merged.push({
200
+ id: base.id,
201
+ label: override.label ?? base.label,
202
+ caption: override.caption ?? base.caption,
203
+ command: override.command ?? base.command,
204
+ icon: override.iconSvg
205
+ ? resolveIcon(base.id, override.iconSvg)
206
+ : base.icon,
207
+ rank: override.rank ?? base.rank,
208
+ requireAvailable: override.requireAvailable ?? base.requireAvailable,
209
+ // `null` is the explicit way to opt out of an agent's default prompt
210
+ // support; an absent key keeps the default. `undefined` from `??` is
211
+ // pruned below.
212
+ promptArgs:
213
+ override.promptArgs === null
214
+ ? undefined
215
+ : (override.promptArgs ?? base.promptArgs)
216
+ });
217
+ }
218
+
219
+ // What remains in `overrideById` are entries with ids that don't match a
220
+ // default — treat them as new agents the user is adding. Skip silently
221
+ // when a required field is missing rather than throwing; the settings
222
+ // schema validates the shape, so this is just a defensive fallback.
223
+ let nextRank =
224
+ merged.reduce((max, agent) => Math.max(max, agent.rank), -1) + 1;
225
+ for (const entry of overrideById.values()) {
226
+ if (entry.enabled === false) {
227
+ continue;
228
+ }
229
+ const label = entry.label ?? entry.id;
230
+ const command = entry.command ?? entry.id;
231
+ merged.push({
232
+ id: entry.id,
233
+ label,
234
+ caption: entry.caption ?? `Start ${label} in a new terminal.`,
235
+ command,
236
+ icon: resolveIcon(entry.id, entry.iconSvg),
237
+ rank: entry.rank ?? nextRank++,
238
+ requireAvailable: entry.requireAvailable ?? true,
239
+ promptArgs:
240
+ entry.promptArgs === null || entry.promptArgs === undefined
241
+ ? undefined
242
+ : entry.promptArgs
243
+ });
244
+ }
245
+
246
+ merged.sort((a, b) => a.rank - b.rank);
247
+ return merged;
248
+ }
@@ -0,0 +1,73 @@
1
+ import { URLExt } from '@jupyterlab/coreutils';
2
+ import { ServerConnection } from '@jupyterlab/services';
3
+
4
+ /**
5
+ * Hit the xtralab server extension's `which`-proxy endpoint and turn the
6
+ * response into a Set of commands that resolved to a real binary on the
7
+ * server's `$PATH`. The frontend uses this set to filter the launcher's
8
+ * agent cards.
9
+ *
10
+ * On any failure (server extension not loaded, network error, malformed
11
+ * response) the returned Set is `null` — callers should treat that as
12
+ * "availability unknown" and either fall back to showing all agents or
13
+ * surface the error, depending on context.
14
+ */
15
+ export async function fetchAvailableCommands(
16
+ commands: string[]
17
+ ): Promise<Set<string> | null> {
18
+ if (commands.length === 0) {
19
+ return new Set();
20
+ }
21
+
22
+ const settings = ServerConnection.makeSettings();
23
+ const url = URLExt.join(
24
+ settings.baseUrl,
25
+ 'xtralab',
26
+ 'agents',
27
+ 'availability'
28
+ );
29
+
30
+ let response: Response;
31
+ try {
32
+ response = await ServerConnection.makeRequest(
33
+ url,
34
+ {
35
+ method: 'POST',
36
+ body: JSON.stringify({ commands })
37
+ },
38
+ settings
39
+ );
40
+ } catch (error) {
41
+ console.warn('xtralab: availability check failed', error);
42
+ return null;
43
+ }
44
+
45
+ if (!response.ok) {
46
+ console.warn(
47
+ `xtralab: availability endpoint returned HTTP ${response.status}`
48
+ );
49
+ return null;
50
+ }
51
+
52
+ let payload: unknown;
53
+ try {
54
+ payload = await response.json();
55
+ } catch (error) {
56
+ console.warn('xtralab: availability response was not JSON', error);
57
+ return null;
58
+ }
59
+
60
+ if (!payload || typeof payload !== 'object') {
61
+ return null;
62
+ }
63
+
64
+ const available = new Set<string>();
65
+ for (const [command, path] of Object.entries(
66
+ payload as Record<string, unknown>
67
+ )) {
68
+ if (typeof path === 'string' && path.length > 0) {
69
+ available.add(command);
70
+ }
71
+ }
72
+ return available;
73
+ }
@@ -0,0 +1,103 @@
1
+ import type { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import type { MainAreaWidget } from '@jupyterlab/apputils';
3
+ import type { ITerminal } from '@jupyterlab/terminal';
4
+ import { DisposableSet, type IDisposable } from '@lumino/disposable';
5
+
6
+ import type { IAgent } from './agents';
7
+ import { buildAgentInvocation } from './invocation';
8
+
9
+ /**
10
+ * The command id for opening the launcher.
11
+ */
12
+ export const CREATE_LAUNCHER_COMMAND = 'launcher:create';
13
+
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
+ /**
23
+ * Register a JupyterLab command per agent. Each command opens a new
24
+ * terminal via `terminal:create-new` and feeds the agent's shell command
25
+ * into the fresh session as if the user had typed it.
26
+ *
27
+ * Returns a single disposable that tears down every registered command —
28
+ * the launcher disposes the previous set before re-registering on settings
29
+ * changes so the command palette stays in sync with the configured agents.
30
+ */
31
+ export function registerAgentCommands(
32
+ app: JupyterFrontEnd,
33
+ agents: IAgent[]
34
+ ): IDisposable {
35
+ const disposables = new DisposableSet();
36
+ for (const agent of agents) {
37
+ const disposable = app.commands.addCommand(agentCommandId(agent.id), {
38
+ label: agent.label,
39
+ caption: agent.caption,
40
+ icon: agent.icon,
41
+ execute: async args => {
42
+ const cwd = args['cwd'] as string | undefined;
43
+ const promptArg = args['prompt'];
44
+ const prompt = typeof promptArg === 'string' ? promptArg : '';
45
+ 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;
54
+
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
+ };
72
+
73
+ const applyAgentLabel = (): void => {
74
+ if (main.isDisposed || main.content.isDisposed) {
75
+ return;
76
+ }
77
+ main.content.title.label = agent.label;
78
+ };
79
+
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
+ }
96
+
97
+ return main;
98
+ }
99
+ });
100
+ disposables.add(disposable);
101
+ }
102
+ return disposables;
103
+ }