jupyterlab_ai_code_assistants_extension 1.2.45 → 1.2.50
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 +1 -0
- package/lib/core/callback.d.ts +15 -0
- package/lib/core/callback.js +140 -0
- package/lib/core/icons.js +9 -8
- package/lib/index.js +6 -3
- package/package.json +1 -1
- package/src/__tests__/callback.spec.ts +258 -0
- package/src/__tests__/index.spec.ts +6 -0
- package/src/core/callback.ts +189 -0
- package/src/core/icons.ts +9 -8
- package/src/index.ts +8 -3
- package/style/base.css +52 -0
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, and the popup says whether the login accepted it. 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,140 @@
|
|
|
1
|
+
import { Dialog, Notification } from '@jupyterlab/apputils';
|
|
2
|
+
import { Widget } from '@lumino/widgets';
|
|
3
|
+
import { requestAPI } from './request';
|
|
4
|
+
/** The command that delivers a pasted login callback link. */
|
|
5
|
+
export const CALLBACK_COMMAND = 'open:callback';
|
|
6
|
+
async function deliver(url, app, trans) {
|
|
7
|
+
try {
|
|
8
|
+
const answer = await requestAPI('callback', app.serviceManager.serverSettings, { method: 'POST', body: JSON.stringify({ url }) });
|
|
9
|
+
return answer.status < 400
|
|
10
|
+
? {
|
|
11
|
+
level: 'success',
|
|
12
|
+
message: trans.__('The login accepted the link. Check the terminal where the login is running.')
|
|
13
|
+
}
|
|
14
|
+
: {
|
|
15
|
+
level: 'warning',
|
|
16
|
+
message: trans.__('The login did not accept this link. Start the login again and paste the new link.')
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
const code = err instanceof Error ? err.message : '';
|
|
21
|
+
return {
|
|
22
|
+
level: 'error',
|
|
23
|
+
message: code === 'callback_not_loopback'
|
|
24
|
+
? trans.__('This link cannot be used. It must start with http://127.0.0.1 or http://localhost.')
|
|
25
|
+
: code === 'callback_unreachable'
|
|
26
|
+
? trans.__('No login is waiting for this link. Start the login again and paste the new link.')
|
|
27
|
+
: trans.__('The link could not be sent to the Jupyter server. Try again.')
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** The popup's body: the link field and, below it, what the last send came
|
|
32
|
+
* to. */
|
|
33
|
+
class CallbackForm extends Widget {
|
|
34
|
+
constructor(trans) {
|
|
35
|
+
super();
|
|
36
|
+
this.addClass('jp-AiAssistantsCallback');
|
|
37
|
+
const label = document.createElement('label');
|
|
38
|
+
label.textContent = trans.__('Callback link from the browser address bar');
|
|
39
|
+
this._input = document.createElement('input');
|
|
40
|
+
this._input.type = 'text';
|
|
41
|
+
this._input.className = 'jp-mod-styled';
|
|
42
|
+
this._input.placeholder = 'http://127.0.0.1:1455/auth/callback?code=...';
|
|
43
|
+
label.appendChild(this._input);
|
|
44
|
+
this._status = document.createElement('div');
|
|
45
|
+
this._status.className = 'jp-AiAssistantsCallback-status';
|
|
46
|
+
this._status.setAttribute('role', 'status');
|
|
47
|
+
this.node.append(label, this._status);
|
|
48
|
+
}
|
|
49
|
+
get url() {
|
|
50
|
+
return this._input.value.trim();
|
|
51
|
+
}
|
|
52
|
+
report(level, message) {
|
|
53
|
+
this._status.dataset.level = level;
|
|
54
|
+
this._status.textContent = message;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** OK sends the link and leaves the window open, so the result is read - and
|
|
58
|
+
* a refused link replaced - in the same place. Close and Escape end it. */
|
|
59
|
+
class CallbackDialog extends Dialog {
|
|
60
|
+
constructor(_form, _send, _trans) {
|
|
61
|
+
super({
|
|
62
|
+
title: _trans.__('Open Callback'),
|
|
63
|
+
body: _form,
|
|
64
|
+
buttons: [
|
|
65
|
+
Dialog.cancelButton({ label: _trans.__('Close') }),
|
|
66
|
+
Dialog.okButton({ label: _trans.__('OK') })
|
|
67
|
+
],
|
|
68
|
+
defaultButton: 1,
|
|
69
|
+
focusNodeSelector: 'input'
|
|
70
|
+
});
|
|
71
|
+
this._form = _form;
|
|
72
|
+
this._send = _send;
|
|
73
|
+
this._trans = _trans;
|
|
74
|
+
this._sending = false;
|
|
75
|
+
}
|
|
76
|
+
resolve(index) {
|
|
77
|
+
if (index === 0) {
|
|
78
|
+
super.resolve(index);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
void this._deliver();
|
|
82
|
+
}
|
|
83
|
+
async _deliver() {
|
|
84
|
+
const url = this._form.url;
|
|
85
|
+
if (this._sending) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!url) {
|
|
89
|
+
this._form.report('error', this._trans.__('Paste the link first.'));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this._sending = true;
|
|
93
|
+
this._form.report('pending', this._trans.__('Sending the link...'));
|
|
94
|
+
const outcome = await this._send(url);
|
|
95
|
+
this._sending = false;
|
|
96
|
+
if (!this.isDisposed) {
|
|
97
|
+
this._form.report(outcome.level, outcome.message);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Register the command that sends a pasted login callback link to the login
|
|
103
|
+
* waiting on the Jupyter server.
|
|
104
|
+
*
|
|
105
|
+
* A CLI login waits for its OAuth redirect on the server's own loopback, but
|
|
106
|
+
* the browser that follows the redirect runs on the user's machine, where that
|
|
107
|
+
* address is another host - so the page fails to load, with the link left in
|
|
108
|
+
* the address bar. Pasted here, the server makes the request instead.
|
|
109
|
+
*/
|
|
110
|
+
export function addCallbackCommand(app, palette, category, trans) {
|
|
111
|
+
app.commands.addCommand(CALLBACK_COMMAND, {
|
|
112
|
+
label: trans.__('Open Callback'),
|
|
113
|
+
caption: trans.__('Send a pasted login callback link to the login waiting on the Jupyter server'),
|
|
114
|
+
describedBy: {
|
|
115
|
+
args: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
url: {
|
|
119
|
+
type: 'string',
|
|
120
|
+
description: 'The callback link. Asked for in a popup when absent.'
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
execute: async (args) => {
|
|
126
|
+
const send = (url) => deliver(url, app, trans);
|
|
127
|
+
const given = typeof args.url === 'string' ? args.url.trim() : '';
|
|
128
|
+
if (given) {
|
|
129
|
+
// No popup to write into, so the result is a toast.
|
|
130
|
+
const outcome = await send(given);
|
|
131
|
+
Notification[outcome.level](outcome.message, {
|
|
132
|
+
autoClose: outcome.level === 'success' ? 4000 : 8000
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
await new CallbackDialog(new CallbackForm(trans), send, trans).launch();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
palette === null || palette === void 0 ? void 0 : palette.addItem({ command: CALLBACK_COMMAND, category });
|
|
140
|
+
}
|
package/lib/core/icons.js
CHANGED
|
@@ -103,15 +103,16 @@ export const filterIcon = new LabIcon({
|
|
|
103
103
|
name: `${ICON_PREFIX}:filter`,
|
|
104
104
|
svgstr: filterSvgStr
|
|
105
105
|
});
|
|
106
|
-
// Row status mark -
|
|
107
|
-
//
|
|
108
|
-
// a CSS `fill` on the circle
|
|
109
|
-
//
|
|
110
|
-
// attribute: the stylesheet is the
|
|
111
|
-
//
|
|
112
|
-
// (DEF-PANE-179).
|
|
106
|
+
// Row status mark - a light, drawn for a conversation that is live or under
|
|
107
|
+
// remote control: a 6px circle over the 8px disc that casts its halo. An SVG
|
|
108
|
+
// rather than a rounded span so each colour is a CSS `fill` on the circle
|
|
109
|
+
// itself, the way every other glyph here is recoloured through its class
|
|
110
|
+
// (DEF-PANE-174). The circles carry no `fill` attribute: the stylesheet is the
|
|
111
|
+
// single source of those colours, and a presentation attribute here would be
|
|
112
|
+
// a second one to keep in step (DEF-PANE-179).
|
|
113
113
|
const statusDotSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
|
114
|
-
<circle class="jp-AiAssistantsPanel-
|
|
114
|
+
<circle class="jp-AiAssistantsPanel-dotHalo" cx="8" cy="8" r="8"/>
|
|
115
|
+
<circle class="jp-AiAssistantsPanel-dotGlyph" cx="8" cy="8" r="6"/>
|
|
115
116
|
</svg>`;
|
|
116
117
|
export const statusDotIcon = new LabIcon({
|
|
117
118
|
name: `${ICON_PREFIX}:status-dot`,
|
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.
|
|
3
|
+
"version": "1.2.50",
|
|
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,258 @@
|
|
|
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 { 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
|
+
/** One macrotask: `Dialog.launch` attaches the popup after its own awaits. */
|
|
32
|
+
const settle = (): Promise<void> =>
|
|
33
|
+
new Promise(resolve => setTimeout(resolve, 0));
|
|
34
|
+
|
|
35
|
+
function setup(): {
|
|
36
|
+
commands: CommandRegistry;
|
|
37
|
+
added: { command: string; category: string }[];
|
|
38
|
+
serverSettings: object;
|
|
39
|
+
} {
|
|
40
|
+
const commands = new CommandRegistry();
|
|
41
|
+
const serverSettings = {};
|
|
42
|
+
const added: { command: string; category: string }[] = [];
|
|
43
|
+
addCallbackCommand(
|
|
44
|
+
{ commands, serviceManager: { serverSettings } } as any,
|
|
45
|
+
{ addItem: (item: any) => added.push(item) } as any,
|
|
46
|
+
'AI Assistants',
|
|
47
|
+
nullTranslator.load('test')
|
|
48
|
+
);
|
|
49
|
+
return { commands, added, serverSettings };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface IPopup {
|
|
53
|
+
/** Settles when the popup has closed. */
|
|
54
|
+
closed: Promise<unknown>;
|
|
55
|
+
input: HTMLInputElement;
|
|
56
|
+
status: HTMLElement;
|
|
57
|
+
ok: HTMLElement;
|
|
58
|
+
close: HTMLElement;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function openPopup(commands: CommandRegistry): Promise<IPopup> {
|
|
62
|
+
const closed = commands.execute(CALLBACK_COMMAND);
|
|
63
|
+
await settle();
|
|
64
|
+
const node = document.querySelector('.jp-Dialog') as HTMLElement;
|
|
65
|
+
return {
|
|
66
|
+
closed,
|
|
67
|
+
input: node.querySelector(
|
|
68
|
+
'.jp-AiAssistantsCallback input'
|
|
69
|
+
) as HTMLInputElement,
|
|
70
|
+
status: node.querySelector(
|
|
71
|
+
'.jp-AiAssistantsCallback-status'
|
|
72
|
+
) as HTMLElement,
|
|
73
|
+
ok: node.querySelector('.jp-mod-accept') as HTMLElement,
|
|
74
|
+
close: node.querySelector('.jp-mod-reject') as HTMLElement
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** No status code, no server error code and never the link, which carries a
|
|
79
|
+
* login code. */
|
|
80
|
+
function expectPlainWords(text: string): void {
|
|
81
|
+
expect(text).not.toMatch(
|
|
82
|
+
/callback_|HTTP|\b[45]\d\d\b|Internal Server Error|one-time/
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('the Open Callback command', () => {
|
|
87
|
+
let success: jest.SpyInstance;
|
|
88
|
+
let error: jest.SpyInstance;
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
request.mockReset();
|
|
92
|
+
success = jest.spyOn(Notification, 'success').mockReturnValue('' as any);
|
|
93
|
+
error = jest.spyOn(Notification, 'error').mockReturnValue('' as any);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(async () => {
|
|
97
|
+
// A popup left open would hold every later one in JupyterLab's queue.
|
|
98
|
+
(
|
|
99
|
+
document.querySelector('.jp-Dialog .jp-mod-reject') as HTMLElement
|
|
100
|
+
)?.click();
|
|
101
|
+
await settle();
|
|
102
|
+
jest.restoreAllMocks();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('is on the command palette under the assistants category', () => {
|
|
106
|
+
const { commands, added } = setup();
|
|
107
|
+
expect(commands.hasCommand(CALLBACK_COMMAND)).toBe(true);
|
|
108
|
+
expect(commands.label(CALLBACK_COMMAND)).toBe('Open Callback');
|
|
109
|
+
expect(added).toEqual([
|
|
110
|
+
{ command: CALLBACK_COMMAND, category: 'AI Assistants' }
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('registers without a palette', () => {
|
|
115
|
+
const commands = new CommandRegistry();
|
|
116
|
+
addCallbackCommand(
|
|
117
|
+
{ commands, serviceManager: { serverSettings: {} } } as any,
|
|
118
|
+
null,
|
|
119
|
+
'AI Assistants',
|
|
120
|
+
nullTranslator.load('test')
|
|
121
|
+
);
|
|
122
|
+
expect(commands.hasCommand(CALLBACK_COMMAND)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('OK sends the trimmed link and writes the result under the field, window still open', async () => {
|
|
126
|
+
const { commands, serverSettings } = setup();
|
|
127
|
+
request.mockResolvedValue({ status: 302 });
|
|
128
|
+
const popup = await openPopup(commands);
|
|
129
|
+
|
|
130
|
+
popup.input.value = ` ${LINK}\n`;
|
|
131
|
+
popup.ok.click();
|
|
132
|
+
await settle();
|
|
133
|
+
|
|
134
|
+
expect(request).toHaveBeenCalledWith('callback', serverSettings, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
body: JSON.stringify({ url: LINK })
|
|
137
|
+
});
|
|
138
|
+
expect(popup.status.dataset.level).toBe('success');
|
|
139
|
+
expect(popup.status.textContent).toContain('The login accepted the link');
|
|
140
|
+
expectPlainWords(popup.status.textContent ?? '');
|
|
141
|
+
// Read in the popup, not in a toast, and the popup is still there.
|
|
142
|
+
expect(success).not.toHaveBeenCalled();
|
|
143
|
+
expect(document.querySelector('.jp-Dialog')).not.toBeNull();
|
|
144
|
+
|
|
145
|
+
popup.close.click();
|
|
146
|
+
await popup.closed;
|
|
147
|
+
expect(document.querySelector('.jp-Dialog')).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('Enter in the field sends, as OK does', async () => {
|
|
151
|
+
const { commands } = setup();
|
|
152
|
+
request.mockResolvedValue({ status: 200 });
|
|
153
|
+
const popup = await openPopup(commands);
|
|
154
|
+
|
|
155
|
+
popup.input.value = LINK;
|
|
156
|
+
popup.input.dispatchEvent(
|
|
157
|
+
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
|
158
|
+
);
|
|
159
|
+
await settle();
|
|
160
|
+
|
|
161
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(popup.status.dataset.level).toBe('success');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('a refused link is replaced and sent again in the same window', async () => {
|
|
166
|
+
const { commands } = setup();
|
|
167
|
+
request
|
|
168
|
+
.mockRejectedValueOnce(new Error('callback_unreachable'))
|
|
169
|
+
.mockResolvedValueOnce({ status: 302 });
|
|
170
|
+
const popup = await openPopup(commands);
|
|
171
|
+
|
|
172
|
+
popup.input.value = LINK;
|
|
173
|
+
popup.ok.click();
|
|
174
|
+
await settle();
|
|
175
|
+
expect(popup.status.dataset.level).toBe('error');
|
|
176
|
+
|
|
177
|
+
popup.input.value = LINK.replace('one-time', 'second');
|
|
178
|
+
popup.ok.click();
|
|
179
|
+
await settle();
|
|
180
|
+
expect(request).toHaveBeenCalledTimes(2);
|
|
181
|
+
expect(popup.status.dataset.level).toBe('success');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('OK on an empty field sends nothing and says so', async () => {
|
|
185
|
+
const { commands } = setup();
|
|
186
|
+
const popup = await openPopup(commands);
|
|
187
|
+
|
|
188
|
+
popup.input.value = ' ';
|
|
189
|
+
popup.ok.click();
|
|
190
|
+
await settle();
|
|
191
|
+
|
|
192
|
+
expect(request).not.toHaveBeenCalled();
|
|
193
|
+
expect(popup.status.textContent).toBe('Paste the link first.');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('Close sends nothing', async () => {
|
|
197
|
+
const { commands } = setup();
|
|
198
|
+
const popup = await openPopup(commands);
|
|
199
|
+
|
|
200
|
+
popup.input.value = LINK;
|
|
201
|
+
popup.close.click();
|
|
202
|
+
await popup.closed;
|
|
203
|
+
|
|
204
|
+
expect(request).not.toHaveBeenCalled();
|
|
205
|
+
expect(document.querySelector('.jp-Dialog')).toBeNull();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it.each([
|
|
209
|
+
[
|
|
210
|
+
'the login answers with an error',
|
|
211
|
+
() => Promise.resolve({ status: 400 }),
|
|
212
|
+
'warning',
|
|
213
|
+
'The login did not accept this link'
|
|
214
|
+
],
|
|
215
|
+
[
|
|
216
|
+
'the link is not a loopback link',
|
|
217
|
+
() => Promise.reject(new Error('callback_not_loopback')),
|
|
218
|
+
'error',
|
|
219
|
+
'It must start with http://127.0.0.1 or http://localhost'
|
|
220
|
+
],
|
|
221
|
+
[
|
|
222
|
+
'no login is listening',
|
|
223
|
+
() => Promise.reject(new Error('callback_unreachable')),
|
|
224
|
+
'error',
|
|
225
|
+
'No login is waiting for this link'
|
|
226
|
+
],
|
|
227
|
+
[
|
|
228
|
+
'the server fails',
|
|
229
|
+
() => Promise.reject(new Error('Internal Server Error')),
|
|
230
|
+
'error',
|
|
231
|
+
'The link could not be sent to the Jupyter server'
|
|
232
|
+
]
|
|
233
|
+
])('says in plain words that %s', async (_case, answer, level, expected) => {
|
|
234
|
+
const { commands } = setup();
|
|
235
|
+
request.mockImplementation(answer);
|
|
236
|
+
const popup = await openPopup(commands);
|
|
237
|
+
|
|
238
|
+
popup.input.value = LINK;
|
|
239
|
+
popup.ok.click();
|
|
240
|
+
await settle();
|
|
241
|
+
|
|
242
|
+
expect(popup.status.dataset.level).toBe(level);
|
|
243
|
+
expect(popup.status.textContent).toContain(expected);
|
|
244
|
+
expectPlainWords(popup.status.textContent ?? '');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('given the link as an argument, opens no popup and reports in a toast', async () => {
|
|
248
|
+
const { commands } = setup();
|
|
249
|
+
request.mockRejectedValue(new Error('callback_unreachable'));
|
|
250
|
+
|
|
251
|
+
await commands.execute(CALLBACK_COMMAND, { url: LINK });
|
|
252
|
+
|
|
253
|
+
expect(document.querySelector('.jp-Dialog')).toBeNull();
|
|
254
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
255
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
256
|
+
expectPlainWords(String(error.mock.calls[0][0]));
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -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,189 @@
|
|
|
1
|
+
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
2
|
+
import { Dialog, ICommandPalette, Notification } from '@jupyterlab/apputils';
|
|
3
|
+
import { TranslationBundle } from '@jupyterlab/translation';
|
|
4
|
+
import { Widget } from '@lumino/widgets';
|
|
5
|
+
|
|
6
|
+
import { requestAPI } from './request';
|
|
7
|
+
|
|
8
|
+
/** The command that delivers a pasted login callback link. */
|
|
9
|
+
export const CALLBACK_COMMAND = 'open:callback';
|
|
10
|
+
|
|
11
|
+
/** What a delivery came to, in words for the user. Never the link, which
|
|
12
|
+
* carries a login code, and never a status code. */
|
|
13
|
+
interface IOutcome {
|
|
14
|
+
level: 'success' | 'warning' | 'error';
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function deliver(
|
|
19
|
+
url: string,
|
|
20
|
+
app: JupyterFrontEnd,
|
|
21
|
+
trans: TranslationBundle
|
|
22
|
+
): Promise<IOutcome> {
|
|
23
|
+
try {
|
|
24
|
+
const answer = await requestAPI<{ status: number }>(
|
|
25
|
+
'callback',
|
|
26
|
+
app.serviceManager.serverSettings,
|
|
27
|
+
{ method: 'POST', body: JSON.stringify({ url }) }
|
|
28
|
+
);
|
|
29
|
+
return answer.status < 400
|
|
30
|
+
? {
|
|
31
|
+
level: 'success',
|
|
32
|
+
message: trans.__(
|
|
33
|
+
'The login accepted the link. Check the terminal where the login is running.'
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
: {
|
|
37
|
+
level: 'warning',
|
|
38
|
+
message: trans.__(
|
|
39
|
+
'The login did not accept this link. Start the login again and paste the new link.'
|
|
40
|
+
)
|
|
41
|
+
};
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const code = err instanceof Error ? err.message : '';
|
|
44
|
+
return {
|
|
45
|
+
level: 'error',
|
|
46
|
+
message:
|
|
47
|
+
code === 'callback_not_loopback'
|
|
48
|
+
? trans.__(
|
|
49
|
+
'This link cannot be used. It must start with http://127.0.0.1 or http://localhost.'
|
|
50
|
+
)
|
|
51
|
+
: code === 'callback_unreachable'
|
|
52
|
+
? trans.__(
|
|
53
|
+
'No login is waiting for this link. Start the login again and paste the new link.'
|
|
54
|
+
)
|
|
55
|
+
: trans.__(
|
|
56
|
+
'The link could not be sent to the Jupyter server. Try again.'
|
|
57
|
+
)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The popup's body: the link field and, below it, what the last send came
|
|
63
|
+
* to. */
|
|
64
|
+
class CallbackForm extends Widget {
|
|
65
|
+
constructor(trans: TranslationBundle) {
|
|
66
|
+
super();
|
|
67
|
+
this.addClass('jp-AiAssistantsCallback');
|
|
68
|
+
const label = document.createElement('label');
|
|
69
|
+
label.textContent = trans.__('Callback link from the browser address bar');
|
|
70
|
+
this._input = document.createElement('input');
|
|
71
|
+
this._input.type = 'text';
|
|
72
|
+
this._input.className = 'jp-mod-styled';
|
|
73
|
+
this._input.placeholder = 'http://127.0.0.1:1455/auth/callback?code=...';
|
|
74
|
+
label.appendChild(this._input);
|
|
75
|
+
this._status = document.createElement('div');
|
|
76
|
+
this._status.className = 'jp-AiAssistantsCallback-status';
|
|
77
|
+
this._status.setAttribute('role', 'status');
|
|
78
|
+
this.node.append(label, this._status);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get url(): string {
|
|
82
|
+
return this._input.value.trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
report(level: IOutcome['level'] | 'pending', message: string): void {
|
|
86
|
+
this._status.dataset.level = level;
|
|
87
|
+
this._status.textContent = message;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private _input: HTMLInputElement;
|
|
91
|
+
private _status: HTMLDivElement;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** OK sends the link and leaves the window open, so the result is read - and
|
|
95
|
+
* a refused link replaced - in the same place. Close and Escape end it. */
|
|
96
|
+
class CallbackDialog extends Dialog<void> {
|
|
97
|
+
constructor(
|
|
98
|
+
private _form: CallbackForm,
|
|
99
|
+
private _send: (url: string) => Promise<IOutcome>,
|
|
100
|
+
private _trans: TranslationBundle
|
|
101
|
+
) {
|
|
102
|
+
super({
|
|
103
|
+
title: _trans.__('Open Callback'),
|
|
104
|
+
body: _form,
|
|
105
|
+
buttons: [
|
|
106
|
+
Dialog.cancelButton({ label: _trans.__('Close') }),
|
|
107
|
+
Dialog.okButton({ label: _trans.__('OK') })
|
|
108
|
+
],
|
|
109
|
+
defaultButton: 1,
|
|
110
|
+
focusNodeSelector: 'input'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
resolve(index?: number): void {
|
|
115
|
+
if (index === 0) {
|
|
116
|
+
super.resolve(index);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
void this._deliver();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async _deliver(): Promise<void> {
|
|
123
|
+
const url = this._form.url;
|
|
124
|
+
if (this._sending) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!url) {
|
|
128
|
+
this._form.report('error', this._trans.__('Paste the link first.'));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
this._sending = true;
|
|
132
|
+
this._form.report('pending', this._trans.__('Sending the link...'));
|
|
133
|
+
const outcome = await this._send(url);
|
|
134
|
+
this._sending = false;
|
|
135
|
+
if (!this.isDisposed) {
|
|
136
|
+
this._form.report(outcome.level, outcome.message);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private _sending = false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Register the command that sends a pasted login callback link to the login
|
|
145
|
+
* waiting on the Jupyter server.
|
|
146
|
+
*
|
|
147
|
+
* A CLI login waits for its OAuth redirect on the server's own loopback, but
|
|
148
|
+
* the browser that follows the redirect runs on the user's machine, where that
|
|
149
|
+
* address is another host - so the page fails to load, with the link left in
|
|
150
|
+
* the address bar. Pasted here, the server makes the request instead.
|
|
151
|
+
*/
|
|
152
|
+
export function addCallbackCommand(
|
|
153
|
+
app: JupyterFrontEnd,
|
|
154
|
+
palette: ICommandPalette | null,
|
|
155
|
+
category: string,
|
|
156
|
+
trans: TranslationBundle
|
|
157
|
+
): void {
|
|
158
|
+
app.commands.addCommand(CALLBACK_COMMAND, {
|
|
159
|
+
label: trans.__('Open Callback'),
|
|
160
|
+
caption: trans.__(
|
|
161
|
+
'Send a pasted login callback link to the login waiting on the Jupyter server'
|
|
162
|
+
),
|
|
163
|
+
describedBy: {
|
|
164
|
+
args: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
url: {
|
|
168
|
+
type: 'string',
|
|
169
|
+
description: 'The callback link. Asked for in a popup when absent.'
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
execute: async args => {
|
|
175
|
+
const send = (url: string): Promise<IOutcome> => deliver(url, app, trans);
|
|
176
|
+
const given = typeof args.url === 'string' ? args.url.trim() : '';
|
|
177
|
+
if (given) {
|
|
178
|
+
// No popup to write into, so the result is a toast.
|
|
179
|
+
const outcome = await send(given);
|
|
180
|
+
Notification[outcome.level](outcome.message, {
|
|
181
|
+
autoClose: outcome.level === 'success' ? 4000 : 8000
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
await new CallbackDialog(new CallbackForm(trans), send, trans).launch();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
palette?.addItem({ command: CALLBACK_COMMAND, category });
|
|
189
|
+
}
|
package/src/core/icons.ts
CHANGED
|
@@ -126,15 +126,16 @@ export const filterIcon = new LabIcon({
|
|
|
126
126
|
svgstr: filterSvgStr
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
// Row status mark -
|
|
130
|
-
//
|
|
131
|
-
// a CSS `fill` on the circle
|
|
132
|
-
//
|
|
133
|
-
// attribute: the stylesheet is the
|
|
134
|
-
//
|
|
135
|
-
// (DEF-PANE-179).
|
|
129
|
+
// Row status mark - a light, drawn for a conversation that is live or under
|
|
130
|
+
// remote control: a 6px circle over the 8px disc that casts its halo. An SVG
|
|
131
|
+
// rather than a rounded span so each colour is a CSS `fill` on the circle
|
|
132
|
+
// itself, the way every other glyph here is recoloured through its class
|
|
133
|
+
// (DEF-PANE-174). The circles carry no `fill` attribute: the stylesheet is the
|
|
134
|
+
// single source of those colours, and a presentation attribute here would be
|
|
135
|
+
// a second one to keep in step (DEF-PANE-179).
|
|
136
136
|
const statusDotSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
|
137
|
-
<circle class="jp-AiAssistantsPanel-
|
|
137
|
+
<circle class="jp-AiAssistantsPanel-dotHalo" cx="8" cy="8" r="8"/>
|
|
138
|
+
<circle class="jp-AiAssistantsPanel-dotGlyph" cx="8" cy="8" r="6"/>
|
|
138
139
|
</svg>`;
|
|
139
140
|
|
|
140
141
|
export const statusDotIcon = new LabIcon({
|
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>();
|
package/style/base.css
CHANGED
|
@@ -394,6 +394,15 @@
|
|
|
394
394
|
fill: var(--jp-success-color1);
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
+
/* The 8px disc behind the 6px light. It is what casts the halo, so the halo
|
|
398
|
+
keeps the size it had when the light itself was 8px; fully opaque for that
|
|
399
|
+
reason, since a shadow is cast by alpha. The 1px of it that shows around the
|
|
400
|
+
light is painted as the halo looks at that point - the glow colour, dimmed -
|
|
401
|
+
so it reads as the halo's inner edge and not as a second dot. */
|
|
402
|
+
.jp-AiAssistantsPanel-dotHalo {
|
|
403
|
+
fill: color-mix(in srgb, var(--jp-success-color1) 65%, black);
|
|
404
|
+
}
|
|
405
|
+
|
|
397
406
|
/* Light themes take a different dot, because both halves of the one above stop
|
|
398
407
|
working there. JupyterLab stamps the theme's own `isLight` onto the body, so
|
|
399
408
|
the switch is CSS and no TypeScript sees it.
|
|
@@ -426,6 +435,12 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotGlyph {
|
|
|
426
435
|
fill: var(--md-green-500);
|
|
427
436
|
}
|
|
428
437
|
|
|
438
|
+
/* The mint ring's own colour: three passes saturate the ring at the disc's
|
|
439
|
+
edge, so undimmed mint is what the halo looks like there. */
|
|
440
|
+
body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
441
|
+
fill: var(--md-green-200);
|
|
442
|
+
}
|
|
443
|
+
|
|
429
444
|
.jp-AiAssistantsPanel-dotPlaceholder {
|
|
430
445
|
background-color: transparent;
|
|
431
446
|
}
|
|
@@ -1001,3 +1016,40 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotGlyph {
|
|
|
1001
1016
|
.jp-AiAssistantsPanel-row.jp-mod-stale .jp-AiAssistantsPanel-name {
|
|
1002
1017
|
opacity: 0.65;
|
|
1003
1018
|
}
|
|
1019
|
+
|
|
1020
|
+
/* The Open Callback popup. A callback link runs to about 250 characters, so
|
|
1021
|
+
the field takes the width a normal window can give it. */
|
|
1022
|
+
.jp-AiAssistantsCallback {
|
|
1023
|
+
width: min(640px, 80vw);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
.jp-AiAssistantsCallback label {
|
|
1027
|
+
display: flex;
|
|
1028
|
+
flex-direction: column;
|
|
1029
|
+
gap: 4px;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
.jp-AiAssistantsCallback input {
|
|
1033
|
+
width: 100%;
|
|
1034
|
+
box-sizing: border-box;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/* What the last send came to. The height is reserved, so the buttons do not
|
|
1038
|
+
move when the text arrives. */
|
|
1039
|
+
.jp-AiAssistantsCallback-status {
|
|
1040
|
+
min-height: 1.5em;
|
|
1041
|
+
margin-top: 8px;
|
|
1042
|
+
color: var(--jp-ui-font-color2);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
.jp-AiAssistantsCallback-status[data-level='success'] {
|
|
1046
|
+
color: var(--jp-success-color1);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
.jp-AiAssistantsCallback-status[data-level='warning'] {
|
|
1050
|
+
color: var(--jp-warn-color1);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
.jp-AiAssistantsCallback-status[data-level='error'] {
|
|
1054
|
+
color: var(--jp-error-color1);
|
|
1055
|
+
}
|