jupyterlab_ai_code_assistants_extension 1.2.45 → 1.2.47

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 CHANGED
@@ -47,6 +47,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
47
47
  - **Conflict-safe upgrade** - if a retired standalone extension is still installed, its panel wins and this extension stands down for that assistant instead of showing a duplicate
48
48
  - **DeepSeek in the browser** - the DeepSeek Harness has no terminal chat; its panel lists the projects under `~/.dsh/sessions` (or `$DSH_HOME`) and `Open Web UI` starts `dsh --profile web --no-open --port 0` in the project's terminal, which prints the URL to open. The conversation is then chosen in the harness's own browser UI; branching and deleting work from the panel as for every other assistant. The URL is on the machine running JupyterLab, so a remote server needs a port forward to reach it
49
49
  - **Auto-disabled when absent** - an assistant whose CLI is not on `PATH` does not show a panel
50
+ - **Open Callback** - when an assistant's login sends your browser to an `http://127.0.0.1:...` link that fails to load, because the login is waiting on the machine running JupyterLab, run `Open Callback` from the command palette and paste the link; the Jupyter server requests it for you. Only `http` links to that machine's own loopback are accepted
50
51
 
51
52
  ## Requirements
52
53
 
@@ -0,0 +1,15 @@
1
+ import { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import { ICommandPalette } from '@jupyterlab/apputils';
3
+ import { TranslationBundle } from '@jupyterlab/translation';
4
+ /** The command that delivers a pasted login callback link. */
5
+ export declare const CALLBACK_COMMAND = "open:callback";
6
+ /**
7
+ * Register the command that sends a pasted login callback link to the login
8
+ * waiting on the Jupyter server.
9
+ *
10
+ * A CLI login waits for its OAuth redirect on the server's own loopback, but
11
+ * the browser that follows the redirect runs on the user's machine, where that
12
+ * address is another host - so the page fails to load, with the link left in
13
+ * the address bar. Pasted here, the server makes the request instead.
14
+ */
15
+ export declare function addCallbackCommand(app: JupyterFrontEnd, palette: ICommandPalette | null, category: string, trans: TranslationBundle): void;
@@ -0,0 +1,65 @@
1
+ import { InputDialog, Notification } from '@jupyterlab/apputils';
2
+ import { requestAPI } from './request';
3
+ /** The command that delivers a pasted login callback link. */
4
+ export const CALLBACK_COMMAND = 'open:callback';
5
+ /**
6
+ * Register the command that sends a pasted login callback link to the login
7
+ * waiting on the Jupyter server.
8
+ *
9
+ * A CLI login waits for its OAuth redirect on the server's own loopback, but
10
+ * the browser that follows the redirect runs on the user's machine, where that
11
+ * address is another host - so the page fails to load, with the link left in
12
+ * the address bar. Pasted here, the server makes the request instead.
13
+ */
14
+ export function addCallbackCommand(app, palette, category, trans) {
15
+ app.commands.addCommand(CALLBACK_COMMAND, {
16
+ label: trans.__('Open Callback'),
17
+ caption: trans.__('Send a pasted login callback link to the login waiting on the Jupyter server'),
18
+ describedBy: {
19
+ args: {
20
+ type: 'object',
21
+ properties: {
22
+ url: {
23
+ type: 'string',
24
+ description: 'The callback link. Asked for in a dialog when absent.'
25
+ }
26
+ }
27
+ }
28
+ },
29
+ execute: async (args) => {
30
+ var _a;
31
+ let url = typeof args.url === 'string' ? args.url.trim() : '';
32
+ if (!url) {
33
+ const asked = await InputDialog.getText({
34
+ title: trans.__('Open Callback'),
35
+ label: trans.__('Callback link from the browser address bar'),
36
+ placeholder: 'http://127.0.0.1:1455/auth/callback?code=...'
37
+ });
38
+ url = asked.button.accept ? ((_a = asked.value) !== null && _a !== void 0 ? _a : '').trim() : '';
39
+ if (!url) {
40
+ return;
41
+ }
42
+ }
43
+ try {
44
+ const answer = await requestAPI('callback', app.serviceManager.serverSettings, { method: 'POST', body: JSON.stringify({ url }) });
45
+ if (answer.status < 400) {
46
+ Notification.success(trans.__('Callback delivered - the login answered HTTP %1.', answer.status), { autoClose: 4000 });
47
+ }
48
+ else {
49
+ Notification.warning(trans.__('The login refused the callback (HTTP %1). Start the login again and paste the new link.', answer.status), { autoClose: 8000 });
50
+ }
51
+ }
52
+ catch (err) {
53
+ // The server's own codes, never the link: it carries a login code.
54
+ const code = err instanceof Error ? err.message : String(err);
55
+ const message = code === 'callback_not_loopback'
56
+ ? trans.__('Only an http link to 127.0.0.1 or localhost can be opened.')
57
+ : code === 'callback_unreachable'
58
+ ? trans.__('No login is waiting at that address. Start the login again and paste the new link.')
59
+ : trans.__('Could not open the callback: %1', code);
60
+ Notification.error(message, { autoClose: 8000 });
61
+ }
62
+ }
63
+ });
64
+ palette === null || palette === void 0 ? void 0 : palette.addItem({ command: CALLBACK_COMMAND, category });
65
+ }
package/lib/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { ILabShell, ILayoutRestorer } from '@jupyterlab/application';
2
- import { Notification } from '@jupyterlab/apputils';
2
+ import { ICommandPalette, Notification } from '@jupyterlab/apputils';
3
3
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
4
4
  import { ILauncher } from '@jupyterlab/launcher';
5
5
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
6
6
  import { ITerminalTracker } from '@jupyterlab/terminal';
7
7
  import { ITranslator, nullTranslator } from '@jupyterlab/translation';
8
8
  import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
9
+ import { addCallbackCommand } from './core/callback';
9
10
  import { launcherTileIcon, providerIcon } from './core/icons';
10
11
  import { DEFAULT_RECENT_LIMIT } from './core/limits';
11
12
  import { AssistantSessionsPanel, DEFAULT_PRESENTATION_MODE, commandId, panelWidgetId } from './core/panel';
@@ -40,9 +41,10 @@ const plugin = {
40
41
  IDefaultFileBrowser,
41
42
  IColourfulTabs,
42
43
  ILauncher,
43
- ITranslator
44
+ ITranslator,
45
+ ICommandPalette
44
46
  ],
45
- activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser, colourfulTabs, launcher, translator) => {
47
+ activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser, colourfulTabs, launcher, translator, palette) => {
46
48
  console.log('JupyterLab extension jupyterlab_ai_code_assistants_extension is activated!');
47
49
  // Every user-visible string in the panels goes through this bundle.
48
50
  // `ITranslator` is optional, and the null translator returns each string
@@ -51,6 +53,7 @@ const plugin = {
51
53
  // A duplicate id throws here, at registration, rather than surfacing later
52
54
  // as two panels quietly fighting over one widget id.
53
55
  const registry = new ProviderRegistry(PROVIDERS);
56
+ addCallbackCommand(app, palette !== null && palette !== void 0 ? palette : null, LAUNCHER_CATEGORY, trans);
54
57
  const serverSettings = app.serviceManager.serverSettings;
55
58
  const live = new Map();
56
59
  const warnedUnknown = new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_ai_code_assistants_extension",
3
- "version": "1.2.45",
3
+ "version": "1.2.47",
4
4
  "description": "A full launcher and manager for every AI code assistant in JupyterLab - Claude Code, Codex, Kimi, Gemini and DeepSeek. Start, resume, fork, switch, and clean up CLI sessions from a side panel per assistant - one click lands you in the right terminal with the assistant already running, no duplicate tabs, no UUID hunting.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The Open Callback command: a pasted login callback link, handed to the
3
+ * server. The server's side - loopback only, one request, nothing logged - is
4
+ * `tests/test_callback.py`; this is the popup, the request and what the user
5
+ * is told.
6
+ */
7
+
8
+ // ESM-only packages that JupyterLab's apputils pulls in and jest cannot parse.
9
+ jest.mock('@jupyter/react-components', () => ({}));
10
+ jest.mock('@jupyter/web-components', () => ({
11
+ addJupyterLabThemeChangeListener: () => undefined,
12
+ applyJupyterTheme: () => undefined,
13
+ jpButton: () => undefined,
14
+ jpToolbar: () => undefined,
15
+ provideJupyterDesignSystem: () => ({ register: () => undefined })
16
+ }));
17
+ jest.mock('../core/request', () => ({
18
+ requestAPI: jest.fn()
19
+ }));
20
+
21
+ import { InputDialog, Notification } from '@jupyterlab/apputils';
22
+ import { nullTranslator } from '@jupyterlab/translation';
23
+ import { CommandRegistry } from '@lumino/commands';
24
+
25
+ import { CALLBACK_COMMAND, addCallbackCommand } from '../core/callback';
26
+ import { requestAPI } from '../core/request';
27
+
28
+ const request = requestAPI as jest.Mock;
29
+ const LINK = 'http://127.0.0.1:1455/auth/callback?code=one-time&state=s';
30
+
31
+ function setup(): {
32
+ commands: CommandRegistry;
33
+ added: { command: string; category: string }[];
34
+ serverSettings: object;
35
+ } {
36
+ const commands = new CommandRegistry();
37
+ const serverSettings = {};
38
+ const added: { command: string; category: string }[] = [];
39
+ addCallbackCommand(
40
+ { commands, serviceManager: { serverSettings } } as any,
41
+ { addItem: (item: any) => added.push(item) } as any,
42
+ 'AI Assistants',
43
+ nullTranslator.load('test')
44
+ );
45
+ return { commands, added, serverSettings };
46
+ }
47
+
48
+ describe('the Open Callback command', () => {
49
+ let success: jest.SpyInstance;
50
+ let warning: jest.SpyInstance;
51
+ let error: jest.SpyInstance;
52
+ let dialog: jest.SpyInstance;
53
+
54
+ beforeEach(() => {
55
+ request.mockReset();
56
+ success = jest.spyOn(Notification, 'success').mockReturnValue('' as any);
57
+ warning = jest.spyOn(Notification, 'warning').mockReturnValue('' as any);
58
+ error = jest.spyOn(Notification, 'error').mockReturnValue('' as any);
59
+ dialog = jest.spyOn(InputDialog, 'getText');
60
+ });
61
+
62
+ afterEach(() => {
63
+ jest.restoreAllMocks();
64
+ });
65
+
66
+ it('is on the command palette under the assistants category', () => {
67
+ const { commands, added } = setup();
68
+ expect(commands.hasCommand(CALLBACK_COMMAND)).toBe(true);
69
+ expect(commands.label(CALLBACK_COMMAND)).toBe('Open Callback');
70
+ expect(added).toEqual([
71
+ { command: CALLBACK_COMMAND, category: 'AI Assistants' }
72
+ ]);
73
+ });
74
+
75
+ it('registers without a palette', () => {
76
+ const commands = new CommandRegistry();
77
+ addCallbackCommand(
78
+ { commands, serviceManager: { serverSettings: {} } } as any,
79
+ null,
80
+ 'AI Assistants',
81
+ nullTranslator.load('test')
82
+ );
83
+ expect(commands.hasCommand(CALLBACK_COMMAND)).toBe(true);
84
+ });
85
+
86
+ it('asks for the link in a popup and posts it, trimmed, to the server', async () => {
87
+ const { commands, serverSettings } = setup();
88
+ dialog.mockResolvedValue({
89
+ button: { accept: true },
90
+ value: ` ${LINK}\n`
91
+ } as any);
92
+ request.mockResolvedValue({ status: 302 });
93
+
94
+ await commands.execute(CALLBACK_COMMAND);
95
+
96
+ expect(dialog).toHaveBeenCalledTimes(1);
97
+ expect(request).toHaveBeenCalledWith('callback', serverSettings, {
98
+ method: 'POST',
99
+ body: JSON.stringify({ url: LINK })
100
+ });
101
+ expect(success).toHaveBeenCalledTimes(1);
102
+ expect(String(success.mock.calls[0][0])).toContain('302');
103
+ });
104
+
105
+ it('takes the link as an argument without a popup', async () => {
106
+ const { commands } = setup();
107
+ request.mockResolvedValue({ status: 200 });
108
+
109
+ await commands.execute(CALLBACK_COMMAND, { url: LINK });
110
+
111
+ expect(dialog).not.toHaveBeenCalled();
112
+ expect(request).toHaveBeenCalledTimes(1);
113
+ expect(success).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ it.each([
117
+ ['cancelled', { button: { accept: false }, value: LINK }],
118
+ ['left empty', { button: { accept: true }, value: ' ' }]
119
+ ])('sends nothing when the popup is %s', async (_name, answer) => {
120
+ const { commands } = setup();
121
+ dialog.mockResolvedValue(answer as any);
122
+
123
+ await commands.execute(CALLBACK_COMMAND);
124
+
125
+ expect(request).not.toHaveBeenCalled();
126
+ expect(success).not.toHaveBeenCalled();
127
+ expect(error).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it('says so when the login refused the callback', async () => {
131
+ const { commands } = setup();
132
+ request.mockResolvedValue({ status: 400 });
133
+
134
+ await commands.execute(CALLBACK_COMMAND, { url: LINK });
135
+
136
+ expect(success).not.toHaveBeenCalled();
137
+ expect(warning).toHaveBeenCalledTimes(1);
138
+ expect(String(warning.mock.calls[0][0])).toContain('400');
139
+ });
140
+
141
+ it.each([
142
+ ['callback_not_loopback', '127.0.0.1'],
143
+ ['callback_unreachable', 'No login is waiting'],
144
+ ['Internal Server Error', 'Internal Server Error']
145
+ ])('reports %s without repeating the link', async (code, expected) => {
146
+ const { commands } = setup();
147
+ request.mockRejectedValue(new Error(code));
148
+
149
+ await commands.execute(CALLBACK_COMMAND, { url: LINK });
150
+
151
+ expect(error).toHaveBeenCalledTimes(1);
152
+ const message = String(error.mock.calls[0][0]);
153
+ expect(message).toContain(expected);
154
+ // The link carries a login code.
155
+ expect(message).not.toContain('one-time');
156
+ });
157
+ });
@@ -480,6 +480,12 @@ describe('ACC-PROV - the activation contract', () => {
480
480
  ])
481
481
  );
482
482
 
483
+ it('registers the Open Callback command at activation', async () => {
484
+ const live = await activate();
485
+ expect(live.app.commands.hasCommand('open:callback')).toBe(true);
486
+ live.docked.forEach(widget => widget.dispose());
487
+ });
488
+
483
489
  it('ACC-PROV-10, ACC-PROV-11 - each panel carries its own widget id and registers it for restore', async () => {
484
490
  const live = await activate();
485
491
  // How this fails: pass a constant to `restorer.add` and two panels claim
@@ -0,0 +1,99 @@
1
+ import { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import {
3
+ ICommandPalette,
4
+ InputDialog,
5
+ Notification
6
+ } from '@jupyterlab/apputils';
7
+ import { TranslationBundle } from '@jupyterlab/translation';
8
+
9
+ import { requestAPI } from './request';
10
+
11
+ /** The command that delivers a pasted login callback link. */
12
+ export const CALLBACK_COMMAND = 'open:callback';
13
+
14
+ /**
15
+ * Register the command that sends a pasted login callback link to the login
16
+ * waiting on the Jupyter server.
17
+ *
18
+ * A CLI login waits for its OAuth redirect on the server's own loopback, but
19
+ * the browser that follows the redirect runs on the user's machine, where that
20
+ * address is another host - so the page fails to load, with the link left in
21
+ * the address bar. Pasted here, the server makes the request instead.
22
+ */
23
+ export function addCallbackCommand(
24
+ app: JupyterFrontEnd,
25
+ palette: ICommandPalette | null,
26
+ category: string,
27
+ trans: TranslationBundle
28
+ ): void {
29
+ app.commands.addCommand(CALLBACK_COMMAND, {
30
+ label: trans.__('Open Callback'),
31
+ caption: trans.__(
32
+ 'Send a pasted login callback link to the login waiting on the Jupyter server'
33
+ ),
34
+ describedBy: {
35
+ args: {
36
+ type: 'object',
37
+ properties: {
38
+ url: {
39
+ type: 'string',
40
+ description: 'The callback link. Asked for in a dialog when absent.'
41
+ }
42
+ }
43
+ }
44
+ },
45
+ execute: async args => {
46
+ let url = typeof args.url === 'string' ? args.url.trim() : '';
47
+ if (!url) {
48
+ const asked = await InputDialog.getText({
49
+ title: trans.__('Open Callback'),
50
+ label: trans.__('Callback link from the browser address bar'),
51
+ placeholder: 'http://127.0.0.1:1455/auth/callback?code=...'
52
+ });
53
+ url = asked.button.accept ? (asked.value ?? '').trim() : '';
54
+ if (!url) {
55
+ return;
56
+ }
57
+ }
58
+ try {
59
+ const answer = await requestAPI<{ status: number }>(
60
+ 'callback',
61
+ app.serviceManager.serverSettings,
62
+ { method: 'POST', body: JSON.stringify({ url }) }
63
+ );
64
+ if (answer.status < 400) {
65
+ Notification.success(
66
+ trans.__(
67
+ 'Callback delivered - the login answered HTTP %1.',
68
+ answer.status
69
+ ),
70
+ { autoClose: 4000 }
71
+ );
72
+ } else {
73
+ Notification.warning(
74
+ trans.__(
75
+ 'The login refused the callback (HTTP %1). Start the login again and paste the new link.',
76
+ answer.status
77
+ ),
78
+ { autoClose: 8000 }
79
+ );
80
+ }
81
+ } catch (err) {
82
+ // The server's own codes, never the link: it carries a login code.
83
+ const code = err instanceof Error ? err.message : String(err);
84
+ const message =
85
+ code === 'callback_not_loopback'
86
+ ? trans.__(
87
+ 'Only an http link to 127.0.0.1 or localhost can be opened.'
88
+ )
89
+ : code === 'callback_unreachable'
90
+ ? trans.__(
91
+ 'No login is waiting at that address. Start the login again and paste the new link.'
92
+ )
93
+ : trans.__('Could not open the callback: %1', code);
94
+ Notification.error(message, { autoClose: 8000 });
95
+ }
96
+ }
97
+ });
98
+ palette?.addItem({ command: CALLBACK_COMMAND, category });
99
+ }
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  JupyterFrontEnd,
5
5
  JupyterFrontEndPlugin
6
6
  } from '@jupyterlab/application';
7
- import { Notification } from '@jupyterlab/apputils';
7
+ import { ICommandPalette, Notification } from '@jupyterlab/apputils';
8
8
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
9
9
  import { ILauncher } from '@jupyterlab/launcher';
10
10
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
@@ -13,6 +13,7 @@ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
13
13
  import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
14
14
  import { IDisposable } from '@lumino/disposable';
15
15
 
16
+ import { addCallbackCommand } from './core/callback';
16
17
  import { launcherTileIcon, providerIcon } from './core/icons';
17
18
  import { DEFAULT_RECENT_LIMIT } from './core/limits';
18
19
  import {
@@ -75,7 +76,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
75
76
  IDefaultFileBrowser,
76
77
  IColourfulTabs,
77
78
  ILauncher,
78
- ITranslator
79
+ ITranslator,
80
+ ICommandPalette
79
81
  ],
80
82
  activate: async (
81
83
  app: JupyterFrontEnd,
@@ -86,7 +88,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
86
88
  fileBrowser: IDefaultFileBrowser | null,
87
89
  colourfulTabs: IColourfulTabs | null,
88
90
  launcher: ILauncher | null,
89
- translator: ITranslator | null
91
+ translator: ITranslator | null,
92
+ palette: ICommandPalette | null
90
93
  ) => {
91
94
  console.log(
92
95
  'JupyterLab extension jupyterlab_ai_code_assistants_extension is activated!'
@@ -103,6 +106,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
103
106
  // as two panels quietly fighting over one widget id.
104
107
  const registry = new ProviderRegistry(PROVIDERS);
105
108
 
109
+ addCallbackCommand(app, palette ?? null, LAUNCHER_CATEGORY, trans);
110
+
106
111
  const serverSettings = app.serviceManager.serverSettings;
107
112
  const live = new Map<string, ILivePanel>();
108
113
  const warnedUnknown = new Set<string>();