@agentconnect/ui 0.1.0

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 (68) hide show
  1. package/README.md +63 -0
  2. package/dist/client.d.ts +15 -0
  3. package/dist/client.d.ts.map +1 -0
  4. package/dist/client.js +22 -0
  5. package/dist/components/connect.d.ts +79 -0
  6. package/dist/components/connect.d.ts.map +1 -0
  7. package/dist/components/connect.js +1110 -0
  8. package/dist/components/index.d.ts +8 -0
  9. package/dist/components/index.d.ts.map +1 -0
  10. package/dist/components/index.js +7 -0
  11. package/dist/components/login-button.d.ts +16 -0
  12. package/dist/components/login-button.d.ts.map +1 -0
  13. package/dist/components/login-button.js +69 -0
  14. package/dist/components/model-picker.d.ts +16 -0
  15. package/dist/components/model-picker.d.ts.map +1 -0
  16. package/dist/components/model-picker.js +61 -0
  17. package/dist/components/provider-status.d.ts +10 -0
  18. package/dist/components/provider-status.d.ts.map +1 -0
  19. package/dist/components/provider-status.js +42 -0
  20. package/dist/constants.d.ts +33 -0
  21. package/dist/constants.d.ts.map +1 -0
  22. package/dist/constants.js +156 -0
  23. package/dist/index.d.ts +11 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +13 -0
  26. package/dist/register.d.ts +9 -0
  27. package/dist/register.d.ts.map +1 -0
  28. package/dist/register.js +25 -0
  29. package/dist/styles/base.d.ts +5 -0
  30. package/dist/styles/base.d.ts.map +1 -0
  31. package/dist/styles/base.js +49 -0
  32. package/dist/styles/buttons.d.ts +6 -0
  33. package/dist/styles/buttons.d.ts.map +1 -0
  34. package/dist/styles/buttons.js +206 -0
  35. package/dist/styles/cards.d.ts +5 -0
  36. package/dist/styles/cards.d.ts.map +1 -0
  37. package/dist/styles/cards.js +212 -0
  38. package/dist/styles/index.d.ts +20 -0
  39. package/dist/styles/index.d.ts.map +1 -0
  40. package/dist/styles/index.js +42 -0
  41. package/dist/styles/inputs.d.ts +5 -0
  42. package/dist/styles/inputs.d.ts.map +1 -0
  43. package/dist/styles/inputs.js +79 -0
  44. package/dist/styles/panels.d.ts +5 -0
  45. package/dist/styles/panels.d.ts.map +1 -0
  46. package/dist/styles/panels.js +184 -0
  47. package/dist/styles/responsive.d.ts +5 -0
  48. package/dist/styles/responsive.d.ts.map +1 -0
  49. package/dist/styles/responsive.js +62 -0
  50. package/dist/styles/utilities.d.ts +5 -0
  51. package/dist/styles/utilities.d.ts.map +1 -0
  52. package/dist/styles/utilities.js +86 -0
  53. package/dist/types.d.ts +137 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +4 -0
  56. package/dist/utils/dom.d.ts +24 -0
  57. package/dist/utils/dom.d.ts.map +1 -0
  58. package/dist/utils/dom.js +42 -0
  59. package/dist/utils/html.d.ts +8 -0
  60. package/dist/utils/html.d.ts.map +1 -0
  61. package/dist/utils/html.js +11 -0
  62. package/dist/utils/index.d.ts +7 -0
  63. package/dist/utils/index.d.ts.map +1 -0
  64. package/dist/utils/index.js +6 -0
  65. package/dist/utils/storage.d.ts +29 -0
  66. package/dist/utils/storage.d.ts.map +1 -0
  67. package/dist/utils/storage.js +110 -0
  68. package/package.json +40 -0
