jupyterlab_ai_code_assistants_extension 1.0.39 → 1.1.13
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 +23 -4
- package/lib/core/panel.js +155 -9
- package/lib/core/request.d.ts +1 -0
- package/lib/core/request.js +19 -1
- package/lib/core/types.d.ts +8 -0
- package/lib/index.js +53 -4
- package/package.json +6 -2
- package/src/__tests__/core-neutrality.spec.ts +12 -3
- package/src/__tests__/index.spec.ts +297 -0
- package/src/__tests__/launcher-icon.spec.ts +77 -0
- package/src/__tests__/launcher.spec.ts +537 -0
- package/src/__tests__/panel.spec.ts +130 -1
- package/src/__tests__/request.spec.ts +47 -0
- package/src/core/icons.ts +36 -4
- package/src/core/panel.ts +181 -9
- package/src/core/request.ts +21 -1
- package/src/core/types.ts +9 -0
- package/src/index.ts +61 -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
|
@@ -89,17 +89,36 @@ export declare class AssistantSessionsPanel extends Widget {
|
|
|
89
89
|
* JupyterLab's move-to-trash setting. Settable because a panel docked before
|
|
90
90
|
* the server has answered its first status probe is built without either
|
|
91
91
|
* (DEF-132); the action paths read them lazily, so a late value applies on
|
|
92
|
-
* the next click,
|
|
93
|
-
*
|
|
92
|
+
* the next click, and a root arriving after rows were drawn redraws them
|
|
93
|
+
* once (DEF-136).
|
|
94
94
|
*
|
|
95
95
|
* Trailing slashes go, but never the root itself: serving JupyterLab at
|
|
96
96
|
* `/` used to normalise to the empty string, which reads as "no root" -
|
|
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',
|
|
@@ -508,6 +513,18 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
508
513
|
_showError(err) {
|
|
509
514
|
const message = err instanceof Error ? err.message : String(err);
|
|
510
515
|
this._logError(message);
|
|
516
|
+
// DEF-138: the server ANSWERED, and its answer is that this assistant's
|
|
517
|
+
// binary is not on the PATH the Jupyter server runs with. Blaming the
|
|
518
|
+
// server names the wrong cause - the panel is docked only because no
|
|
519
|
+
// roster has arrived yet, and the roster is what removes it (DEF-132).
|
|
520
|
+
// The error code is matched as well as the status, because a 503 raised
|
|
521
|
+
// by a proxy or a stopped single-user server is NOT this: its body never
|
|
522
|
+
// parses to `{"error": "cli_not_found"}`, so it keeps the copy below.
|
|
523
|
+
if (isResponseStatus(err, 503) && message === 'cli_not_found') {
|
|
524
|
+
this._setInlineError(`\`${this._descriptor.cliBinary}\` was not found on the Jupyter ` +
|
|
525
|
+
`server's PATH.`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
511
528
|
const copy = isRequestTimeout(err)
|
|
512
529
|
? 'The server is not answering - retrying.'
|
|
513
530
|
: 'Could not reach the server - retrying.';
|
|
@@ -714,8 +731,8 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
714
731
|
* JupyterLab's move-to-trash setting. Settable because a panel docked before
|
|
715
732
|
* the server has answered its first status probe is built without either
|
|
716
733
|
* (DEF-132); the action paths read them lazily, so a late value applies on
|
|
717
|
-
* the next click,
|
|
718
|
-
*
|
|
734
|
+
* the next click, and a root arriving after rows were drawn redraws them
|
|
735
|
+
* once (DEF-136).
|
|
719
736
|
*
|
|
720
737
|
* Trailing slashes go, but never the root itself: serving JupyterLab at
|
|
721
738
|
* `/` used to normalise to the empty string, which reads as "no root" -
|
|
@@ -724,25 +741,146 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
724
741
|
setRoot(rootDir, deleteToTrash) {
|
|
725
742
|
var _a;
|
|
726
743
|
const root = (rootDir || '').replace(/\/+$/, '');
|
|
727
|
-
|
|
744
|
+
const next = root || (rootDir ? '/' : '');
|
|
745
|
+
const changed = next !== this._rootDir;
|
|
746
|
+
this._rootDir = next;
|
|
728
747
|
this._deleteToTrash = deleteToTrash;
|
|
729
748
|
(_a = this._renameNewButton) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
749
|
+
// Guarded on the VALUE, not on the call: `reconcile` re-sends the same
|
|
750
|
+
// root every status tick, so a changed-guard redraws exactly once, on the
|
|
751
|
+
// '' -> root transition. `_sessions` stands in for "the shell is built and
|
|
752
|
+
// rows are on screen" - it is null until the first `_fetch` answers, which
|
|
753
|
+
// is what keeps the constructor's own `setRoot` from reaching `_render`
|
|
754
|
+
// before `_buildShell` has created `_bodyEl`.
|
|
755
|
+
if (changed && this._sessions !== null) {
|
|
756
|
+
this._render();
|
|
757
|
+
}
|
|
730
758
|
}
|
|
731
|
-
/** Absolute path of
|
|
732
|
-
*
|
|
733
|
-
|
|
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) {
|
|
734
766
|
var _a, _b, _c;
|
|
735
767
|
// No roster yet (DEF-132): a relative folder joined to an empty root
|
|
736
768
|
// would be an absolute path at the filesystem root.
|
|
737
769
|
if (!this._rootDir) {
|
|
738
770
|
return '';
|
|
739
771
|
}
|
|
740
|
-
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(/^\/+/, '');
|
|
741
773
|
if (!rel) {
|
|
742
774
|
return this._rootDir;
|
|
743
775
|
}
|
|
744
776
|
return this._rootDir === '/' ? `/${rel}` : `${this._rootDir}/${rel}`;
|
|
745
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
|
+
}
|
|
746
884
|
/** Start a brand-new conversation in the file browser's current folder. */
|
|
747
885
|
async _newSession(force) {
|
|
748
886
|
var _a;
|
|
@@ -1770,7 +1908,11 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1770
1908
|
// absolute filesystem path, so it goes through _pathUnderRoot.
|
|
1771
1909
|
const rel = this._pathUnderRoot(s.project_path);
|
|
1772
1910
|
if (rel === null) {
|
|
1773
|
-
|
|
1911
|
+
// A root the server has not sent yet is not a folder outside it
|
|
1912
|
+
// (DEF-134).
|
|
1913
|
+
Notification.warning(this._rootDir
|
|
1914
|
+
? 'Folder is outside the JupyterLab root - cannot open a terminal there.'
|
|
1915
|
+
: 'Waiting for the server root - cannot open a terminal yet.', { autoClose: 4000 });
|
|
1774
1916
|
return;
|
|
1775
1917
|
}
|
|
1776
1918
|
this._app.commands
|
|
@@ -1788,7 +1930,11 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1788
1930
|
}
|
|
1789
1931
|
const rel = this._pathUnderRoot(s.project_path);
|
|
1790
1932
|
if (rel === null) {
|
|
1791
|
-
|
|
1933
|
+
// A root the server has not sent yet is not a folder outside it
|
|
1934
|
+
// (DEF-134).
|
|
1935
|
+
Notification.warning(this._rootDir
|
|
1936
|
+
? 'Folder is outside the JupyterLab root - the file browser cannot show it.'
|
|
1937
|
+
: 'Waiting for the server root - the file browser cannot show it yet.', { autoClose: 4000 });
|
|
1792
1938
|
return;
|
|
1793
1939
|
}
|
|
1794
1940
|
this._app.commands
|
package/lib/core/request.d.ts
CHANGED
|
@@ -56,6 +56,7 @@ export declare function isRequestTimeout(err: unknown): boolean;
|
|
|
56
56
|
* @returns The response body interpreted as JSON
|
|
57
57
|
* @throws RequestTimeoutError when the deadline passes before the server
|
|
58
58
|
* answers
|
|
59
|
+
* @throws Error when a 200 carries a body this helper cannot parse
|
|
59
60
|
*/
|
|
60
61
|
export declare function requestAPI<T>(endPoint: string, serverSettings: ServerConnection.ISettings, init?: RequestInit): Promise<T>;
|
|
61
62
|
/** Build a provider-scoped path: `providers/<id>/<path>`. The id is encoded so
|
package/lib/core/request.js
CHANGED
|
@@ -63,6 +63,7 @@ export function isRequestTimeout(err) {
|
|
|
63
63
|
* @returns The response body interpreted as JSON
|
|
64
64
|
* @throws RequestTimeoutError when the deadline passes before the server
|
|
65
65
|
* answers
|
|
66
|
+
* @throws Error when a 200 carries a body this helper cannot parse
|
|
66
67
|
*/
|
|
67
68
|
export async function requestAPI(endPoint, serverSettings, init = {}) {
|
|
68
69
|
var _a, _b;
|
|
@@ -111,7 +112,24 @@ export async function requestAPI(endPoint, serverSettings, init = {}) {
|
|
|
111
112
|
data = JSON.parse(data);
|
|
112
113
|
}
|
|
113
114
|
catch (_c) {
|
|
114
|
-
|
|
115
|
+
if (response.ok) {
|
|
116
|
+
// DEF-118: every handler in `core/routes.py` finishes `json.dumps`, so
|
|
117
|
+
// a 200 this helper cannot parse did not come from this server - it is
|
|
118
|
+
// an SSO or proxy interstitial served in its place. Returning the raw
|
|
119
|
+
// string let every caller read `undefined` off it: `probeStatus` wrote
|
|
120
|
+
// a roster with no `providers` and `reconcile` undocked all four
|
|
121
|
+
// panels, `_fetch` rendered an empty session list, and
|
|
122
|
+
// `ColourStore.load` replaced the cache with an empty map. Refusing it
|
|
123
|
+
// makes the probe FAIL, and a failed probe writes nothing (DEF-132),
|
|
124
|
+
// so the last known roster stands.
|
|
125
|
+
throw new Error(`Response to ${endPoint} was not JSON (HTTP ${response.status})`);
|
|
126
|
+
}
|
|
127
|
+
// Not ok: the `ResponseError` below carries this body as its message, so
|
|
128
|
+
// this side is no longer a silent path and a proxy's error page here is
|
|
129
|
+
// noise (DEF-116). This is the conditional DEF-118 recorded - `debug`
|
|
130
|
+
// only on the refusal side; the 200 side is now louder than the `warn`
|
|
131
|
+
// it asked for.
|
|
132
|
+
console.debug(`${LOG_PREFIX} Not a JSON response body.`, response);
|
|
115
133
|
}
|
|
116
134
|
}
|
|
117
135
|
if (!response.ok) {
|
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.
|
|
@@ -226,6 +264,17 @@ const plugin = {
|
|
|
226
264
|
// gets a notification, so the COMMON failure was the quiet one.
|
|
227
265
|
Notification.warning(`${label} is enabled but \`${binary}\` was not found on the Jupyter server's PATH, so its panel is not shown.`, { autoClose: 8000 });
|
|
228
266
|
}
|
|
267
|
+
// The latch is per ABSENCE, not per page load. Once the server reports
|
|
268
|
+
// the binary present again, a later disappearance gets its own warning:
|
|
269
|
+
// without this, one transient absence - a CLI upgrade unlinking the
|
|
270
|
+
// binary for a moment, or an obsolete roster from an overlapping probe -
|
|
271
|
+
// permanently suppresses the real one (DEF-125). It cannot make the
|
|
272
|
+
// warning repeat while the state is unchanged, which is what the
|
|
273
|
+
// once-per-id latch above exists to prevent: the two conditions are
|
|
274
|
+
// mutually exclusive.
|
|
275
|
+
if (probe !== null && probe.available !== false) {
|
|
276
|
+
warnedUnavailable.delete(id);
|
|
277
|
+
}
|
|
229
278
|
if (enabled && available) {
|
|
230
279
|
if (live.has(id)) {
|
|
231
280
|
const panel = live.get(id).panel;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab_ai_code_assistants_extension",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.13",
|
|
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');
|