jupyterlab_ai_code_assistants_extension 1.0.41 → 1.2.1
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/icons.d.ts +14 -0
- package/lib/core/icons.js +29 -4
- package/lib/core/panel.d.ts +21 -2
- package/lib/core/panel.js +119 -4
- package/lib/core/types.d.ts +8 -0
- package/lib/index.js +42 -4
- package/package.json +6 -2
- package/src/__tests__/core-neutrality.spec.ts +12 -3
- package/src/__tests__/index.spec.ts +171 -1
- package/src/__tests__/launcher-icon.spec.ts +77 -0
- package/src/__tests__/launcher.spec.ts +537 -0
- package/src/core/icons.ts +36 -4
- package/src/core/panel.ts +141 -4
- package/src/core/types.ts +9 -0
- package/src/index.ts +50 -4
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
|
|
|
30
30
|
- **Joint settings page** - one settings section covering all assistants, with a per-assistant toggle (all on by default); toggling takes effect live, no JupyterLab reload
|
|
31
31
|
- **Three-section side panel** - Favorites, Recent, and All projects, each scrolling independently
|
|
32
32
|
- **One-click resume** - click a row to jump back into that session in a terminal; an open terminal for the project is reused instead of duplicated
|
|
33
|
+
- **Launcher tiles** - every enabled assistant has a tile in an "AI Assistants" section of the JupyterLab Launcher (after Other, under the extension's own robot-head icon); a click opens the assistant in the file browser's current folder, resuming the folder's conversation when one exists and starting a new one otherwise, through the companion `jupyterlab_basic_terminal_extension` (installed automatically). Disable an assistant and its tile goes; disable them all and the section goes with them
|
|
33
34
|
- **Your terminal's `claude -c` follows the panel** (Claude) - switching to or launching a conversation makes plain `claude -c` in that project resume that same conversation, including one that has been compacted, which Claude Code otherwise refuses to continue
|
|
34
35
|
- **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short id with last-activity time; "Manage Sessions..." opens a searchable popup over the full list with multi-select delete and per-row open and copy-id buttons
|
|
35
36
|
- **Branch session** - fork the current conversation into a new named session via the right-click menu; each assistant forks its own way (Claude's native `--fork-session`, Codex's `codex fork`, server-side copies for Kimi and Gemini) behind the same menu item
|
package/lib/core/icons.d.ts
CHANGED
|
@@ -11,3 +11,17 @@ export declare const switchIcon: LabIcon;
|
|
|
11
11
|
export declare const filterIcon: LabIcon;
|
|
12
12
|
/** Build (or reuse) the LabIcon for a provider's own glyph. */
|
|
13
13
|
export declare function providerIcon(name: string, svgstr: string): LabIcon;
|
|
14
|
+
/**
|
|
15
|
+
* A provider's tile icon as the Launcher should draw it.
|
|
16
|
+
*
|
|
17
|
+
* The Launcher has no section icon of its own: a category's header is drawn
|
|
18
|
+
* with the FIRST tile's command icon under the `launcherSection` stylesheet
|
|
19
|
+
* preset, and each tile under `launcherCard`. This view of `tile` renders the
|
|
20
|
+
* extension's joint icon under the section preset and `tile` everywhere else,
|
|
21
|
+
* so the header carries no vendor's mark while every tile keeps its own
|
|
22
|
+
* (ACC-LNCH-163).
|
|
23
|
+
*
|
|
24
|
+
* Built the way `LabIcon.bindprops` builds its views - a prototype child of
|
|
25
|
+
* the tile with its own `react` - so `LabIcon.resolve` still recognises it.
|
|
26
|
+
*/
|
|
27
|
+
export declare function launcherTileIcon(tile: LabIcon): LabIcon;
|
package/lib/core/icons.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LabIcon } from '@jupyterlab/ui-components';
|
|
2
|
+
import * as React from 'react';
|
|
2
3
|
// Shared panel chrome icons. Provider icons are NOT here - each provider
|
|
3
4
|
// carries its own SVG on its descriptor and the registry builds the LabIcon
|
|
4
5
|
// from it, so adding an assistant touches no core file.
|
|
@@ -6,11 +7,13 @@ import { LabIcon } from '@jupyterlab/ui-components';
|
|
|
6
7
|
// Material-style glyphs matched to the rest of the sidebar so header and menu
|
|
7
8
|
// icons render at identical size and theme.
|
|
8
9
|
const ICON_PREFIX = 'jupyterlab_ai_code_assistants_extension';
|
|
9
|
-
// The extension's own identity, used by the settings entry
|
|
10
|
-
// all assistants, so it cannot borrow any one
|
|
11
|
-
//
|
|
10
|
+
// The extension's own identity, used by the settings entry and the Launcher
|
|
11
|
+
// section header - one section for all assistants, so it cannot borrow any one
|
|
12
|
+
// assistant's glyph. A robot head in the Material filled style: antenna, side
|
|
13
|
+
// bolts, eye and mouth cut-outs. One evenodd path, so the cut-outs are holes
|
|
14
|
+
// and the theme's `.jp-icon3[fill]` rule recolours the whole glyph at once.
|
|
12
15
|
const assistantsSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
|
|
13
|
-
<path class="jp-icon3" fill="#616161" d="
|
|
16
|
+
<path class="jp-icon3" fill="#616161" fill-rule="evenodd" d="M12 1.5a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2ZM11.2 4.7h1.6V7h-1.6ZM7 7h10a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-8a3 3 0 0 1 3-3ZM2 11h2v5H2ZM20 11h2v5h-2ZM9 10.2a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6ZM15 10.2a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6ZM8.6 15.6h6.8a.9.9 0 0 1 0 1.8H8.6a.9.9 0 0 1 0-1.8Z"/>
|
|
14
17
|
</svg>`;
|
|
15
18
|
export const assistantsIcon = new LabIcon({
|
|
16
19
|
name: `${ICON_PREFIX}:assistants`,
|
|
@@ -114,3 +117,25 @@ export function providerIcon(name, svgstr) {
|
|
|
114
117
|
}
|
|
115
118
|
return icon;
|
|
116
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* A provider's tile icon as the Launcher should draw it.
|
|
122
|
+
*
|
|
123
|
+
* The Launcher has no section icon of its own: a category's header is drawn
|
|
124
|
+
* with the FIRST tile's command icon under the `launcherSection` stylesheet
|
|
125
|
+
* preset, and each tile under `launcherCard`. This view of `tile` renders the
|
|
126
|
+
* extension's joint icon under the section preset and `tile` everywhere else,
|
|
127
|
+
* so the header carries no vendor's mark while every tile keeps its own
|
|
128
|
+
* (ACC-LNCH-163).
|
|
129
|
+
*
|
|
130
|
+
* Built the way `LabIcon.bindprops` builds its views - a prototype child of
|
|
131
|
+
* the tile with its own `react` - so `LabIcon.resolve` still recognises it.
|
|
132
|
+
*/
|
|
133
|
+
export function launcherTileIcon(tile) {
|
|
134
|
+
const view = Object.create(tile);
|
|
135
|
+
const react = React.forwardRef((props, ref) => React.createElement(props.stylesheet === 'launcherSection'
|
|
136
|
+
? assistantsIcon.react
|
|
137
|
+
: tile.react, { ...props, ref }));
|
|
138
|
+
// `react` is readonly on the type; the view shadows the tile's own.
|
|
139
|
+
Object.defineProperty(view, 'react', { value: react });
|
|
140
|
+
return view;
|
|
141
|
+
}
|
package/lib/core/panel.d.ts
CHANGED
|
@@ -97,9 +97,28 @@ export declare class AssistantSessionsPanel extends Widget {
|
|
|
97
97
|
* the + button then did nothing at all, and Open Terminal and Show in File
|
|
98
98
|
* Browser answered "outside the JupyterLab root" for every row inside it. */
|
|
99
99
|
setRoot(rootDir: string, deleteToTrash: boolean): void;
|
|
100
|
-
/** Absolute path of
|
|
101
|
-
*
|
|
100
|
+
/** Absolute path of a folder given as a Contents-API path, defaulting to the
|
|
101
|
+
* file browser's current folder; the server root when neither is available.
|
|
102
|
+
*
|
|
103
|
+
* `path` is passed by the Launcher tile, which carries the folder its click
|
|
104
|
+
* happened in rather than whatever the browser shows now. An empty string is
|
|
105
|
+
* the root, so the default applies only when nothing at all was given. */
|
|
102
106
|
private _currentFolder;
|
|
107
|
+
/**
|
|
108
|
+
* Start or resume this assistant in `cwd`, and answer with the terminal
|
|
109
|
+
* widget the sibling extension opened - the Launcher replaces its own tab
|
|
110
|
+
* with whatever the command resolves to (ACC-LNCH-157).
|
|
111
|
+
*
|
|
112
|
+
* The tile's whole click path lives here rather than in `src/index.ts`
|
|
113
|
+
* because every input it needs is already the panel's: the root join, the
|
|
114
|
+
* listing fetch, the terminal reuse ladder and the settings-resolved launch
|
|
115
|
+
* mode. Nothing here retries and nothing is scheduled: a guard that refuses
|
|
116
|
+
* shows one notification and launches nothing, and a request that fails
|
|
117
|
+
* writes nothing.
|
|
118
|
+
*
|
|
119
|
+
* `cwd` is a Contents-API path, `''` for the root.
|
|
120
|
+
*/
|
|
121
|
+
launchHere(cwd?: string): Promise<any>;
|
|
103
122
|
/** Start a brand-new conversation in the file browser's current folder. */
|
|
104
123
|
private _newSession;
|
|
105
124
|
/**
|
package/lib/core/panel.js
CHANGED
|
@@ -36,6 +36,11 @@ const BRANCH_WATCH_INTERVAL_MS = 2000;
|
|
|
36
36
|
const BRANCH_WATCH_MAX_ATTEMPTS = 90; // ~3 minutes
|
|
37
37
|
/** Conversations listed inline in a branch submenu before the popup takes over. */
|
|
38
38
|
const INLINE_BRANCH_LIMIT = 5;
|
|
39
|
+
/** Command id of the sibling extension that spawns a shell-less terminal on a
|
|
40
|
+
* bare argv, with args `{argv, cwd}`. That string IS the coupling: the sibling
|
|
41
|
+
* exports no token, so its absence is something to ask the command registry
|
|
42
|
+
* about at click time rather than a build-time dependency (ACC-LNCH-155). */
|
|
43
|
+
const BASIC_TERMINAL_LAUNCH = 'basic-terminal:launch';
|
|
39
44
|
export const DEFAULT_PRESENTATION_MODE = 'name';
|
|
40
45
|
const SECTION_LABELS = {
|
|
41
46
|
favourites: 'Favorites',
|
|
@@ -751,21 +756,131 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
751
756
|
this._render();
|
|
752
757
|
}
|
|
753
758
|
}
|
|
754
|
-
/** Absolute path of
|
|
755
|
-
*
|
|
756
|
-
|
|
759
|
+
/** Absolute path of a folder given as a Contents-API path, defaulting to the
|
|
760
|
+
* file browser's current folder; the server root when neither is available.
|
|
761
|
+
*
|
|
762
|
+
* `path` is passed by the Launcher tile, which carries the folder its click
|
|
763
|
+
* happened in rather than whatever the browser shows now. An empty string is
|
|
764
|
+
* the root, so the default applies only when nothing at all was given. */
|
|
765
|
+
_currentFolder(path) {
|
|
757
766
|
var _a, _b, _c;
|
|
758
767
|
// No roster yet (DEF-132): a relative folder joined to an empty root
|
|
759
768
|
// would be an absolute path at the filesystem root.
|
|
760
769
|
if (!this._rootDir) {
|
|
761
770
|
return '';
|
|
762
771
|
}
|
|
763
|
-
const rel = ((_c = (_b = (_a = this._fileBrowser) === null || _a === void 0 ? void 0 : _a.model) === null || _b === void 0 ? void 0 : _b.path) !== null && _c !== void 0 ? _c : '').replace(/^\/+/, '');
|
|
772
|
+
const rel = ((_c = path !== null && path !== void 0 ? path : (_b = (_a = this._fileBrowser) === null || _a === void 0 ? void 0 : _a.model) === null || _b === void 0 ? void 0 : _b.path) !== null && _c !== void 0 ? _c : '').replace(/^\/+/, '');
|
|
764
773
|
if (!rel) {
|
|
765
774
|
return this._rootDir;
|
|
766
775
|
}
|
|
767
776
|
return this._rootDir === '/' ? `/${rel}` : `${this._rootDir}/${rel}`;
|
|
768
777
|
}
|
|
778
|
+
/**
|
|
779
|
+
* Start or resume this assistant in `cwd`, and answer with the terminal
|
|
780
|
+
* widget the sibling extension opened - the Launcher replaces its own tab
|
|
781
|
+
* with whatever the command resolves to (ACC-LNCH-157).
|
|
782
|
+
*
|
|
783
|
+
* The tile's whole click path lives here rather than in `src/index.ts`
|
|
784
|
+
* because every input it needs is already the panel's: the root join, the
|
|
785
|
+
* listing fetch, the terminal reuse ladder and the settings-resolved launch
|
|
786
|
+
* mode. Nothing here retries and nothing is scheduled: a guard that refuses
|
|
787
|
+
* shows one notification and launches nothing, and a request that fails
|
|
788
|
+
* writes nothing.
|
|
789
|
+
*
|
|
790
|
+
* `cwd` is a Contents-API path, `''` for the root.
|
|
791
|
+
*/
|
|
792
|
+
async launchHere(cwd) {
|
|
793
|
+
var _a, _b;
|
|
794
|
+
// No roster yet, so the root is unknown. Joining onto an empty one names a
|
|
795
|
+
// folder at the filesystem root, which the server accepts whenever it
|
|
796
|
+
// happens to exist (ACC-LNCH-148, DEF-132).
|
|
797
|
+
if (!this._rootDir) {
|
|
798
|
+
Notification.warning('Waiting for the server root - nothing was launched.', { autoClose: 4000 });
|
|
799
|
+
return undefined;
|
|
800
|
+
}
|
|
801
|
+
// A registered drive (jupyter-fs, S3) is not assumed to map onto the
|
|
802
|
+
// server filesystem, and its local path would resolve against the server's
|
|
803
|
+
// own root - an unrelated directory of the same name (ACC-LNCH-149).
|
|
804
|
+
if (typeof cwd === 'string' &&
|
|
805
|
+
this._app.serviceManager.contents.driveName(cwd) !== '') {
|
|
806
|
+
Notification.warning('The file browser is on a drive that does not map onto the Jupyter ' +
|
|
807
|
+
"server's filesystem, so nothing was launched.", { autoClose: 4000 });
|
|
808
|
+
return undefined;
|
|
809
|
+
}
|
|
810
|
+
if (!this._app.commands.hasCommand(BASIC_TERMINAL_LAUNCH)) {
|
|
811
|
+
Notification.warning('jupyterlab_basic_terminal_extension is not installed, and it owns ' +
|
|
812
|
+
`the \`${BASIC_TERMINAL_LAUNCH}\` command this launch runs on.`, { autoClose: 4000 });
|
|
813
|
+
return undefined;
|
|
814
|
+
}
|
|
815
|
+
const folder = this._currentFolder(cwd);
|
|
816
|
+
let argv;
|
|
817
|
+
try {
|
|
818
|
+
// Every click asks the server for the listing (ACC-LNCH-151). `_sessions`
|
|
819
|
+
// is written by `_fetch` and by the optimistic remove in
|
|
820
|
+
// `_removeProject`, and the poll that calls `_fetch` stops on
|
|
821
|
+
// `onBeforeHide` - so a hidden panel's cache is as old as the moment it
|
|
822
|
+
// was hidden, and reading it resumed a conversation that had since moved
|
|
823
|
+
// or minted a second one for a folder that already had a row.
|
|
824
|
+
await this._fetch();
|
|
825
|
+
const rows = (_a = this._sessions) !== null && _a !== void 0 ? _a : [];
|
|
826
|
+
// A row for this folder means the folder has a conversation, which is
|
|
827
|
+
// what makes this a resume rather than a new session (ACC-LNCH-151).
|
|
828
|
+
const row = (_b = rows.find(s => s.project_path === folder)) !== null && _b !== void 0 ? _b : null;
|
|
829
|
+
if (row) {
|
|
830
|
+
// A terminal already holding that conversation IS the answer: a second
|
|
831
|
+
// process on one history is never what the click meant (ACC-LNCH-152).
|
|
832
|
+
const found = await this._terminals.findForSession(row.session_id);
|
|
833
|
+
if (found) {
|
|
834
|
+
this._terminals.focus(found.widget);
|
|
835
|
+
return found.widget;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const request = {
|
|
839
|
+
project_path: folder,
|
|
840
|
+
encoded_path: row === null || row === void 0 ? void 0 : row.encoded_path,
|
|
841
|
+
session_id: row === null || row === void 0 ? void 0 : row.session_id,
|
|
842
|
+
// Minted here when the CLI takes an id, so the new conversation is
|
|
843
|
+
// identifiable from its argv on the first poll (ACC-LNCH-153).
|
|
844
|
+
new_session_id: row || !this._descriptor.mintsNewSessionId ? undefined : UUID.uuid4(),
|
|
845
|
+
// No forced mode - the same default the + button launches under
|
|
846
|
+
// (ACC-LNCH-147).
|
|
847
|
+
mode: this._launchMode()
|
|
848
|
+
};
|
|
849
|
+
const answer = await requestProvider(this._descriptor.id, 'launch-argv', this._serverSettings, { method: 'POST', body: JSON.stringify(request) });
|
|
850
|
+
argv = answer.argv;
|
|
851
|
+
}
|
|
852
|
+
catch (err) {
|
|
853
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
854
|
+
// The binary went away after the panel docked. Named, because the
|
|
855
|
+
// symptom otherwise reads as a broken tile.
|
|
856
|
+
if (isResponseStatus(err, 503) && message === 'cli_not_found') {
|
|
857
|
+
Notification.warning(`\`${this._descriptor.cliBinary}\` was not found on the Jupyter ` +
|
|
858
|
+
"server's PATH, so nothing was launched.", { autoClose: 4000 });
|
|
859
|
+
return undefined;
|
|
860
|
+
}
|
|
861
|
+
this._notifyLaunchError('Could not start a session in this folder', err);
|
|
862
|
+
return undefined;
|
|
863
|
+
}
|
|
864
|
+
// The folder is already resolved against the server root, and the sibling
|
|
865
|
+
// takes an absolute path (ACC-LNCH-154).
|
|
866
|
+
//
|
|
867
|
+
// Its own catch, because the cause is a different one: the block above
|
|
868
|
+
// refuses before anything was spawned, this one refuses after the argv was
|
|
869
|
+
// agreed. The Launcher shows a modal "Launcher Error" for any rejection
|
|
870
|
+
// that escapes the command, so the refusal is reported as one notification
|
|
871
|
+
// and the Launcher keeps its tab (ACC-LNCH-157 covers the success path
|
|
872
|
+
// only).
|
|
873
|
+
try {
|
|
874
|
+
return await this._app.commands.execute(BASIC_TERMINAL_LAUNCH, {
|
|
875
|
+
argv,
|
|
876
|
+
cwd: folder
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
this._notifyLaunchError('Could not open a terminal for this folder', err);
|
|
881
|
+
return undefined;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
769
884
|
/** Start a brand-new conversation in the file browser's current folder. */
|
|
770
885
|
async _newSession(force) {
|
|
771
886
|
var _a;
|
package/lib/core/types.d.ts
CHANGED
|
@@ -205,6 +205,14 @@ export interface ILaunchRequest {
|
|
|
205
205
|
export interface ILaunchResponse {
|
|
206
206
|
terminal_name: string;
|
|
207
207
|
}
|
|
208
|
+
/** `POST providers/<id>/launch-argv`: the command line a launch WOULD run,
|
|
209
|
+
* for a caller that owns the spawn itself. Same body as {@link ILaunchRequest}
|
|
210
|
+
* and the same server-side bookkeeping as `launch`; the difference is that
|
|
211
|
+
* nothing is spawned, so the argv can be handed to another extension's
|
|
212
|
+
* terminal (ACC-LNCH-154). */
|
|
213
|
+
export interface ILaunchArgvResponse {
|
|
214
|
+
argv: string[];
|
|
215
|
+
}
|
|
208
216
|
/** What a terminal is running, as the server resolved it from `/proc`. */
|
|
209
217
|
export interface ITerminalProbeResponse {
|
|
210
218
|
terminal_name: string;
|
package/lib/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { ILabShell, ILayoutRestorer } from '@jupyterlab/application';
|
|
2
2
|
import { Notification } from '@jupyterlab/apputils';
|
|
3
3
|
import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
|
|
4
|
+
import { ILauncher } from '@jupyterlab/launcher';
|
|
4
5
|
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
5
6
|
import { ITerminalTracker } from '@jupyterlab/terminal';
|
|
6
7
|
import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
|
|
8
|
+
import { launcherTileIcon, providerIcon } from './core/icons';
|
|
7
9
|
import { DEFAULT_RECENT_LIMIT } from './core/limits';
|
|
8
10
|
import { AssistantSessionsPanel, DEFAULT_PRESENTATION_MODE, commandId, panelWidgetId } from './core/panel';
|
|
9
11
|
import { LOG_PREFIX, ProviderRegistry } from './core/registry';
|
|
@@ -17,6 +19,14 @@ const PANEL_RANK = 600;
|
|
|
17
19
|
/** How often the server is re-asked which assistants are available, so a CLI
|
|
18
20
|
* installed while JupyterLab runs surfaces without a reload. */
|
|
19
21
|
const STATUS_INTERVAL_MS = 60000;
|
|
22
|
+
/** Launcher section every assistant tile lands in. */
|
|
23
|
+
const LAUNCHER_CATEGORY = 'AI Assistants';
|
|
24
|
+
/** Where that section sits. The Launcher ranks Notebook 0, Console 20 and
|
|
25
|
+
* Other 100, and the smallest rank among a category's items decides where the
|
|
26
|
+
* category goes - so ONE value on every tile, above Other's, puts the
|
|
27
|
+
* assistants after Other and keeps them there whichever tile is added first;
|
|
28
|
+
* unranked third-party categories (rank Infinity) still follow (ACC-LNCH-156). */
|
|
29
|
+
const LAUNCHER_CATEGORY_RANK = 200;
|
|
20
30
|
const plugin = {
|
|
21
31
|
id: PLUGIN_ID,
|
|
22
32
|
description: 'One side panel per AI code assistant, over a shared provider registry: sessions, favourites, branching and one-click resume in a terminal.',
|
|
@@ -27,9 +37,10 @@ const plugin = {
|
|
|
27
37
|
ISettingRegistry,
|
|
28
38
|
ITerminalTracker,
|
|
29
39
|
IDefaultFileBrowser,
|
|
30
|
-
IColourfulTabs
|
|
40
|
+
IColourfulTabs,
|
|
41
|
+
ILauncher
|
|
31
42
|
],
|
|
32
|
-
activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser, colourfulTabs) => {
|
|
43
|
+
activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser, colourfulTabs, launcher) => {
|
|
33
44
|
console.log('JupyterLab extension jupyterlab_ai_code_assistants_extension is activated!');
|
|
34
45
|
// A duplicate id throws here, at registration, rather than surfacing later
|
|
35
46
|
// as two panels quietly fighting over one widget id.
|
|
@@ -135,7 +146,7 @@ const plugin = {
|
|
|
135
146
|
labShell.add(panel, sidebar, { rank: PANEL_RANK });
|
|
136
147
|
};
|
|
137
148
|
const start = (id) => {
|
|
138
|
-
var _a;
|
|
149
|
+
var _a, _b;
|
|
139
150
|
const module = registry.get(id);
|
|
140
151
|
if (!module || live.has(id)) {
|
|
141
152
|
return;
|
|
@@ -163,7 +174,29 @@ const plugin = {
|
|
|
163
174
|
label: `Refresh ${module.descriptor.panelTitle}`,
|
|
164
175
|
execute: () => panel.refresh()
|
|
165
176
|
});
|
|
166
|
-
|
|
177
|
+
// The Launcher tile's command. It is registered whether or not a
|
|
178
|
+
// launcher exists, because the tile is the only caller but the command
|
|
179
|
+
// is what carries the label, the icon and the click path.
|
|
180
|
+
const launchCommandId = commandId(id, 'launch-here');
|
|
181
|
+
const launchCommand = app.commands.addCommand(launchCommandId, {
|
|
182
|
+
label: module.descriptor.label,
|
|
183
|
+
caption: `Start or resume ${module.descriptor.label} in the current folder`,
|
|
184
|
+
// The section header borrows this icon too; the view draws the
|
|
185
|
+
// joint icon there and the provider's own on the tile.
|
|
186
|
+
icon: launcherTileIcon(providerIcon(module.descriptor.iconName, module.descriptor.iconSvg)),
|
|
187
|
+
// The Launcher puts the file browser's folder in `cwd` on every
|
|
188
|
+
// click; the panel resolves it against the server root.
|
|
189
|
+
execute: args => panel.launchHere(args.cwd)
|
|
190
|
+
});
|
|
191
|
+
// Tile order follows the barrel, so the Launcher section reads in the
|
|
192
|
+
// sidebar's order (ACC-LNCH-158).
|
|
193
|
+
const tile = (_b = launcher === null || launcher === void 0 ? void 0 : launcher.add({
|
|
194
|
+
command: launchCommandId,
|
|
195
|
+
category: LAUNCHER_CATEGORY,
|
|
196
|
+
rank: registry.ids.indexOf(id),
|
|
197
|
+
categoryRank: LAUNCHER_CATEGORY_RANK
|
|
198
|
+
})) !== null && _b !== void 0 ? _b : null;
|
|
199
|
+
live.set(id, { panel, command, launchCommand, tile });
|
|
167
200
|
}
|
|
168
201
|
catch (err) {
|
|
169
202
|
// One provider failing to start must leave the others working.
|
|
@@ -171,6 +204,7 @@ const plugin = {
|
|
|
171
204
|
}
|
|
172
205
|
};
|
|
173
206
|
const stop = (id) => {
|
|
207
|
+
var _a;
|
|
174
208
|
const entry = live.get(id);
|
|
175
209
|
if (!entry) {
|
|
176
210
|
return;
|
|
@@ -179,6 +213,10 @@ const plugin = {
|
|
|
179
213
|
// Disposing stops the polling and clears this provider's tab tints; the
|
|
180
214
|
// terminals it launched keep running, untouched.
|
|
181
215
|
entry.command.dispose();
|
|
216
|
+
// Tile before the command it runs, so the Launcher never re-renders an
|
|
217
|
+
// item whose command has already gone.
|
|
218
|
+
(_a = entry.tile) === null || _a === void 0 ? void 0 : _a.dispose();
|
|
219
|
+
entry.launchCommand.dispose();
|
|
182
220
|
entry.panel.dispose();
|
|
183
221
|
};
|
|
184
222
|
/** Bring the docked panels in line with settings and CLI availability.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab_ai_code_assistants_extension",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Jupyterlab extension to replace individual jupyterlab_*_code_extension and integrate into one, that offers joint capability, joint settings page and individual right toolbar panels per code assistant, with the ability in settings to decide which code assistant support is on (default - all are enabled)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"@jupyterlab/apputils": "^4.0.0",
|
|
63
63
|
"@jupyterlab/coreutils": "^6.0.0",
|
|
64
64
|
"@jupyterlab/filebrowser": "^4.0.0",
|
|
65
|
+
"@jupyterlab/launcher": "^4.6.0",
|
|
65
66
|
"@jupyterlab/services": "^7.0.0",
|
|
66
67
|
"@jupyterlab/settingregistry": "^4.0.0",
|
|
67
68
|
"@jupyterlab/terminal": "^4.0.0",
|
|
@@ -71,7 +72,8 @@
|
|
|
71
72
|
"@lumino/disposable": "^2.0.0",
|
|
72
73
|
"@lumino/messaging": "^2.0.0",
|
|
73
74
|
"@lumino/widgets": "^2.0.0",
|
|
74
|
-
"jupyterlab_colourful_tab_extension": "^1.0.19"
|
|
75
|
+
"jupyterlab_colourful_tab_extension": "^1.0.19",
|
|
76
|
+
"react": "^18.2.0"
|
|
75
77
|
},
|
|
76
78
|
"devDependencies": {
|
|
77
79
|
"@eslint/js": "^9.0.0",
|
|
@@ -80,6 +82,7 @@
|
|
|
80
82
|
"@jupyterlab/testutils": "^4.0.0",
|
|
81
83
|
"@types/jest": "^29.2.0",
|
|
82
84
|
"@types/json-schema": "^7.0.11",
|
|
85
|
+
"@types/react-dom": "^18.2.0",
|
|
83
86
|
"eslint": "^9.0.0",
|
|
84
87
|
"eslint-config-prettier": "^9.0.0",
|
|
85
88
|
"eslint-plugin-prettier": "^5.0.0",
|
|
@@ -87,6 +90,7 @@
|
|
|
87
90
|
"jest": "^29.2.0",
|
|
88
91
|
"npm-run-all2": "^7.0.1",
|
|
89
92
|
"prettier": "^3.0.0",
|
|
93
|
+
"react-dom": "^18.2.0",
|
|
90
94
|
"rimraf": "^5.0.1",
|
|
91
95
|
"stylelint": "^15.10.1",
|
|
92
96
|
"stylelint-config-recommended": "^13.0.0",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* No assistant is named in `src/core
|
|
2
|
+
* No assistant is named in `src/core/` or in the composition root
|
|
3
|
+
* `src/index.ts` (ACC-LNCH-150).
|
|
3
4
|
*
|
|
4
5
|
* The architecture rests on it: adding or removing an assistant must touch one
|
|
5
6
|
* provider module and one barrel line, never a core file. A single `if (id ===
|
|
@@ -112,13 +113,21 @@ function coreFiles(): string[] {
|
|
|
112
113
|
.map(name => path.join(CORE_DIR, name));
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
describe('the core
|
|
116
|
+
describe('the core and the composition root name no assistant', () => {
|
|
116
117
|
it('has core files to scan at all', () => {
|
|
117
118
|
// Guards against the scan passing because it found nothing to read.
|
|
118
119
|
expect(coreFiles().length).toBeGreaterThan(3);
|
|
119
120
|
});
|
|
120
121
|
|
|
121
|
-
|
|
122
|
+
// ACC-LNCH-150 names the composition root as well as `src/core/`, so
|
|
123
|
+
// `index.ts` joins this scan only; `coreFiles()` stays as it is because the
|
|
124
|
+
// registry test below forbids the providers import `index.ts` must make.
|
|
125
|
+
it.each(
|
|
126
|
+
[...coreFiles(), path.resolve(__dirname, '..', 'index.ts')].map(file => [
|
|
127
|
+
path.basename(file),
|
|
128
|
+
file
|
|
129
|
+
])
|
|
130
|
+
)(
|
|
122
131
|
'%s carries no assistant name outside comments',
|
|
123
132
|
(_name: string, file: string) => {
|
|
124
133
|
const code = fs.readFileSync(file, 'utf8');
|
|
@@ -40,9 +40,11 @@ jest.mock('../core/request', () => ({
|
|
|
40
40
|
import { CommandRegistry } from '@lumino/commands';
|
|
41
41
|
import { Signal } from '@lumino/signaling';
|
|
42
42
|
|
|
43
|
-
import {
|
|
43
|
+
import { providerIcon } from '../core/icons';
|
|
44
|
+
import { AssistantSessionsPanel, commandId } from '../core/panel';
|
|
44
45
|
import { requestAPI } from '../core/request';
|
|
45
46
|
import plugin from '../index';
|
|
47
|
+
import { PROVIDERS } from '../providers';
|
|
46
48
|
|
|
47
49
|
const request = requestAPI as jest.Mock;
|
|
48
50
|
|
|
@@ -125,3 +127,171 @@ describe('DEF-GUARD-139 - reconcile hands a late roster to a docked panel', () =
|
|
|
125
127
|
docked.forEach(w => w.dispose());
|
|
126
128
|
});
|
|
127
129
|
});
|
|
130
|
+
|
|
131
|
+
describe('ACC-LNCH-143..146 - the Launcher tile has the panel lifecycle', () => {
|
|
132
|
+
/** A roster in which every assistant is present, or every one but `absent`. */
|
|
133
|
+
const roster = (absent?: string): unknown => ({
|
|
134
|
+
root_dir: '/srv/lab',
|
|
135
|
+
delete_to_trash: true,
|
|
136
|
+
providers: PROVIDERS.map(module => ({
|
|
137
|
+
id: module.descriptor.id,
|
|
138
|
+
available: module.descriptor.id !== absent
|
|
139
|
+
}))
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
interface IActivation {
|
|
143
|
+
app: any;
|
|
144
|
+
docked: AssistantSessionsPanel[];
|
|
145
|
+
/** Every `ILauncher.add` call, in order. */
|
|
146
|
+
added: any[];
|
|
147
|
+
/** Commands whose tile has been disposed. */
|
|
148
|
+
disposed: string[];
|
|
149
|
+
/** Replace the roster the next probe answers with. */
|
|
150
|
+
setRoster: (next: unknown) => void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Activate the plugin with no settings registry - every assistant enabled
|
|
154
|
+
* by default - against a launcher double, or none at all. */
|
|
155
|
+
async function activate(withLauncher = true): Promise<IActivation> {
|
|
156
|
+
let current = roster();
|
|
157
|
+
request.mockImplementation((path: string) =>
|
|
158
|
+
path === 'status'
|
|
159
|
+
? Promise.resolve(current)
|
|
160
|
+
: Promise.reject(new Error('no other route'))
|
|
161
|
+
);
|
|
162
|
+
const added: any[] = [];
|
|
163
|
+
const disposed: string[] = [];
|
|
164
|
+
const docked: AssistantSessionsPanel[] = [];
|
|
165
|
+
const launcher = withLauncher
|
|
166
|
+
? ({
|
|
167
|
+
add: (options: any) => {
|
|
168
|
+
added.push(options);
|
|
169
|
+
return { dispose: () => disposed.push(options.command) };
|
|
170
|
+
}
|
|
171
|
+
} as any)
|
|
172
|
+
: null;
|
|
173
|
+
const app = {
|
|
174
|
+
serviceManager: { serverSettings: {} },
|
|
175
|
+
commands: new CommandRegistry(),
|
|
176
|
+
hasPlugin: () => false
|
|
177
|
+
} as any;
|
|
178
|
+
const labShell = {
|
|
179
|
+
add: (widget: AssistantSessionsPanel) => docked.push(widget)
|
|
180
|
+
} as any;
|
|
181
|
+
await (plugin.activate as any)(
|
|
182
|
+
app,
|
|
183
|
+
labShell,
|
|
184
|
+
null,
|
|
185
|
+
null,
|
|
186
|
+
null,
|
|
187
|
+
null,
|
|
188
|
+
null,
|
|
189
|
+
launcher
|
|
190
|
+
);
|
|
191
|
+
await flush();
|
|
192
|
+
return {
|
|
193
|
+
app,
|
|
194
|
+
docked,
|
|
195
|
+
added,
|
|
196
|
+
disposed,
|
|
197
|
+
setRoster: (next: unknown) => {
|
|
198
|
+
current = next;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
it('adds one tile per docked assistant, labelled and iconed from its descriptor', async () => {
|
|
204
|
+
const live = await activate();
|
|
205
|
+
expect(live.added).toHaveLength(PROVIDERS.length);
|
|
206
|
+
for (const module of PROVIDERS) {
|
|
207
|
+
const id = commandId(module.descriptor.id, 'launch-here');
|
|
208
|
+
const tile = live.added.find(item => item.command === id);
|
|
209
|
+
expect(tile).toBeDefined();
|
|
210
|
+
expect(tile.category).toBe('AI Assistants');
|
|
211
|
+
expect(live.app.commands.label(id)).toBe(module.descriptor.label);
|
|
212
|
+
// A `launcherTileIcon` view of the provider's own icon - the header
|
|
213
|
+
// draws the joint icon through it (launcher-icon.spec.ts).
|
|
214
|
+
expect(Object.getPrototypeOf(live.app.commands.icon(id))).toBe(
|
|
215
|
+
providerIcon(module.descriptor.iconName, module.descriptor.iconSvg)
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
live.docked.forEach(widget => widget.dispose());
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('disposes the tile and its command when the panel stops', async () => {
|
|
222
|
+
const live = await activate();
|
|
223
|
+
const id = commandId(PROVIDERS[0].descriptor.id, 'launch-here');
|
|
224
|
+
expect(live.app.commands.hasCommand(id)).toBe(true);
|
|
225
|
+
|
|
226
|
+
// The server now reports that assistant's binary gone, which is the same
|
|
227
|
+
// decision that undocks its panel - there is no second one for the tile.
|
|
228
|
+
live.setRoster(roster(PROVIDERS[0].descriptor.id));
|
|
229
|
+
window.dispatchEvent(new Event('online'));
|
|
230
|
+
await flush();
|
|
231
|
+
|
|
232
|
+
expect(live.disposed).toEqual([id]);
|
|
233
|
+
expect(live.app.commands.hasCommand(id)).toBe(false);
|
|
234
|
+
expect(live.docked[0].isDisposed).toBe(true);
|
|
235
|
+
live.docked.forEach(widget => widget.dispose());
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('gives an assistant whose binary is absent neither a panel nor a tile', async () => {
|
|
239
|
+
const absent = PROVIDERS[1].descriptor.id;
|
|
240
|
+
request.mockImplementation((path: string) =>
|
|
241
|
+
path === 'status'
|
|
242
|
+
? Promise.resolve(roster(absent))
|
|
243
|
+
: Promise.reject(new Error('no other route'))
|
|
244
|
+
);
|
|
245
|
+
const added: any[] = [];
|
|
246
|
+
const docked: AssistantSessionsPanel[] = [];
|
|
247
|
+
await (plugin.activate as any)(
|
|
248
|
+
{
|
|
249
|
+
serviceManager: { serverSettings: {} },
|
|
250
|
+
commands: new CommandRegistry(),
|
|
251
|
+
hasPlugin: () => false
|
|
252
|
+
} as any,
|
|
253
|
+
{ add: (widget: AssistantSessionsPanel) => docked.push(widget) } as any,
|
|
254
|
+
null,
|
|
255
|
+
null,
|
|
256
|
+
null,
|
|
257
|
+
null,
|
|
258
|
+
null,
|
|
259
|
+
{
|
|
260
|
+
add: (options: any) => {
|
|
261
|
+
added.push(options);
|
|
262
|
+
return { dispose: () => undefined };
|
|
263
|
+
}
|
|
264
|
+
} as any
|
|
265
|
+
);
|
|
266
|
+
await flush();
|
|
267
|
+
|
|
268
|
+
expect(docked.map(widget => widget.node.dataset.provider)).not.toContain(
|
|
269
|
+
absent
|
|
270
|
+
);
|
|
271
|
+
expect(added.map(item => item.command)).not.toContain(
|
|
272
|
+
commandId(absent, 'launch-here')
|
|
273
|
+
);
|
|
274
|
+
expect(added).toHaveLength(PROVIDERS.length - 1);
|
|
275
|
+
docked.forEach(widget => widget.dispose());
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('docks the panels and adds nothing when JupyterLab has no launcher', async () => {
|
|
279
|
+
// `start` swallows what a provider throws, so "nothing threw" is read off
|
|
280
|
+
// the line it logs rather than off the activation resolving.
|
|
281
|
+
const logged = jest
|
|
282
|
+
.spyOn(console, 'error')
|
|
283
|
+
.mockImplementation(() => undefined);
|
|
284
|
+
const live = await activate(false);
|
|
285
|
+
expect(logged).not.toHaveBeenCalled();
|
|
286
|
+
logged.mockRestore();
|
|
287
|
+
expect(live.docked).toHaveLength(PROVIDERS.length);
|
|
288
|
+
expect(live.added).toEqual([]);
|
|
289
|
+
// The command is still registered - only the tile depends on the launcher.
|
|
290
|
+
expect(
|
|
291
|
+
live.app.commands.hasCommand(
|
|
292
|
+
commandId(PROVIDERS[0].descriptor.id, 'launch-here')
|
|
293
|
+
)
|
|
294
|
+
).toBe(true);
|
|
295
|
+
live.docked.forEach(widget => widget.dispose());
|
|
296
|
+
});
|
|
297
|
+
});
|