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.
Files changed (57) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +44 -0
  3. package/lib/core/colour.d.ts +57 -0
  4. package/lib/core/colour.js +190 -0
  5. package/lib/core/icons.d.ts +12 -0
  6. package/lib/core/icons.js +99 -0
  7. package/lib/core/modes.d.ts +13 -0
  8. package/lib/core/modes.js +36 -0
  9. package/lib/core/panel.d.ts +233 -0
  10. package/lib/core/panel.js +1673 -0
  11. package/lib/core/popup.d.ts +40 -0
  12. package/lib/core/popup.js +404 -0
  13. package/lib/core/registry.d.ts +24 -0
  14. package/lib/core/registry.js +48 -0
  15. package/lib/core/request.d.ts +25 -0
  16. package/lib/core/request.js +64 -0
  17. package/lib/core/terminals.d.ts +129 -0
  18. package/lib/core/terminals.js +344 -0
  19. package/lib/core/types.d.ts +269 -0
  20. package/lib/core/types.js +6 -0
  21. package/lib/index.d.ts +3 -0
  22. package/lib/index.js +277 -0
  23. package/lib/providers/claude.d.ts +3 -0
  24. package/lib/providers/claude.js +88 -0
  25. package/lib/providers/codex.d.ts +3 -0
  26. package/lib/providers/codex.js +79 -0
  27. package/lib/providers/gemini.d.ts +2 -0
  28. package/lib/providers/gemini.js +73 -0
  29. package/lib/providers/index.d.ts +3 -0
  30. package/lib/providers/index.js +16 -0
  31. package/lib/providers/kimi.d.ts +23 -0
  32. package/lib/providers/kimi.js +139 -0
  33. package/package.json +169 -0
  34. package/schema/plugin.json +93 -0
  35. package/src/__tests__/colour.spec.ts +153 -0
  36. package/src/__tests__/core-neutrality.spec.ts +86 -0
  37. package/src/__tests__/labels.spec.ts +89 -0
  38. package/src/__tests__/launch-mode.spec.ts +53 -0
  39. package/src/__tests__/registry.spec.ts +178 -0
  40. package/src/core/colour.ts +224 -0
  41. package/src/core/icons.ts +121 -0
  42. package/src/core/modes.ts +45 -0
  43. package/src/core/panel.ts +2020 -0
  44. package/src/core/popup.ts +475 -0
  45. package/src/core/registry.ts +60 -0
  46. package/src/core/request.ts +96 -0
  47. package/src/core/terminals.ts +446 -0
  48. package/src/core/types.ts +305 -0
  49. package/src/index.ts +356 -0
  50. package/src/providers/claude.ts +102 -0
  51. package/src/providers/codex.ts +89 -0
  52. package/src/providers/gemini.ts +80 -0
  53. package/src/providers/index.ts +21 -0
  54. package/src/providers/kimi.ts +155 -0
  55. package/style/base.css +888 -0
  56. package/style/index.css +1 -0
  57. 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
+ });