@@ -0,0 +1,1110 @@
1
+ /**
2
+ * Main AgentConnect connect component.
3
+ * Handles provider selection, model picking, and connection management.
4
+ */
5
+ import { getClient } from '../client';
6
+ import { ERROR_MESSAGES, PROVIDER_ICONS, CONNECT_TEMPLATE, STORAGE_KEYS } from '../constants';
7
+ import { ensureStyles } from '../styles';
8
+ import { escapeHtml, readLocalConfig, persistLocalConfig, readSelection, saveSelection, clearSelection, buildScopeId, parseCommaSeparated, setCssVar, } from '../utils';
9
+ export class AgentConnectConnect extends HTMLElement {
10
+ state;
11
+ localConfig;
12
+ prefetching;
13
+ busy;
14
+ busyMessage;
15
+ isSelectingModel;
16
+ pendingModelRender;
17
+ popoverLocked;
18
+ popoverPosition;
19
+ modelsProvider;
20
+ modelsFetchedAt;
21
+ elements;
22
+ handleResize;
23
+ loginPollTimer;
24
+ loginPollIntervalMs = 2000;
25
+ modelsRefreshIntervalMs = 30_000;
26
+ constructor() {
27
+ super();
28
+ this.attachShadow({ mode: 'open' });
29
+ this.state = this.createInitialState();
30
+ this.localConfig = readLocalConfig(this.localConfigKey);
31
+ this.prefetching = false;
32
+ this.busy = false;
33
+ this.busyMessage = null;
34
+ this.isSelectingModel = false;
35
+ this.pendingModelRender = false;
36
+ this.popoverLocked = false;
37
+ this.popoverPosition = null;
38
+ this.modelsProvider = null;
39
+ this.modelsFetchedAt = 0;
40
+ this.elements = null;
41
+ this.loginPollTimer = null;
42
+ this.handleResize = () => {
43
+ if (this.state.view === 'connected' && this.elements?.overlay?.classList.contains('open')) {
44
+ this.positionPopover(true);
45
+ }
46
+ };
47
+ }
48
+ connectedCallback() {
49
+ ensureStyles(this.shadowRoot);
50
+ this.render();
51
+ this.restoreSelection();
52
+ this.updateButtonLabel();
53
+ window.addEventListener('resize', this.handleResize);
54
+ this.prefetchProviders();
55
+ }
56
+ disconnectedCallback() {
57
+ window.removeEventListener('resize', this.handleResize);
58
+ this.stopLoginPolling();
59
+ }
60
+ createInitialState() {
61
+ return {
62
+ connected: null,
63
+ selectedProvider: null,
64
+ selectedModel: null,
65
+ selectedReasoningEffort: null,
66
+ providers: [],
67
+ models: [],
68
+ modelsLoading: false,
69
+ view: 'connect',
70
+ loginExperience: null,
71
+ };
72
+ }
73
+ get storageKey() {
74
+ return this.getAttribute('storage-key') || STORAGE_KEYS.lastSelection;
75
+ }
76
+ get localConfigKey() {
77
+ return this.getAttribute('local-config-key') || STORAGE_KEYS.localConfig;
78
+ }
79
+ render() {
80
+ if (this.elements)
81
+ return;
82
+ this.shadowRoot.innerHTML = '';
83
+ ensureStyles(this.shadowRoot);
84
+ const container = document.createElement('div');
85
+ container.innerHTML = CONNECT_TEMPLATE;
86
+ this.shadowRoot.appendChild(container);
87
+ this.cacheElements();
88
+ this.bindEvents();
89
+ }
90
+ cacheElements() {
91
+ const root = this.shadowRoot;
92
+ this.elements = {
93
+ button: root.querySelector('.ac-connect-button'),
94
+ overlay: root.querySelector('.ac-overlay'),
95
+ connectPanel: root.querySelector('.ac-panel[data-view="connect"]'),
96
+ localPanel: root.querySelector('.ac-panel[data-view="local"]'),
97
+ popoverPanel: root.querySelector('.ac-panel[data-view="connected"]'),
98
+ providerList: root.querySelector('.ac-provider-list'),
99
+ connectedModelSelect: root.querySelector('.ac-connected-model'),
100
+ connectedEffortSelect: root.querySelector('.ac-connected-effort'),
101
+ effortField: root.querySelector('.ac-effort-field'),
102
+ modelLoading: root.querySelector('.ac-model-loading'),
103
+ localStatus: root.querySelector('.ac-local-status'),
104
+ closeButtons: Array.from(root.querySelectorAll('.ac-close')),
105
+ disconnectButtons: Array.from(root.querySelectorAll('.ac-disconnect')),
106
+ backButton: root.querySelector('.ac-back'),
107
+ saveLocalButton: root.querySelector('.ac-save-local'),
108
+ localBaseInput: root.querySelector('.ac-local-base'),
109
+ localModelInput: root.querySelector('.ac-local-model'),
110
+ localKeyInput: root.querySelector('.ac-local-key'),
111
+ localModelsInput: root.querySelector('.ac-local-models'),
112
+ popoverTitle: root.querySelector('.ac-popover-title'),
113
+ progressWrap: root.querySelector('.ac-progress'),
114
+ progressLabel: root.querySelector('.ac-progress-label'),
115
+ };
116
+ }
117
+ bindEvents() {
118
+ if (!this.elements)
119
+ return;
120
+ const { button, overlay, closeButtons, disconnectButtons, backButton, saveLocalButton, connectedModelSelect, connectedEffortSelect, } = this.elements;
121
+ button?.addEventListener('click', () => this.open());
122
+ closeButtons.forEach((item) => item.addEventListener('click', () => this.close()));
123
+ disconnectButtons.forEach((item) => item.addEventListener('click', () => this.disconnect()));
124
+ backButton?.addEventListener('click', () => this.setView('connect'));
125
+ saveLocalButton?.addEventListener('click', () => this.saveLocalConfig());
126
+ overlay?.addEventListener('click', (event) => {
127
+ if (event.target === overlay)
128
+ this.close();
129
+ });
130
+ connectedModelSelect?.addEventListener('change', () => {
131
+ const value = connectedModelSelect.value || null;
132
+ if (!value || !this.state.connected)
133
+ return;
134
+ this.state.selectedModel = value;
135
+ this.syncReasoningEffort();
136
+ this.renderReasoningEfforts();
137
+ this.applySelection(this.state.connected.provider, value, false, this.state.selectedReasoningEffort);
138
+ this.isSelectingModel = false;
139
+ this.flushModelRender();
140
+ });
141
+ connectedModelSelect?.addEventListener('pointerdown', () => {
142
+ this.isSelectingModel = true;
143
+ });
144
+ connectedModelSelect?.addEventListener('focus', () => {
145
+ this.isSelectingModel = true;
146
+ });
147
+ connectedModelSelect?.addEventListener('blur', () => {
148
+ this.isSelectingModel = false;
149
+ this.flushModelRender();
150
+ });
151
+ connectedEffortSelect?.addEventListener('change', () => {
152
+ const value = connectedEffortSelect.value || null;
153
+ if (!this.state.connected || !this.state.selectedModel)
154
+ return;
155
+ this.state.selectedReasoningEffort = value;
156
+ this.applySelection(this.state.connected.provider, this.state.selectedModel, false, this.state.selectedReasoningEffort);
157
+ });
158
+ }
159
+ open() {
160
+ this.elements?.overlay?.classList.add('open');
161
+ this.localConfig = readLocalConfig(this.localConfigKey);
162
+ if (!this.state.connected) {
163
+ this.restoreSelection(true);
164
+ }
165
+ if (!this.state.providers.length) {
166
+ this.state.providers = this.getFallbackProviders();
167
+ this.renderProviders();
168
+ }
169
+ this.setView(this.state.connected ? 'connected' : 'connect');
170
+ this.refresh();
171
+ if (!this.state.models.length) {
172
+ this.startLoginPolling();
173
+ }
174
+ else {
175
+ this.stopLoginPolling();
176
+ }
177
+ }
178
+ close() {
179
+ this.elements?.overlay?.classList.remove('open');
180
+ this.stopLoginPolling();
181
+ this.popoverLocked = false;
182
+ this.popoverPosition = null;
183
+ }
184
+ setView(view) {
185
+ this.state.view = view;
186
+ const { overlay, connectPanel, localPanel, popoverPanel } = this.elements ?? {};
187
+ if (overlay) {
188
+ overlay.dataset.mode = view === 'connected' ? 'popover' : 'modal';
189
+ }
190
+ this.setPanelActive(connectPanel ?? null, view === 'connect');
191
+ this.setPanelActive(localPanel ?? null, view === 'local');
192
+ this.setPanelActive(popoverPanel ?? null, view === 'connected');
193
+ if (view === 'local') {
194
+ this.populateLocalForm();
195
+ }
196
+ if (view === 'connected') {
197
+ this.popoverLocked = false;
198
+ this.popoverPosition = null;
199
+ if (!this.state.models.length) {
200
+ this.startLoginPolling();
201
+ }
202
+ else {
203
+ this.stopLoginPolling();
204
+ }
205
+ this.renderConnectedModels();
206
+ this.renderReasoningEfforts();
207
+ requestAnimationFrame(() => this.positionPopover());
208
+ }
209
+ }
210
+ setPanelActive(panel, active) {
211
+ if (!panel)
212
+ return;
213
+ panel.hidden = !active;
214
+ panel.dataset.active = active ? 'true' : 'false';
215
+ }
216
+ positionPopover(force = false) {
217
+ const { overlay, button, popoverPanel } = this.elements ?? {};
218
+ if (!overlay || !button || !popoverPanel)
219
+ return;
220
+ if (this.popoverLocked && this.popoverPosition && !force) {
221
+ setCssVar(overlay, '--ac-popover-top', `${this.popoverPosition.top}px`);
222
+ setCssVar(overlay, '--ac-popover-left', `${this.popoverPosition.left}px`);
223
+ return;
224
+ }
225
+ const rect = button.getBoundingClientRect();
226
+ const panelRect = popoverPanel.getBoundingClientRect();
227
+ const panelWidth = panelRect.width || 320;
228
+ const panelHeight = panelRect.height || 220;
229
+ const gap = 8;
230
+ let left = rect.left;
231
+ if (left + panelWidth > window.innerWidth - 12) {
232
+ left = window.innerWidth - panelWidth - 12;
233
+ }
234
+ if (left < 12)
235
+ left = 12;
236
+ let top = rect.bottom + gap;
237
+ if (top + panelHeight > window.innerHeight - 12) {
238
+ top = rect.top - panelHeight - gap;
239
+ }
240
+ const finalTop = Math.max(12, top);
241
+ const finalLeft = Math.max(12, left);
242
+ this.popoverPosition = { top: finalTop, left: finalLeft };
243
+ this.popoverLocked = true;
244
+ setCssVar(overlay, '--ac-popover-top', `${finalTop}px`);
245
+ setCssVar(overlay, '--ac-popover-left', `${finalLeft}px`);
246
+ }
247
+ hasLocalConfig() {
248
+ return Boolean(this.localConfig?.model ||
249
+ this.localConfig?.baseUrl ||
250
+ (Array.isArray(this.localConfig?.models) && this.localConfig.models.length));
251
+ }
252
+ getLocalModelOptions() {
253
+ const options = new Set();
254
+ if (this.localConfig?.model)
255
+ options.add(this.localConfig.model);
256
+ if (Array.isArray(this.localConfig?.models)) {
257
+ for (const model of this.localConfig.models) {
258
+ if (model)
259
+ options.add(model);
260
+ }
261
+ }
262
+ return Array.from(options).map((id) => ({ id, provider: 'local', displayName: id }));
263
+ }
264
+ getFallbackProviders() {
265
+ return [
266
+ { id: 'claude', name: 'Claude', installed: false, loggedIn: false, pending: true },
267
+ { id: 'codex', name: 'Codex', installed: false, loggedIn: false, pending: true },
268
+ { id: 'local', name: 'Local', installed: false, loggedIn: false, pending: true },
269
+ ];
270
+ }
271
+ populateLocalForm() {
272
+ const { localBaseInput, localModelInput, localKeyInput, localModelsInput } = this.elements ?? {};
273
+ if (!localBaseInput)
274
+ return;
275
+ localBaseInput.value = this.localConfig?.baseUrl || '';
276
+ if (localModelInput)
277
+ localModelInput.value = this.localConfig?.model || '';
278
+ if (localKeyInput)
279
+ localKeyInput.value = this.localConfig?.apiKey || '';
280
+ if (localModelsInput) {
281
+ localModelsInput.value = Array.isArray(this.localConfig?.models)
282
+ ? this.localConfig.models.join(', ')
283
+ : '';
284
+ }
285
+ }
286
+ restoreSelection(silent = false) {
287
+ const selection = readSelection(this.storageKey);
288
+ if (selection) {
289
+ this.state.connected = selection;
290
+ this.state.selectedProvider = selection.provider;
291
+ this.state.selectedModel = selection.model;
292
+ this.state.selectedReasoningEffort = selection.reasoningEffort;
293
+ if (!silent) {
294
+ setTimeout(() => {
295
+ this.dispatchSelectionEvent('agentconnect:connected', selection, null, true);
296
+ }, 0);
297
+ }
298
+ }
299
+ }
300
+ async refresh(options = {}) {
301
+ const { silent = false } = options;
302
+ if (!silent) {
303
+ this.setStatus('');
304
+ }
305
+ try {
306
+ const client = await getClient();
307
+ if (!this.state.loginExperience) {
308
+ const hello = await client.hello().catch(() => null);
309
+ this.state.loginExperience = hello?.loginExperience ?? null;
310
+ }
311
+ const providers = await client.providers.list();
312
+ this.state.providers = providers;
313
+ if (this.state.connected) {
314
+ this.state.selectedProvider = this.state.connected.provider;
315
+ this.state.selectedModel = this.state.connected.model;
316
+ }
317
+ else if (!this.state.selectedProvider) {
318
+ this.state.selectedProvider = providers[0]?.id || null;
319
+ }
320
+ const shouldRefreshModels = !this.state.selectedProvider ||
321
+ this.state.models.length === 0 ||
322
+ this.modelsProvider !== this.state.selectedProvider ||
323
+ Date.now() - this.modelsFetchedAt > this.modelsRefreshIntervalMs;
324
+ if (shouldRefreshModels) {
325
+ await this.refreshModels();
326
+ }
327
+ if (this.state.connected && this.state.selectedModel) {
328
+ const effortChanged = this.state.connected.reasoningEffort !== this.state.selectedReasoningEffort;
329
+ const modelChanged = this.state.connected.model !== this.state.selectedModel;
330
+ if (modelChanged || effortChanged) {
331
+ this.applySelection(this.state.selectedProvider, this.state.selectedModel, false, this.state.selectedReasoningEffort);
332
+ }
333
+ }
334
+ this.renderProviders();
335
+ this.renderConnectedModels();
336
+ this.renderReasoningEfforts();
337
+ this.updatePopoverTitle();
338
+ this.updateButtonLabel();
339
+ if (this.state.view === 'connected') {
340
+ requestAnimationFrame(() => this.positionPopover());
341
+ }
342
+ }
343
+ catch {
344
+ if (!silent) {
345
+ this.setAlert({
346
+ type: 'error',
347
+ ...ERROR_MESSAGES.connection_failed,
348
+ onAction: () => this.refresh(),
349
+ });
350
+ }
351
+ }
352
+ }
353
+ startLoginPolling() {
354
+ if (this.loginPollTimer)
355
+ return;
356
+ this.loginPollTimer = setInterval(() => {
357
+ if (!this.elements?.overlay?.classList.contains('open')) {
358
+ this.stopLoginPolling();
359
+ return;
360
+ }
361
+ if (this.busy)
362
+ return;
363
+ this.pollProviderStatus().catch(() => { });
364
+ }, this.loginPollIntervalMs);
365
+ }
366
+ stopLoginPolling() {
367
+ if (!this.loginPollTimer)
368
+ return;
369
+ clearInterval(this.loginPollTimer);
370
+ this.loginPollTimer = null;
371
+ }
372
+ async pollProviderStatus() {
373
+ const client = await getClient();
374
+ const previous = this.state.providers;
375
+ const providers = await client.providers.list();
376
+ this.state.providers = providers;
377
+ if (this.state.view === 'connected') {
378
+ this.updatePopoverTitle();
379
+ this.updateButtonLabel();
380
+ }
381
+ else {
382
+ this.renderProviders();
383
+ this.updatePopoverTitle();
384
+ this.updateButtonLabel();
385
+ }
386
+ const selected = this.state.selectedProvider;
387
+ if (!selected)
388
+ return;
389
+ const before = previous.find((entry) => entry.id === selected);
390
+ const after = providers.find((entry) => entry.id === selected);
391
+ if (before && after && !before.loggedIn && after.loggedIn) {
392
+ await this.refreshModels();
393
+ this.renderConnectedModels();
394
+ this.renderReasoningEfforts();
395
+ }
396
+ if (after?.loggedIn && this.state.models.length > 0) {
397
+ this.stopLoginPolling();
398
+ }
399
+ }
400
+ async prefetchProviders() {
401
+ if (this.prefetching)
402
+ return;
403
+ this.prefetching = true;
404
+ await this.refresh({ silent: true });
405
+ await this.prefetchAllProviderModels();
406
+ this.prefetching = false;
407
+ }
408
+ async prefetchAllProviderModels() {
409
+ if (!this.state.providers.length)
410
+ return;
411
+ const client = await getClient();
412
+ await Promise.allSettled(this.state.providers.map((provider) => client.models.list(provider.id)));
413
+ }
414
+ async refreshModels() {
415
+ if (!this.state.selectedProvider) {
416
+ this.state.models = [];
417
+ this.state.modelsLoading = false;
418
+ return;
419
+ }
420
+ this.state.modelsLoading = true;
421
+ this.renderConnectedModels();
422
+ const providerId = this.state.selectedProvider;
423
+ try {
424
+ const client = await getClient();
425
+ const models = await client.models.list(providerId);
426
+ let resolvedModels = models;
427
+ if (providerId === 'local') {
428
+ const localOptions = this.getLocalModelOptions();
429
+ if (localOptions.length) {
430
+ const merged = [...localOptions, ...models];
431
+ const seen = new Set();
432
+ resolvedModels = merged.filter((entry) => {
433
+ if (seen.has(entry.id))
434
+ return false;
435
+ seen.add(entry.id);
436
+ return true;
437
+ });
438
+ }
439
+ }
440
+ this.state.models = resolvedModels;
441
+ this.modelsProvider = providerId;
442
+ this.modelsFetchedAt = Date.now();
443
+ if (!this.state.selectedModel ||
444
+ !resolvedModels.find((m) => m.id === this.state.selectedModel)) {
445
+ this.state.selectedModel = resolvedModels[0]?.id || null;
446
+ }
447
+ this.syncReasoningEffort();
448
+ }
449
+ finally {
450
+ this.state.modelsLoading = false;
451
+ this.renderConnectedModels();
452
+ }
453
+ }
454
+ renderProviders() {
455
+ const { providerList } = this.elements ?? {};
456
+ if (!providerList)
457
+ return;
458
+ const fragment = document.createDocumentFragment();
459
+ for (const provider of this.state.providers) {
460
+ const card = this.buildProviderCard(provider);
461
+ fragment.appendChild(card);
462
+ }
463
+ providerList.replaceChildren(fragment);
464
+ }
465
+ buildProviderCard(provider) {
466
+ const card = document.createElement('div');
467
+ card.className = 'ac-provider-card';
468
+ card.dataset.provider = provider.id;
469
+ const isPending = provider.pending === true;
470
+ if (isPending) {
471
+ card.classList.add('loading');
472
+ }
473
+ if (provider.id === this.state.connected?.provider && !isPending) {
474
+ card.classList.add('active');
475
+ }
476
+ const statusText = this.getProviderStatusText(provider);
477
+ const svgIcon = PROVIDER_ICONS[provider.id] || null;
478
+ const initials = (provider.name || provider.id)
479
+ .split(' ')
480
+ .map((part) => part[0])
481
+ .join('')
482
+ .slice(0, 2)
483
+ .toUpperCase();
484
+ const row = document.createElement('div');
485
+ row.className = 'ac-provider-row';
486
+ const meta = document.createElement('div');
487
+ meta.className = 'ac-provider-meta';
488
+ const icon = document.createElement('div');
489
+ icon.className = 'ac-provider-icon';
490
+ icon.dataset.provider = provider.id;
491
+ if (svgIcon) {
492
+ icon.innerHTML = svgIcon;
493
+ }
494
+ else {
495
+ const iconLabel = document.createElement('span');
496
+ iconLabel.textContent = initials;
497
+ icon.appendChild(iconLabel);
498
+ }
499
+ const textWrap = document.createElement('div');
500
+ const name = document.createElement('div');
501
+ name.className = 'ac-provider-name';
502
+ name.textContent = provider.name || provider.id;
503
+ const status = document.createElement('div');
504
+ status.className = 'ac-provider-status';
505
+ status.textContent = statusText;
506
+ textWrap.append(name, status);
507
+ meta.append(icon, textWrap);
508
+ row.appendChild(meta);
509
+ card.appendChild(row);
510
+ card.addEventListener('click', () => {
511
+ this.handleProviderSelect(provider);
512
+ });
513
+ const action = this.buildProviderAction(provider);
514
+ if (action) {
515
+ const actions = document.createElement('div');
516
+ actions.className = 'ac-provider-actions';
517
+ actions.appendChild(action);
518
+ card.appendChild(actions);
519
+ }
520
+ return card;
521
+ }
522
+ getProviderStatusText(provider) {
523
+ const pending = provider.pending === true;
524
+ const isLocal = provider.id === 'local';
525
+ const hasLocalConfig = this.hasLocalConfig();
526
+ const isConnected = this.state.connected?.provider === provider.id;
527
+ if (pending)
528
+ return 'Checking...';
529
+ if (isLocal) {
530
+ if (!hasLocalConfig)
531
+ return 'Needs setup';
532
+ return provider.installed ? 'Configured' : 'Offline';
533
+ }
534
+ if (!provider.installed)
535
+ return 'Not installed';
536
+ if (!provider.loggedIn)
537
+ return 'Login needed';
538
+ if (isConnected)
539
+ return 'Connected';
540
+ return 'Detected';
541
+ }
542
+ buildProviderAction(provider) {
543
+ const pending = provider.pending === true;
544
+ const isLocal = provider.id === 'local';
545
+ const hasLocalConfig = this.hasLocalConfig();
546
+ const isConnected = this.state.connected?.provider === provider.id;
547
+ const terminalLogin = provider.id === 'claude' && this.state.loginExperience === 'terminal';
548
+ if (pending) {
549
+ return this.buildActionButton('Checking...', true);
550
+ }
551
+ if (isLocal) {
552
+ return this.buildActionButton(hasLocalConfig ? 'Edit' : 'Configure', false, () => this.openLocalConfig());
553
+ }
554
+ if (!provider.installed) {
555
+ const label = terminalLogin ? 'Install + Run /login' : 'Install + Login';
556
+ const button = this.buildActionButton(label, false, () => this.connectProvider(provider));
557
+ if (terminalLogin) {
558
+ button.title = 'Opens a terminal and runs claude login';
559
+ }
560
+ return button;
561
+ }
562
+ if (!provider.loggedIn) {
563
+ const label = terminalLogin ? 'Run /login' : 'Login';
564
+ const button = this.buildActionButton(label, false, () => this.connectProvider(provider));
565
+ if (terminalLogin) {
566
+ button.title = 'Opens a terminal and runs claude login';
567
+ }
568
+ return button;
569
+ }
570
+ if (isConnected)
571
+ return null;
572
+ return null;
573
+ }
574
+ buildActionButton(label, disabled, onClick, loading = false) {
575
+ const button = document.createElement('button');
576
+ button.className = 'ac-button secondary';
577
+ if (loading) {
578
+ button.classList.add('loading');
579
+ }
580
+ button.textContent = label;
581
+ button.disabled = Boolean(disabled || loading);
582
+ if (onClick) {
583
+ button.addEventListener('click', (event) => {
584
+ event.stopPropagation();
585
+ onClick();
586
+ });
587
+ }
588
+ return button;
589
+ }
590
+ async handleProviderSelect(provider) {
591
+ if (this.busy)
592
+ return;
593
+ if (provider.pending)
594
+ return;
595
+ if (provider.id === 'local') {
596
+ this.openLocalConfig();
597
+ return;
598
+ }
599
+ await this.connectProvider(provider);
600
+ }
601
+ async connectProvider(provider) {
602
+ if (this.busy)
603
+ return;
604
+ this.setAlert(null);
605
+ const providerName = provider.name || provider.id;
606
+ this.setBusy(true, `Connecting to ${providerName}...`);
607
+ this.setProviderLoading(provider.id, true, 'Connecting...');
608
+ try {
609
+ const ready = await this.ensureProviderReady(provider, providerName);
610
+ if (!ready)
611
+ return;
612
+ this.state.selectedProvider = provider.id;
613
+ await this.refreshModels();
614
+ const model = this.state.selectedModel || this.state.models[0]?.id;
615
+ if (!model) {
616
+ this.setAlert({
617
+ type: 'error',
618
+ ...ERROR_MESSAGES.no_models,
619
+ });
620
+ return;
621
+ }
622
+ this.applySelection(provider.id, model, true, this.state.selectedReasoningEffort);
623
+ this.refresh({ silent: true });
624
+ }
625
+ catch {
626
+ this.setAlert({
627
+ type: 'error',
628
+ ...ERROR_MESSAGES.login_failed,
629
+ onAction: () => this.connectProvider(provider),
630
+ });
631
+ }
632
+ finally {
633
+ this.setBusy(false);
634
+ this.setProviderLoading(provider.id, false);
635
+ }
636
+ }
637
+ setProviderLoading(providerId, loading, label = 'Working...') {
638
+ const card = this.elements?.providerList?.querySelector(`.ac-provider-card[data-provider="${providerId}"]`);
639
+ if (!card)
640
+ return;
641
+ const button = card.querySelector('.ac-provider-actions .ac-button');
642
+ if (button) {
643
+ button.disabled = loading;
644
+ if (loading) {
645
+ button.classList.add('loading');
646
+ button.dataset.originalText = button.textContent || '';
647
+ button.textContent = label;
648
+ }
649
+ else {
650
+ button.classList.remove('loading');
651
+ if (button.dataset.originalText) {
652
+ button.textContent = button.dataset.originalText;
653
+ }
654
+ }
655
+ }
656
+ }
657
+ async ensureProviderReady(provider, providerName) {
658
+ const client = await getClient();
659
+ let needsLogin = !provider.loggedIn;
660
+ if (!provider.installed) {
661
+ this.setBusy(true, `Installing ${providerName} CLI...`);
662
+ this.setProviderLoading(provider.id, true, 'Installing...');
663
+ const installed = await client.providers.ensureInstalled(provider.id);
664
+ if (!installed.installed) {
665
+ const pmInfo = installed.packageManager
666
+ ? ` Tried using ${installed.packageManager}.`
667
+ : '';
668
+ this.setAlert({
669
+ type: 'error',
670
+ title: ERROR_MESSAGES.install_failed.title,
671
+ message: `${ERROR_MESSAGES.install_failed.message}${pmInfo}`,
672
+ action: ERROR_MESSAGES.install_failed.action,
673
+ onAction: () => this.connectProvider(provider),
674
+ });
675
+ return false;
676
+ }
677
+ if (installed.packageManager && installed.packageManager !== 'unknown') {
678
+ this.setStatus(`Installed via ${installed.packageManager}`);
679
+ }
680
+ // Check if already logged in after installation
681
+ const status = await client.providers.status(provider.id);
682
+ needsLogin = !status.loggedIn;
683
+ }
684
+ if (needsLogin) {
685
+ this.setBusy(true, `Waiting for ${providerName} login...`);
686
+ this.setProviderLoading(provider.id, true, 'Logging in...');
687
+ const loginOptions = provider.id === 'claude' && this.state.loginExperience
688
+ ? { loginExperience: this.state.loginExperience }
689
+ : undefined;
690
+ const loggedIn = await client.providers.login(provider.id, loginOptions);
691
+ if (!loggedIn.loggedIn) {
692
+ this.setAlert({
693
+ type: 'error',
694
+ ...ERROR_MESSAGES.login_incomplete,
695
+ onAction: () => this.connectProvider(provider),
696
+ });
697
+ return false;
698
+ }
699
+ }
700
+ return true;
701
+ }
702
+ openLocalConfig() {
703
+ if (!this.elements?.overlay?.classList.contains('open')) {
704
+ this.elements?.overlay?.classList.add('open');
705
+ }
706
+ this.localConfig = readLocalConfig(this.localConfigKey);
707
+ this.setStatus('', 'local');
708
+ this.setView('local');
709
+ }
710
+ async saveLocalConfig() {
711
+ const { saveLocalButton, localBaseInput, localModelInput, localKeyInput, localModelsInput } = this.elements ?? {};
712
+ this.setAlert(null, 'local');
713
+ this.clearFieldErrors();
714
+ const config = {
715
+ baseUrl: localBaseInput?.value.trim() || '',
716
+ model: localModelInput?.value.trim() || '',
717
+ apiKey: localKeyInput?.value.trim() || '',
718
+ models: parseCommaSeparated(localModelsInput?.value || ''),
719
+ };
720
+ const validationErrors = this.validateLocalConfig(config);
721
+ if (validationErrors.length > 0) {
722
+ for (const err of validationErrors) {
723
+ this.showFieldError(err.field, err.message);
724
+ }
725
+ return;
726
+ }
727
+ if (saveLocalButton) {
728
+ saveLocalButton.disabled = true;
729
+ saveLocalButton.classList.add('loading');
730
+ }
731
+ this.localConfig = config;
732
+ persistLocalConfig(config, this.localConfigKey);
733
+ try {
734
+ const client = await getClient();
735
+ const result = await client.providers.login('local', config);
736
+ if (!result.loggedIn) {
737
+ this.setAlert({
738
+ type: 'error',
739
+ ...ERROR_MESSAGES.local_unreachable,
740
+ onAction: () => this.saveLocalConfig(),
741
+ }, 'local');
742
+ return;
743
+ }
744
+ this.state.selectedProvider = 'local';
745
+ await this.refreshModels();
746
+ const nextModel = this.state.selectedModel || this.state.models[0]?.id;
747
+ if (nextModel) {
748
+ this.applySelection('local', nextModel, true, this.state.selectedReasoningEffort);
749
+ }
750
+ else {
751
+ await this.refresh();
752
+ this.setView('connect');
753
+ }
754
+ }
755
+ catch {
756
+ this.setAlert({
757
+ type: 'error',
758
+ ...ERROR_MESSAGES.local_save_failed,
759
+ onAction: () => this.saveLocalConfig(),
760
+ }, 'local');
761
+ }
762
+ finally {
763
+ if (saveLocalButton) {
764
+ saveLocalButton.disabled = false;
765
+ saveLocalButton.classList.remove('loading');
766
+ }
767
+ }
768
+ }
769
+ renderConnectedModels() {
770
+ const { connectedModelSelect, modelLoading } = this.elements ?? {};
771
+ if (!connectedModelSelect)
772
+ return;
773
+ if (this.isSelectingModel) {
774
+ this.pendingModelRender = true;
775
+ return;
776
+ }
777
+ this.pendingModelRender = false;
778
+ if (this.state.modelsLoading) {
779
+ connectedModelSelect.style.display = 'none';
780
+ if (modelLoading)
781
+ modelLoading.style.display = 'flex';
782
+ return;
783
+ }
784
+ connectedModelSelect.style.display = '';
785
+ if (modelLoading)
786
+ modelLoading.style.display = 'none';
787
+ connectedModelSelect.innerHTML = '';
788
+ if (!this.state.models.length) {
789
+ if (!this.modelsFetchedAt) {
790
+ connectedModelSelect.style.display = 'none';
791
+ if (modelLoading)
792
+ modelLoading.style.display = 'flex';
793
+ return;
794
+ }
795
+ const option = document.createElement('option');
796
+ option.value = '';
797
+ option.textContent = 'No models available';
798
+ connectedModelSelect.appendChild(option);
799
+ return;
800
+ }
801
+ for (const model of this.state.models) {
802
+ const option = document.createElement('option');
803
+ option.value = model.id;
804
+ option.textContent = model.displayName || model.id;
805
+ connectedModelSelect.appendChild(option);
806
+ }
807
+ if (this.state.connected?.model) {
808
+ connectedModelSelect.value = this.state.connected.model;
809
+ return;
810
+ }
811
+ if (this.state.selectedModel) {
812
+ connectedModelSelect.value = this.state.selectedModel;
813
+ }
814
+ }
815
+ flushModelRender() {
816
+ if (!this.pendingModelRender)
817
+ return;
818
+ this.pendingModelRender = false;
819
+ this.renderConnectedModels();
820
+ }
821
+ getReasoningEffortsForModel(modelId) {
822
+ if (!modelId)
823
+ return [];
824
+ const model = this.state.models.find((entry) => entry.id === modelId);
825
+ if (!model || !Array.isArray(model.reasoningEfforts))
826
+ return [];
827
+ return model.reasoningEfforts.filter((entry) => entry && entry.id);
828
+ }
829
+ getDefaultReasoningEffort(modelId) {
830
+ if (!modelId)
831
+ return null;
832
+ const model = this.state.models.find((entry) => entry.id === modelId);
833
+ if (model?.defaultReasoningEffort)
834
+ return model.defaultReasoningEffort;
835
+ return null;
836
+ }
837
+ syncReasoningEffort() {
838
+ const modelId = this.state.selectedModel;
839
+ if (!modelId) {
840
+ this.state.selectedReasoningEffort = null;
841
+ return;
842
+ }
843
+ const options = this.getReasoningEffortsForModel(modelId);
844
+ if (!options.length) {
845
+ this.state.selectedReasoningEffort = null;
846
+ return;
847
+ }
848
+ const current = this.state.selectedReasoningEffort;
849
+ if (current && options.some((option) => option.id === current)) {
850
+ return;
851
+ }
852
+ const fallback = this.getDefaultReasoningEffort(modelId) || options[0].id;
853
+ this.state.selectedReasoningEffort = fallback;
854
+ }
855
+ renderReasoningEfforts() {
856
+ const { connectedEffortSelect, effortField } = this.elements ?? {};
857
+ if (!connectedEffortSelect || !effortField)
858
+ return;
859
+ const options = this.getReasoningEffortsForModel(this.state.selectedModel);
860
+ if (!options.length) {
861
+ effortField.style.display = 'none';
862
+ connectedEffortSelect.innerHTML = '';
863
+ return;
864
+ }
865
+ effortField.style.display = '';
866
+ connectedEffortSelect.innerHTML = '';
867
+ for (const option of options) {
868
+ const entry = document.createElement('option');
869
+ entry.value = option.id;
870
+ entry.textContent = option.label || option.id;
871
+ connectedEffortSelect.appendChild(entry);
872
+ }
873
+ const selected = this.state.selectedReasoningEffort || options[0].id;
874
+ this.state.selectedReasoningEffort = selected;
875
+ connectedEffortSelect.value = selected;
876
+ }
877
+ updatePopoverTitle() {
878
+ const { popoverTitle } = this.elements ?? {};
879
+ if (!popoverTitle)
880
+ return;
881
+ if (!this.state.connected) {
882
+ popoverTitle.textContent = 'Provider';
883
+ return;
884
+ }
885
+ const providerName = this.state.providers.find((p) => p.id === this.state.connected?.provider)?.name ||
886
+ this.state.connected.provider;
887
+ popoverTitle.textContent = providerName;
888
+ }
889
+ updateButtonLabel() {
890
+ const { button } = this.elements ?? {};
891
+ if (!button)
892
+ return;
893
+ if (!this.state.connected) {
894
+ button.textContent = 'Connect Agent';
895
+ button.removeAttribute('aria-label');
896
+ return;
897
+ }
898
+ const providerName = this.state.providers.find((p) => p.id === this.state.connected?.provider)?.name ||
899
+ this.state.connected.provider;
900
+ const effort = this.state.connected.reasoningEffort
901
+ ? ` · ${this.state.connected.reasoningEffort}`
902
+ : '';
903
+ const providerId = this.state.connected.provider;
904
+ const modelLabel = this.formatButtonModelLabel(providerId, this.state.connected.model, providerName);
905
+ const label = `${modelLabel}${effort}`;
906
+ const ariaLabel = `${providerName} · ${modelLabel}${effort}`;
907
+ const iconSvg = PROVIDER_ICONS[providerId] ?? null;
908
+ this.renderConnectButtonContent(label, iconSvg, providerId, ariaLabel);
909
+ }
910
+ formatButtonModelLabel(providerId, model, fallback) {
911
+ const value = model || fallback;
912
+ if (providerId !== 'claude')
913
+ return value;
914
+ const normalized = value.trim().toLowerCase();
915
+ const map = {
916
+ default: 'Default',
917
+ sonnet: 'Sonnet',
918
+ haiku: 'Haiku',
919
+ opus: 'Opus',
920
+ };
921
+ return map[normalized] || value;
922
+ }
923
+ renderConnectButtonContent(label, iconSvg, providerId, ariaLabel) {
924
+ const { button } = this.elements ?? {};
925
+ if (!button)
926
+ return;
927
+ button.replaceChildren();
928
+ if (ariaLabel) {
929
+ button.setAttribute('aria-label', ariaLabel);
930
+ }
931
+ else {
932
+ button.removeAttribute('aria-label');
933
+ }
934
+ if (!iconSvg) {
935
+ button.textContent = label;
936
+ return;
937
+ }
938
+ const icon = document.createElement('span');
939
+ icon.className = 'ac-connect-icon';
940
+ icon.dataset.provider = providerId;
941
+ icon.setAttribute('aria-hidden', 'true');
942
+ icon.innerHTML = iconSvg;
943
+ const text = document.createElement('span');
944
+ text.className = 'ac-connect-label';
945
+ text.textContent = label;
946
+ button.append(icon, text);
947
+ }
948
+ applySelection(provider, model, closeAfter, reasoningEffort = null) {
949
+ const scopeId = buildScopeId(provider, model, reasoningEffort);
950
+ const previous = this.state.connected;
951
+ const selection = { provider, model, reasoningEffort, scopeId };
952
+ this.state.connected = selection;
953
+ this.state.selectedProvider = provider;
954
+ this.state.selectedModel = model;
955
+ this.state.selectedReasoningEffort = reasoningEffort;
956
+ saveSelection(selection, this.storageKey);
957
+ this.updateButtonLabel();
958
+ this.updatePopoverTitle();
959
+ if (!previous) {
960
+ this.dispatchSelectionEvent('agentconnect:connected', selection, null, false);
961
+ }
962
+ else if (previous.scopeId !== scopeId) {
963
+ this.dispatchSelectionEvent('agentconnect:selection-changed', selection, previous, false);
964
+ }
965
+ if (closeAfter) {
966
+ this.close();
967
+ }
968
+ }
969
+ disconnect() {
970
+ const previous = this.state.connected;
971
+ this.state.connected = null;
972
+ this.state.selectedProvider = null;
973
+ this.state.selectedModel = null;
974
+ this.state.selectedReasoningEffort = null;
975
+ clearSelection(this.storageKey);
976
+ this.updateButtonLabel();
977
+ this.renderProviders();
978
+ if (previous) {
979
+ this.dispatchSelectionEvent('agentconnect:disconnected', null, previous, false);
980
+ }
981
+ this.close();
982
+ }
983
+ dispatchSelectionEvent(type, selection, previous, restored) {
984
+ const detail = {
985
+ provider: selection?.provider || null,
986
+ model: selection?.model || null,
987
+ reasoningEffort: selection?.reasoningEffort || null,
988
+ scopeId: selection?.scopeId || null,
989
+ previousProvider: previous?.provider || null,
990
+ previousModel: previous?.model || null,
991
+ previousReasoningEffort: previous?.reasoningEffort || null,
992
+ previousScopeId: previous?.scopeId || null,
993
+ restored: Boolean(restored),
994
+ };
995
+ this.dispatchEvent(new CustomEvent(type, {
996
+ detail,
997
+ bubbles: true,
998
+ composed: true,
999
+ }));
1000
+ }
1001
+ validateLocalConfig(config) {
1002
+ const errors = [];
1003
+ if (config.baseUrl && !/^https?:\/\/.+/.test(config.baseUrl)) {
1004
+ errors.push({
1005
+ field: 'baseUrl',
1006
+ message: 'Enter a valid URL (e.g., http://localhost:11434/v1)',
1007
+ });
1008
+ }
1009
+ if (!config.model && (!config.models || config.models.length === 0)) {
1010
+ errors.push({
1011
+ field: 'model',
1012
+ message: 'Provide at least one model ID',
1013
+ });
1014
+ }
1015
+ if (config.model && !/^[a-zA-Z0-9._:\-/]+$/.test(config.model)) {
1016
+ errors.push({
1017
+ field: 'model',
1018
+ message: 'Model ID contains invalid characters',
1019
+ });
1020
+ }
1021
+ return errors;
1022
+ }
1023
+ showFieldError(fieldName, message) {
1024
+ const fieldMap = {
1025
+ baseUrl: this.elements?.localBaseInput ?? null,
1026
+ model: this.elements?.localModelInput ?? null,
1027
+ };
1028
+ const input = fieldMap[fieldName];
1029
+ if (!input)
1030
+ return;
1031
+ const field = input.closest('.ac-field');
1032
+ if (field) {
1033
+ field.classList.add('error');
1034
+ const errorEl = document.createElement('div');
1035
+ errorEl.className = 'ac-field-error';
1036
+ errorEl.textContent = message;
1037
+ field.appendChild(errorEl);
1038
+ }
1039
+ }
1040
+ clearFieldErrors() {
1041
+ const fields = this.shadowRoot?.querySelectorAll('.ac-field.error');
1042
+ fields?.forEach((field) => {
1043
+ field.classList.remove('error');
1044
+ field.querySelector('.ac-field-error')?.remove();
1045
+ });
1046
+ }
1047
+ setStatus(message, _view = 'connect') {
1048
+ this.setAlert(null, 'local');
1049
+ if (message) {
1050
+ this.setAlert({ message }, 'local');
1051
+ }
1052
+ }
1053
+ setAlert(alert, view = 'local') {
1054
+ if (view !== 'local')
1055
+ return;
1056
+ const target = this.elements?.localStatus;
1057
+ if (!target)
1058
+ return;
1059
+ if (!alert) {
1060
+ target.hidden = true;
1061
+ target.innerHTML = '';
1062
+ target.className = 'ac-alert ac-status';
1063
+ return;
1064
+ }
1065
+ target.hidden = false;
1066
+ target.className = 'ac-alert';
1067
+ if (alert.type) {
1068
+ target.classList.add(alert.type);
1069
+ }
1070
+ let html = '';
1071
+ if (alert.title) {
1072
+ html += `<div class="ac-alert-title">${escapeHtml(alert.title)}</div>`;
1073
+ }
1074
+ if (alert.message) {
1075
+ html += `<div class="ac-alert-message">${escapeHtml(alert.message)}</div>`;
1076
+ }
1077
+ if (alert.action && alert.onAction) {
1078
+ html += `<div class="ac-alert-actions"><button class="ac-button secondary" type="button">${escapeHtml(alert.action)}</button></div>`;
1079
+ }
1080
+ target.innerHTML = html;
1081
+ if (alert.action && alert.onAction) {
1082
+ const actionBtn = target.querySelector('.ac-alert-actions .ac-button');
1083
+ actionBtn?.addEventListener('click', () => {
1084
+ this.setAlert(null, view);
1085
+ alert.onAction();
1086
+ });
1087
+ }
1088
+ }
1089
+ setBusy(isBusy, message) {
1090
+ this.busy = Boolean(isBusy);
1091
+ if (message !== undefined) {
1092
+ this.busyMessage = message ?? null;
1093
+ }
1094
+ if (!this.elements)
1095
+ return;
1096
+ const { connectPanel, progressWrap, progressLabel } = this.elements;
1097
+ if (!connectPanel || !progressWrap || !progressLabel)
1098
+ return;
1099
+ if (this.busy) {
1100
+ connectPanel.dataset.busy = 'true';
1101
+ progressWrap.hidden = false;
1102
+ progressLabel.textContent = this.busyMessage || 'Working...';
1103
+ }
1104
+ else {
1105
+ connectPanel.removeAttribute('data-busy');
1106
+ progressWrap.hidden = true;
1107
+ progressLabel.textContent = '';
1108
+ }
1109
+ }
1110
+ }