jupyterlab_ai_code_assistants_extension 0.6.15
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/LICENSE +29 -0
- package/README.md +44 -0
- package/lib/core/colour.d.ts +57 -0
- package/lib/core/colour.js +190 -0
- package/lib/core/icons.d.ts +12 -0
- package/lib/core/icons.js +99 -0
- package/lib/core/modes.d.ts +13 -0
- package/lib/core/modes.js +36 -0
- package/lib/core/panel.d.ts +233 -0
- package/lib/core/panel.js +1673 -0
- package/lib/core/popup.d.ts +40 -0
- package/lib/core/popup.js +404 -0
- package/lib/core/registry.d.ts +24 -0
- package/lib/core/registry.js +48 -0
- package/lib/core/request.d.ts +25 -0
- package/lib/core/request.js +64 -0
- package/lib/core/terminals.d.ts +129 -0
- package/lib/core/terminals.js +344 -0
- package/lib/core/types.d.ts +269 -0
- package/lib/core/types.js +6 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +277 -0
- package/lib/providers/claude.d.ts +3 -0
- package/lib/providers/claude.js +88 -0
- package/lib/providers/codex.d.ts +3 -0
- package/lib/providers/codex.js +79 -0
- package/lib/providers/gemini.d.ts +2 -0
- package/lib/providers/gemini.js +73 -0
- package/lib/providers/index.d.ts +3 -0
- package/lib/providers/index.js +16 -0
- package/lib/providers/kimi.d.ts +23 -0
- package/lib/providers/kimi.js +139 -0
- package/package.json +169 -0
- package/schema/plugin.json +93 -0
- package/src/__tests__/colour.spec.ts +153 -0
- package/src/__tests__/core-neutrality.spec.ts +86 -0
- package/src/__tests__/labels.spec.ts +89 -0
- package/src/__tests__/launch-mode.spec.ts +53 -0
- package/src/__tests__/registry.spec.ts +178 -0
- package/src/core/colour.ts +224 -0
- package/src/core/icons.ts +121 -0
- package/src/core/modes.ts +45 -0
- package/src/core/panel.ts +2020 -0
- package/src/core/popup.ts +475 -0
- package/src/core/registry.ts +60 -0
- package/src/core/request.ts +96 -0
- package/src/core/terminals.ts +446 -0
- package/src/core/types.ts +305 -0
- package/src/index.ts +356 -0
- package/src/providers/claude.ts +102 -0
- package/src/providers/codex.ts +89 -0
- package/src/providers/gemini.ts +80 -0
- package/src/providers/index.ts +21 -0
- package/src/providers/kimi.ts +155 -0
- package/style/base.css +888 -0
- package/style/index.css +1 -0
- package/style/index.js +1 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The colour ladder.
|
|
3
|
+
*
|
|
4
|
+
* Highest wins: a user-set colour, then the conversation's own colour for a
|
|
5
|
+
* `native` assistant, then the derived hash, then nothing. Each step is a pure
|
|
6
|
+
* function precisely so this can be asserted without a panel - the tint is the
|
|
7
|
+
* part that regressed twice behind a green suite that never ran the logic.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
TAB_COLOUR_IDS,
|
|
12
|
+
effectiveColour,
|
|
13
|
+
fnv1aColour,
|
|
14
|
+
readUserSetTabColour,
|
|
15
|
+
sessionForCwds
|
|
16
|
+
} from '../core/colour';
|
|
17
|
+
import { ISession } from '../core/types';
|
|
18
|
+
|
|
19
|
+
const SESSION_A = '11111111-1111-4111-8111-111111111111';
|
|
20
|
+
const SESSION_B = '22222222-2222-4222-8222-222222222222';
|
|
21
|
+
|
|
22
|
+
function session(projectPath: string, id: string): ISession {
|
|
23
|
+
return {
|
|
24
|
+
project_path: projectPath,
|
|
25
|
+
encoded_path: projectPath,
|
|
26
|
+
session_id: id,
|
|
27
|
+
name: projectPath,
|
|
28
|
+
name_source: 'basename',
|
|
29
|
+
message_count: 0,
|
|
30
|
+
file_mtime: 0,
|
|
31
|
+
git_branch: null,
|
|
32
|
+
favourite: false,
|
|
33
|
+
extra_sessions: 0
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('fnv1aColour', () => {
|
|
38
|
+
it('answers one of the six tab colours', () => {
|
|
39
|
+
expect(TAB_COLOUR_IDS).toHaveLength(6);
|
|
40
|
+
expect(TAB_COLOUR_IDS).toContain(fnv1aColour(SESSION_A));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('is stable for the same conversation', () => {
|
|
44
|
+
expect(fnv1aColour(SESSION_A)).toEqual(fnv1aColour(SESSION_A));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('spreads across the vocabulary rather than answering one colour', () => {
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
for (let i = 0; i < 200; i++) {
|
|
50
|
+
seen.add(fnv1aColour(`session_${i}-abc`));
|
|
51
|
+
}
|
|
52
|
+
expect(seen.size).toBeGreaterThan(3);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('agrees with the server hash, id for id', () => {
|
|
56
|
+
// Hard-coded from the Python `derived_colour` of the same ids: both sides
|
|
57
|
+
// hash the same string over the same six-colour order, so a conversation
|
|
58
|
+
// tints identically whichever one resolved it. A drift in either
|
|
59
|
+
// implementation - or in the order below - fails here.
|
|
60
|
+
expect(TAB_COLOUR_IDS).toEqual([
|
|
61
|
+
'rose',
|
|
62
|
+
'peach',
|
|
63
|
+
'lemon',
|
|
64
|
+
'mint',
|
|
65
|
+
'sky',
|
|
66
|
+
'lavender'
|
|
67
|
+
]);
|
|
68
|
+
expect(fnv1aColour(SESSION_A)).toEqual('peach');
|
|
69
|
+
expect(fnv1aColour(SESSION_B)).toEqual('lavender');
|
|
70
|
+
expect(fnv1aColour('session_demo')).toEqual('peach');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('effectiveColour', () => {
|
|
75
|
+
it('lets a user-set colour beat every default', () => {
|
|
76
|
+
expect(effectiveColour(SESSION_A, 'native', 'sky', 'peach')).toEqual('sky');
|
|
77
|
+
expect(effectiveColour(SESSION_A, 'derived', 'sky', null)).toEqual('sky');
|
|
78
|
+
expect(effectiveColour(SESSION_A, 'none', 'sky', null)).toEqual('sky');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('takes the assistant own colour for a native source', () => {
|
|
82
|
+
expect(effectiveColour(SESSION_A, 'native', null, 'peach')).toEqual(
|
|
83
|
+
'peach'
|
|
84
|
+
);
|
|
85
|
+
// A native provider with no colour recorded stays untinted - it must never
|
|
86
|
+
// fall through to the hash, which is another assistant's default.
|
|
87
|
+
expect(effectiveColour(SESSION_A, 'native', null, null)).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('derives from the conversation id for a derived source', () => {
|
|
91
|
+
expect(effectiveColour(SESSION_A, 'derived', null, null)).toEqual(
|
|
92
|
+
fnv1aColour(SESSION_A)
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('answers nothing for a colourless source', () => {
|
|
97
|
+
expect(effectiveColour(SESSION_A, 'none', null, null)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('answers nothing without a conversation', () => {
|
|
101
|
+
expect(effectiveColour(null, 'derived', null, null)).toBeNull();
|
|
102
|
+
expect(effectiveColour(null, 'none', null, null)).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('readUserSetTabColour', () => {
|
|
107
|
+
afterEach(() => window.localStorage.clear());
|
|
108
|
+
|
|
109
|
+
it('reads the colourful-tab extension menu choice', () => {
|
|
110
|
+
window.localStorage.setItem(
|
|
111
|
+
'jupyterlab-colourful-tab-colours',
|
|
112
|
+
JSON.stringify({ 'terminal:1': 3 })
|
|
113
|
+
);
|
|
114
|
+
expect(readUserSetTabColour('1')).toEqual('mint');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('answers null for a terminal with no choice, and never throws', () => {
|
|
118
|
+
expect(readUserSetTabColour('1')).toBeNull();
|
|
119
|
+
window.localStorage.setItem('jupyterlab-colourful-tab-colours', 'not json');
|
|
120
|
+
expect(readUserSetTabColour('1')).toBeNull();
|
|
121
|
+
window.localStorage.setItem(
|
|
122
|
+
'jupyterlab-colourful-tab-colours',
|
|
123
|
+
JSON.stringify({ 'terminal:1': 99 })
|
|
124
|
+
);
|
|
125
|
+
expect(readUserSetTabColour('1')).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('sessionForCwds', () => {
|
|
130
|
+
const parent = session('/home/lab/projects', SESSION_A);
|
|
131
|
+
const nested = session('/home/lab/projects/demo', SESSION_B);
|
|
132
|
+
|
|
133
|
+
it('lets the nested project win over its parent', () => {
|
|
134
|
+
expect(
|
|
135
|
+
sessionForCwds(['/home/lab/projects/demo'], [parent, nested])
|
|
136
|
+
).toEqual(nested);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('matches a subdirectory of a project', () => {
|
|
140
|
+
expect(
|
|
141
|
+
sessionForCwds(['/home/lab/projects/demo/src'], [parent, nested])
|
|
142
|
+
).toEqual(nested);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('does not match a sibling that merely shares a prefix', () => {
|
|
146
|
+
expect(sessionForCwds(['/home/lab/projects-old'], [parent])).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('answers null when no project contains the cwd', () => {
|
|
150
|
+
expect(sessionForCwds(['/tmp'], [parent, nested])).toBeNull();
|
|
151
|
+
expect(sessionForCwds([], [parent])).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* No assistant is named in `src/core/`.
|
|
3
|
+
*
|
|
4
|
+
* The architecture rests on it: adding or removing an assistant must touch one
|
|
5
|
+
* provider module and one barrel line, never a core file. A single `if (id ===
|
|
6
|
+
* 'claude')` is enough to break that promise, and it would never show up as a
|
|
7
|
+
* failing behaviour test - so the source itself is the assertion.
|
|
8
|
+
*
|
|
9
|
+
* Comments are stripped before the scan: the core is allowed to EXPLAIN why an
|
|
10
|
+
* assistant needed a capability flag, it is just not allowed to branch on one.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
|
|
16
|
+
// Every id the registry ships, plus the names of the retired extensions they
|
|
17
|
+
// were ported from.
|
|
18
|
+
const ASSISTANT_NAMES = /claude|codex|kimi|gemini|moonshot|anthropic/i;
|
|
19
|
+
|
|
20
|
+
const CORE_DIR = path.resolve(__dirname, '..', 'core');
|
|
21
|
+
|
|
22
|
+
/** Drop block and line comments, leaving code and string literals. */
|
|
23
|
+
function stripComments(source: string): string {
|
|
24
|
+
return source
|
|
25
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
26
|
+
.replace(/^[^\n]*?\/\/.*$/gm, line =>
|
|
27
|
+
// Keep the part before a trailing `//`, so code on a commented line is
|
|
28
|
+
// still scanned - but drop a whole-line comment entirely.
|
|
29
|
+
line.slice(0, line.indexOf('//'))
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function coreFiles(): string[] {
|
|
34
|
+
return fs
|
|
35
|
+
.readdirSync(CORE_DIR)
|
|
36
|
+
.filter(name => name.endsWith('.ts'))
|
|
37
|
+
.map(name => path.join(CORE_DIR, name));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('the core names no assistant', () => {
|
|
41
|
+
it('has core files to scan at all', () => {
|
|
42
|
+
// Guards against the scan passing because it found nothing to read.
|
|
43
|
+
expect(coreFiles().length).toBeGreaterThan(3);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it.each(coreFiles().map(file => [path.basename(file), file]))(
|
|
47
|
+
'%s carries no assistant name outside comments',
|
|
48
|
+
(_name: string, file: string) => {
|
|
49
|
+
const code = stripComments(fs.readFileSync(file, 'utf8'));
|
|
50
|
+
const offenders = code
|
|
51
|
+
.split('\n')
|
|
52
|
+
.map((line, index) => [index + 1, line] as [number, string])
|
|
53
|
+
.filter(([, line]) => ASSISTANT_NAMES.test(line));
|
|
54
|
+
expect(offenders).toEqual([]);
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
it('reaches every assistant through the registry, not by name', () => {
|
|
59
|
+
// A core file importing a provider module directly is the same failure in
|
|
60
|
+
// a different shape, and the regex above would not catch a relative path.
|
|
61
|
+
for (const file of coreFiles()) {
|
|
62
|
+
const code = fs.readFileSync(file, 'utf8');
|
|
63
|
+
expect(code).not.toMatch(/from\s+'(\.\.\/)+providers/);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('the comment-stripping the scan relies on', () => {
|
|
69
|
+
it('drops a whole-line comment', () => {
|
|
70
|
+
expect(stripComments('// claude is the base\nconst x = 1;')).not.toMatch(
|
|
71
|
+
ASSISTANT_NAMES
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('drops a block comment', () => {
|
|
76
|
+
expect(
|
|
77
|
+
stripComments('/** ported from claude */\nconst x = 1;')
|
|
78
|
+
).not.toMatch(ASSISTANT_NAMES);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps code that a trailing comment sits beside', () => {
|
|
82
|
+
expect(stripComments("const id = 'claude'; // the base")).toMatch(
|
|
83
|
+
ASSISTANT_NAMES
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu-label truncation.
|
|
3
|
+
*
|
|
4
|
+
* Lumino sets no `max-width` on a menu item label, so an uncapped
|
|
5
|
+
* auto-generated title stretches a submenu across the window. The budget is in
|
|
6
|
+
* display COLUMNS, not characters: 60 Han glyphs measure at or above the width
|
|
7
|
+
* that filed the defect, while 60 Latin ones are half that.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
MENU_TITLE_COLUMNS,
|
|
12
|
+
branchMenuLabel,
|
|
13
|
+
shortSessionId,
|
|
14
|
+
truncateToColumns
|
|
15
|
+
} from '../providers/kimi';
|
|
16
|
+
|
|
17
|
+
describe('truncateToColumns', () => {
|
|
18
|
+
it('leaves a short label alone, with no ellipsis', () => {
|
|
19
|
+
expect(truncateToColumns('Refactor the panel', 60)).toEqual(
|
|
20
|
+
'Refactor the panel'
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('counts a wide glyph as two columns', () => {
|
|
25
|
+
const han = '会'.repeat(40);
|
|
26
|
+
const cut = truncateToColumns(han, 10);
|
|
27
|
+
// Five glyphs fit in ten columns; the sixth would overflow.
|
|
28
|
+
expect(cut).toEqual('会'.repeat(5) + '…');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('counts a Latin character as one column', () => {
|
|
32
|
+
expect(truncateToColumns('abcdefghij', 5)).toEqual('abcde…');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('never splits an astral character into a lone surrogate', () => {
|
|
36
|
+
// An emoji is two code units and two columns: cutting by code unit would
|
|
37
|
+
// leave half a surrogate pair, which paints as the replacement glyph.
|
|
38
|
+
const cut = truncateToColumns('😀😀😀', 5);
|
|
39
|
+
expect(cut).toEqual('😀😀…');
|
|
40
|
+
expect(cut).not.toContain('�');
|
|
41
|
+
// Two emoji plus the ellipsis, counted by code point.
|
|
42
|
+
expect([...cut]).toHaveLength(3);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('drops a trailing space so the ellipsis hugs the last word', () => {
|
|
46
|
+
expect(truncateToColumns('one two three', 8)).toEqual('one two…');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('bounds a label at exactly the budget', () => {
|
|
50
|
+
const cut = truncateToColumns('x'.repeat(200), MENU_TITLE_COLUMNS);
|
|
51
|
+
expect(cut).toHaveLength(MENU_TITLE_COLUMNS + 1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('handles an empty label', () => {
|
|
55
|
+
expect(truncateToColumns('', 60)).toEqual('');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('shortSessionId', () => {
|
|
60
|
+
it('slices past the prefix every conversation shares', () => {
|
|
61
|
+
// The core front-slice would render the constant `session_` for every
|
|
62
|
+
// conversation of a project - the exact thing the short id exists to tell
|
|
63
|
+
// apart.
|
|
64
|
+
expect(
|
|
65
|
+
shortSessionId('session_9f8e7d6c-1111-2222-3333-444455556666')
|
|
66
|
+
).toEqual('9f8e7d6c');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('front-slices an id that carries no prefix', () => {
|
|
70
|
+
expect(shortSessionId('9f8e7d6c-1111-2222-3333-444455556666')).toEqual(
|
|
71
|
+
'9f8e7d6c'
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('branchMenuLabel', () => {
|
|
77
|
+
it('keeps the id beside a truncated title', () => {
|
|
78
|
+
const title = '会'.repeat(100);
|
|
79
|
+
const label = branchMenuLabel(title, '9f8e7d6c');
|
|
80
|
+
expect(label.endsWith(' (9f8e7d6c)')).toBe(true);
|
|
81
|
+
// Branches of one project share a path, so the id is what tells them
|
|
82
|
+
// apart - only the title may be trimmed.
|
|
83
|
+
expect(label).toContain('…');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('drops the bracketed id when the title already is the id', () => {
|
|
87
|
+
expect(branchMenuLabel('9f8e7d6c', '9f8e7d6c')).toEqual('9f8e7d6c');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch-mode precedence.
|
|
3
|
+
*
|
|
4
|
+
* A provider can expose both a boolean switch and an enum ladder over the same
|
|
5
|
+
* approval surface, and the payload carries only one token. An enum moved off
|
|
6
|
+
* its default is the user's explicit statement, so it has to beat a boolean
|
|
7
|
+
* left on - otherwise a read-only ladder setting launches wide open.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolveLaunchMode } from '../core/modes';
|
|
11
|
+
import { ILaunchMode } from '../core/types';
|
|
12
|
+
|
|
13
|
+
const MODES: ILaunchMode[] = [
|
|
14
|
+
{
|
|
15
|
+
id: 'yoloMode',
|
|
16
|
+
title: 'YOLO',
|
|
17
|
+
description: 'Boolean switch.',
|
|
18
|
+
kind: 'boolean',
|
|
19
|
+
default: false
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'approvalMode',
|
|
23
|
+
title: 'Approval',
|
|
24
|
+
description: 'Enum ladder.',
|
|
25
|
+
kind: 'enum',
|
|
26
|
+
values: ['default', 'plan', 'yolo'],
|
|
27
|
+
default: 'default'
|
|
28
|
+
}
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
describe('resolveLaunchMode', () => {
|
|
32
|
+
it('lets a non-default enum beat a boolean that is on', () => {
|
|
33
|
+
expect(
|
|
34
|
+
resolveLaunchMode(MODES, { yoloMode: true, approvalMode: 'plan' })
|
|
35
|
+
).toEqual('approvalMode=plan');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('sends the boolean while every enum sits at its default', () => {
|
|
39
|
+
expect(
|
|
40
|
+
resolveLaunchMode(MODES, { yoloMode: true, approvalMode: 'default' })
|
|
41
|
+
).toEqual('yoloMode');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('sends nothing when no mode is set', () => {
|
|
45
|
+
expect(resolveLaunchMode(MODES, {})).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('forces the requested mode whatever the settings say', () => {
|
|
49
|
+
expect(
|
|
50
|
+
resolveLaunchMode(MODES, { approvalMode: 'plan' }, 'yoloMode')
|
|
51
|
+
).toEqual('yoloMode');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frontend provider registry and the descriptor contract.
|
|
3
|
+
*
|
|
4
|
+
* Registration is the only place an id is asserted, and the core reads
|
|
5
|
+
* capability flags rather than ids - so a descriptor that claims a capability
|
|
6
|
+
* it cannot back, or two descriptors sharing an id, has to fail here rather
|
|
7
|
+
* than at render time.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ProviderRegistry } from '../core/registry';
|
|
11
|
+
import { IProviderDescriptor, IProviderModule } from '../core/types';
|
|
12
|
+
import { PROVIDERS } from '../providers';
|
|
13
|
+
|
|
14
|
+
// Every assistant shipped today. A provider added without reaching the barrel
|
|
15
|
+
// fails here rather than silently costing the user a panel.
|
|
16
|
+
const EXPECTED_IDS = ['claude', 'codex', 'kimi', 'gemini'];
|
|
17
|
+
|
|
18
|
+
const FORK_STRATEGIES = ['native-flag', 'native-command', 'server-copy'];
|
|
19
|
+
const COLOUR_SOURCES = ['native', 'derived', 'none'];
|
|
20
|
+
const NAMING_STRATEGIES = ['launch-flag', 'server-side', 'none'];
|
|
21
|
+
|
|
22
|
+
function stub(id: string): IProviderModule {
|
|
23
|
+
return {
|
|
24
|
+
descriptor: {
|
|
25
|
+
id,
|
|
26
|
+
label: id,
|
|
27
|
+
panelTitle: id,
|
|
28
|
+
iconName: `jupyterlab_ai_code_assistants_extension:${id}`,
|
|
29
|
+
iconSvg: '<svg/>',
|
|
30
|
+
cliBinary: id,
|
|
31
|
+
forkStrategy: 'server-copy',
|
|
32
|
+
colourSource: 'none',
|
|
33
|
+
namingStrategy: 'none',
|
|
34
|
+
mintsNewSessionId: false,
|
|
35
|
+
launchModes: [],
|
|
36
|
+
hasRemoteControl: false,
|
|
37
|
+
hasBgAgents: false,
|
|
38
|
+
hasLiveProcess: false
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('the providers barrel', () => {
|
|
44
|
+
it('exports every registered assistant', () => {
|
|
45
|
+
expect(PROVIDERS.map(m => m.descriptor.id).sort()).toEqual(
|
|
46
|
+
[...EXPECTED_IDS].sort()
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('gives each assistant a unique id', () => {
|
|
51
|
+
const ids = PROVIDERS.map(m => m.descriptor.id);
|
|
52
|
+
expect(new Set(ids).size).toEqual(ids.length);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it.each(PROVIDERS.map(m => [m.descriptor.id, m.descriptor]))(
|
|
56
|
+
'%s satisfies the descriptor contract',
|
|
57
|
+
(_id: string, descriptor: IProviderDescriptor) => {
|
|
58
|
+
expect(descriptor.label.trim()).toBeTruthy();
|
|
59
|
+
expect(descriptor.panelTitle.trim()).toBeTruthy();
|
|
60
|
+
expect(descriptor.cliBinary.trim()).toBeTruthy();
|
|
61
|
+
// The icon name is namespaced so two providers' LabIcons never collide.
|
|
62
|
+
expect(descriptor.iconName).toEqual(
|
|
63
|
+
`jupyterlab_ai_code_assistants_extension:${descriptor.id}`
|
|
64
|
+
);
|
|
65
|
+
expect(descriptor.iconSvg).toContain('<svg');
|
|
66
|
+
expect(FORK_STRATEGIES).toContain(descriptor.forkStrategy);
|
|
67
|
+
expect(COLOUR_SOURCES).toContain(descriptor.colourSource);
|
|
68
|
+
expect(NAMING_STRATEGIES).toContain(descriptor.namingStrategy);
|
|
69
|
+
expect(typeof descriptor.mintsNewSessionId).toBe('boolean');
|
|
70
|
+
expect(typeof descriptor.hasRemoteControl).toBe('boolean');
|
|
71
|
+
expect(typeof descriptor.hasBgAgents).toBe('boolean');
|
|
72
|
+
expect(typeof descriptor.hasLiveProcess).toBe('boolean');
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
it.each(PROVIDERS.map(m => [m.descriptor.id, m.descriptor]))(
|
|
77
|
+
'%s declares its launch modes in its own terminology',
|
|
78
|
+
(_id: string, descriptor: IProviderDescriptor) => {
|
|
79
|
+
for (const mode of descriptor.launchModes) {
|
|
80
|
+
expect(mode.id.trim()).toBeTruthy();
|
|
81
|
+
expect(mode.title.trim()).toBeTruthy();
|
|
82
|
+
expect(mode.description.trim()).toBeTruthy();
|
|
83
|
+
if (mode.kind === 'boolean') {
|
|
84
|
+
// Every unsafe switch is off by default, without exception.
|
|
85
|
+
expect(mode.default).toBe(false);
|
|
86
|
+
} else {
|
|
87
|
+
expect(mode.values).toBeDefined();
|
|
88
|
+
expect(mode.values).toContain(mode.default as string);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const ids = descriptor.launchModes.map(m => m.id);
|
|
92
|
+
expect(new Set(ids).size).toEqual(ids.length);
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('ProviderRegistry', () => {
|
|
98
|
+
it('keeps the barrel order', () => {
|
|
99
|
+
const registry = new ProviderRegistry(PROVIDERS);
|
|
100
|
+
expect(registry.ids).toEqual(PROVIDERS.map(m => m.descriptor.id));
|
|
101
|
+
expect(registry.descriptors.map(d => d.id)).toEqual(registry.ids);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('throws on a duplicate id rather than shadowing one', () => {
|
|
105
|
+
expect(() => new ProviderRegistry([stub('twin'), stub('twin')])).toThrow(
|
|
106
|
+
/Duplicate/
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('ignores a module with no descriptor id, and warns once', () => {
|
|
111
|
+
const warn = jest
|
|
112
|
+
.spyOn(console, 'warn')
|
|
113
|
+
.mockImplementation(() => undefined);
|
|
114
|
+
const registry = new ProviderRegistry([
|
|
115
|
+
{ descriptor: undefined } as unknown as IProviderModule,
|
|
116
|
+
stub('alpha')
|
|
117
|
+
]);
|
|
118
|
+
expect(registry.ids).toEqual(['alpha']);
|
|
119
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
120
|
+
warn.mockRestore();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('answers null for an id it does not know', () => {
|
|
124
|
+
const registry = new ProviderRegistry([stub('alpha')]);
|
|
125
|
+
expect(registry.has('alpha')).toBe(true);
|
|
126
|
+
expect(registry.has('stale')).toBe(false);
|
|
127
|
+
// A stale id in saved settings is a null, never a hard failure.
|
|
128
|
+
expect(registry.get('stale')).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('descriptor filtering', () => {
|
|
133
|
+
// Both halves of availability - the user's setting and the binary on PATH -
|
|
134
|
+
// must hold for a panel to be present. This is the predicate the activation
|
|
135
|
+
// path reconciles against.
|
|
136
|
+
const enabledAndAvailable = (
|
|
137
|
+
descriptors: IProviderDescriptor[],
|
|
138
|
+
settings: Record<string, boolean>,
|
|
139
|
+
available: Record<string, boolean>
|
|
140
|
+
): string[] =>
|
|
141
|
+
descriptors
|
|
142
|
+
.filter(d => settings[d.id] !== false && available[d.id] === true)
|
|
143
|
+
.map(d => d.id);
|
|
144
|
+
|
|
145
|
+
const registry = new ProviderRegistry(PROVIDERS);
|
|
146
|
+
|
|
147
|
+
// `Object.fromEntries` is ES2019 and this project targets ES2018.
|
|
148
|
+
const allPresent = (): Record<string, boolean> => {
|
|
149
|
+
const out: Record<string, boolean> = {};
|
|
150
|
+
registry.ids.forEach(id => (out[id] = true));
|
|
151
|
+
return out;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
it('docks a provider only when the setting and the binary agree', () => {
|
|
155
|
+
const all = allPresent();
|
|
156
|
+
expect(enabledAndAvailable(registry.descriptors, {}, all)).toEqual(
|
|
157
|
+
registry.ids
|
|
158
|
+
);
|
|
159
|
+
expect(
|
|
160
|
+
enabledAndAvailable(registry.descriptors, { claude: false }, all)
|
|
161
|
+
).not.toContain('claude');
|
|
162
|
+
expect(
|
|
163
|
+
enabledAndAvailable(registry.descriptors, {}, { ...all, codex: false })
|
|
164
|
+
).not.toContain('codex');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('treats a missing enable key as enabled', () => {
|
|
168
|
+
const all = allPresent();
|
|
169
|
+
// Only an explicit `false` disables, so a fresh install runs everything.
|
|
170
|
+
expect(enabledAndAvailable(registry.descriptors, {}, all).length).toEqual(
|
|
171
|
+
registry.ids.length
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('leaves zero panels when nothing is available, without throwing', () => {
|
|
176
|
+
expect(enabledAndAvailable(registry.descriptors, {}, {})).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
});
|