@hyperspaceng/neural-web-ui 0.60.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 (86) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/README.md +601 -0
  3. package/example/README.md +61 -0
  4. package/example/index.html +13 -0
  5. package/example/package.json +25 -0
  6. package/example/src/app.css +1 -0
  7. package/example/src/custom-messages.ts +99 -0
  8. package/example/src/main.ts +421 -0
  9. package/example/tsconfig.json +23 -0
  10. package/example/vite.config.ts +6 -0
  11. package/package.json +51 -0
  12. package/scripts/count-prompt-tokens.ts +88 -0
  13. package/src/ChatPanel.ts +209 -0
  14. package/src/app.css +68 -0
  15. package/src/components/AgentInterface.ts +401 -0
  16. package/src/components/AttachmentTile.ts +107 -0
  17. package/src/components/ConsoleBlock.ts +72 -0
  18. package/src/components/CustomProviderCard.ts +100 -0
  19. package/src/components/ExpandableSection.ts +46 -0
  20. package/src/components/Input.ts +113 -0
  21. package/src/components/MessageEditor.ts +402 -0
  22. package/src/components/MessageList.ts +98 -0
  23. package/src/components/Messages.ts +383 -0
  24. package/src/components/ProviderKeyInput.ts +153 -0
  25. package/src/components/SandboxedIframe.ts +626 -0
  26. package/src/components/StreamingMessageContainer.ts +103 -0
  27. package/src/components/ThinkingBlock.ts +43 -0
  28. package/src/components/message-renderer-registry.ts +28 -0
  29. package/src/components/sandbox/ArtifactsRuntimeProvider.ts +219 -0
  30. package/src/components/sandbox/AttachmentsRuntimeProvider.ts +66 -0
  31. package/src/components/sandbox/ConsoleRuntimeProvider.ts +186 -0
  32. package/src/components/sandbox/FileDownloadRuntimeProvider.ts +110 -0
  33. package/src/components/sandbox/RuntimeMessageBridge.ts +82 -0
  34. package/src/components/sandbox/RuntimeMessageRouter.ts +216 -0
  35. package/src/components/sandbox/SandboxRuntimeProvider.ts +52 -0
  36. package/src/dialogs/ApiKeyPromptDialog.ts +75 -0
  37. package/src/dialogs/AttachmentOverlay.ts +636 -0
  38. package/src/dialogs/CustomProviderDialog.ts +274 -0
  39. package/src/dialogs/ModelSelector.ts +367 -0
  40. package/src/dialogs/PersistentStorageDialog.ts +144 -0
  41. package/src/dialogs/ProvidersModelsTab.ts +212 -0
  42. package/src/dialogs/SessionListDialog.ts +150 -0
  43. package/src/dialogs/SettingsDialog.ts +218 -0
  44. package/src/index.ts +120 -0
  45. package/src/prompts/prompts.ts +282 -0
  46. package/src/storage/app-storage.ts +60 -0
  47. package/src/storage/backends/indexeddb-storage-backend.ts +193 -0
  48. package/src/storage/store.ts +33 -0
  49. package/src/storage/stores/custom-providers-store.ts +62 -0
  50. package/src/storage/stores/provider-keys-store.ts +33 -0
  51. package/src/storage/stores/sessions-store.ts +136 -0
  52. package/src/storage/stores/settings-store.ts +34 -0
  53. package/src/storage/types.ts +206 -0
  54. package/src/tools/artifacts/ArtifactElement.ts +14 -0
  55. package/src/tools/artifacts/ArtifactPill.ts +26 -0
  56. package/src/tools/artifacts/Console.ts +93 -0
  57. package/src/tools/artifacts/DocxArtifact.ts +213 -0
  58. package/src/tools/artifacts/ExcelArtifact.ts +231 -0
  59. package/src/tools/artifacts/GenericArtifact.ts +117 -0
  60. package/src/tools/artifacts/HtmlArtifact.ts +195 -0
  61. package/src/tools/artifacts/ImageArtifact.ts +116 -0
  62. package/src/tools/artifacts/MarkdownArtifact.ts +82 -0
  63. package/src/tools/artifacts/PdfArtifact.ts +201 -0
  64. package/src/tools/artifacts/SvgArtifact.ts +78 -0
  65. package/src/tools/artifacts/TextArtifact.ts +148 -0
  66. package/src/tools/artifacts/artifacts-tool-renderer.ts +310 -0
  67. package/src/tools/artifacts/artifacts.ts +713 -0
  68. package/src/tools/artifacts/index.ts +7 -0
  69. package/src/tools/extract-document.ts +275 -0
  70. package/src/tools/index.ts +46 -0
  71. package/src/tools/javascript-repl.ts +293 -0
  72. package/src/tools/renderer-registry.ts +130 -0
  73. package/src/tools/renderers/BashRenderer.ts +52 -0
  74. package/src/tools/renderers/CalculateRenderer.ts +58 -0
  75. package/src/tools/renderers/DefaultRenderer.ts +103 -0
  76. package/src/tools/renderers/GetCurrentTimeRenderer.ts +92 -0
  77. package/src/tools/types.ts +15 -0
  78. package/src/utils/attachment-utils.ts +472 -0
  79. package/src/utils/auth-token.ts +22 -0
  80. package/src/utils/format.ts +42 -0
  81. package/src/utils/i18n.ts +653 -0
  82. package/src/utils/model-discovery.ts +277 -0
  83. package/src/utils/proxy-utils.ts +139 -0
  84. package/src/utils/test-sessions.ts +2357 -0
  85. package/tsconfig.build.json +20 -0
  86. package/tsconfig.json +7 -0
@@ -0,0 +1,274 @@
1
+ import { i18n } from "@mariozechner/mini-lit";
2
+ import { Button } from "@mariozechner/mini-lit/dist/Button.js";
3
+ import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
4
+ import { Input } from "@mariozechner/mini-lit/dist/Input.js";
5
+ import { Label } from "@mariozechner/mini-lit/dist/Label.js";
6
+ import { Select } from "@mariozechner/mini-lit/dist/Select.js";
7
+ import type { Model } from "@mariozechner/pi-ai";
8
+ import { html, type TemplateResult } from "lit";
9
+ import { state } from "lit/decorators.js";
10
+ import { getAppStorage } from "../storage/app-storage.js";
11
+ import type { CustomProvider, CustomProviderType } from "../storage/stores/custom-providers-store.js";
12
+ import { discoverModels } from "../utils/model-discovery.js";
13
+
14
+ export class CustomProviderDialog extends DialogBase {
15
+ private provider?: CustomProvider;
16
+ private initialType?: CustomProviderType;
17
+ private onSaveCallback?: () => void;
18
+
19
+ @state() private name = "";
20
+ @state() private type: CustomProviderType = "openai-completions";
21
+ @state() private baseUrl = "";
22
+ @state() private apiKey = "";
23
+ @state() private testing = false;
24
+ @state() private testError = "";
25
+ @state() private discoveredModels: Model<any>[] = [];
26
+
27
+ protected modalWidth = "min(800px, 90vw)";
28
+ protected modalHeight = "min(700px, 90vh)";
29
+
30
+ static async open(
31
+ provider: CustomProvider | undefined,
32
+ initialType: CustomProviderType | undefined,
33
+ onSave?: () => void,
34
+ ) {
35
+ const dialog = new CustomProviderDialog();
36
+ dialog.provider = provider;
37
+ dialog.initialType = initialType;
38
+ dialog.onSaveCallback = onSave;
39
+ document.body.appendChild(dialog);
40
+ dialog.initializeFromProvider();
41
+ dialog.open();
42
+ dialog.requestUpdate();
43
+ }
44
+
45
+ private initializeFromProvider() {
46
+ if (this.provider) {
47
+ this.name = this.provider.name;
48
+ this.type = this.provider.type;
49
+ this.baseUrl = this.provider.baseUrl;
50
+ this.apiKey = this.provider.apiKey || "";
51
+ this.discoveredModels = this.provider.models || [];
52
+ } else {
53
+ this.name = "";
54
+ this.type = this.initialType || "openai-completions";
55
+ this.baseUrl = "";
56
+ this.updateDefaultBaseUrl();
57
+ this.apiKey = "";
58
+ this.discoveredModels = [];
59
+ }
60
+ this.testError = "";
61
+ this.testing = false;
62
+ }
63
+
64
+ private updateDefaultBaseUrl() {
65
+ if (this.baseUrl) return;
66
+
67
+ const defaults: Record<string, string> = {
68
+ ollama: "http://localhost:11434",
69
+ "llama.cpp": "http://localhost:8080",
70
+ vllm: "http://localhost:8000",
71
+ lmstudio: "http://localhost:1234",
72
+ "openai-completions": "",
73
+ "openai-responses": "",
74
+ "anthropic-messages": "",
75
+ };
76
+
77
+ this.baseUrl = defaults[this.type] || "";
78
+ }
79
+
80
+ private isAutoDiscoveryType(): boolean {
81
+ return this.type === "ollama" || this.type === "llama.cpp" || this.type === "vllm" || this.type === "lmstudio";
82
+ }
83
+
84
+ private async testConnection() {
85
+ if (!this.isAutoDiscoveryType()) return;
86
+
87
+ this.testing = true;
88
+ this.testError = "";
89
+ this.discoveredModels = [];
90
+
91
+ try {
92
+ const models = await discoverModels(
93
+ this.type as "ollama" | "llama.cpp" | "vllm" | "lmstudio",
94
+ this.baseUrl,
95
+ this.apiKey || undefined,
96
+ );
97
+
98
+ this.discoveredModels = models.map((model) => ({
99
+ ...model,
100
+ provider: this.name || this.type,
101
+ }));
102
+
103
+ this.testError = "";
104
+ } catch (error) {
105
+ this.testError = error instanceof Error ? error.message : String(error);
106
+ this.discoveredModels = [];
107
+ } finally {
108
+ this.testing = false;
109
+ this.requestUpdate();
110
+ }
111
+ }
112
+
113
+ private async save() {
114
+ if (!this.name || !this.baseUrl) {
115
+ alert(i18n("Please fill in all required fields"));
116
+ return;
117
+ }
118
+
119
+ try {
120
+ const storage = getAppStorage();
121
+
122
+ const provider: CustomProvider = {
123
+ id: this.provider?.id || crypto.randomUUID(),
124
+ name: this.name,
125
+ type: this.type,
126
+ baseUrl: this.baseUrl,
127
+ apiKey: this.apiKey || undefined,
128
+ models: this.isAutoDiscoveryType() ? undefined : this.provider?.models || [],
129
+ };
130
+
131
+ await storage.customProviders.set(provider);
132
+
133
+ if (this.onSaveCallback) {
134
+ this.onSaveCallback();
135
+ }
136
+ this.close();
137
+ } catch (error) {
138
+ console.error("Failed to save provider:", error);
139
+ alert(i18n("Failed to save provider"));
140
+ }
141
+ }
142
+
143
+ protected override renderContent(): TemplateResult {
144
+ const providerTypes = [
145
+ { value: "ollama", label: "Ollama (auto-discovery)" },
146
+ { value: "llama.cpp", label: "llama.cpp (auto-discovery)" },
147
+ { value: "vllm", label: "vLLM (auto-discovery)" },
148
+ { value: "lmstudio", label: "LM Studio (auto-discovery)" },
149
+ { value: "openai-completions", label: "OpenAI Completions Compatible" },
150
+ { value: "openai-responses", label: "OpenAI Responses Compatible" },
151
+ { value: "anthropic-messages", label: "Anthropic Messages Compatible" },
152
+ ];
153
+
154
+ return html`
155
+ <div class="flex flex-col h-full overflow-hidden">
156
+ <div class="p-6 flex-shrink-0 border-b border-border">
157
+ <h2 class="text-lg font-semibold text-foreground">
158
+ ${this.provider ? i18n("Edit Provider") : i18n("Add Provider")}
159
+ </h2>
160
+ </div>
161
+
162
+ <div class="flex-1 overflow-y-auto p-6">
163
+ <div class="flex flex-col gap-4">
164
+ <div class="flex flex-col gap-2">
165
+ ${Label({ htmlFor: "provider-name", children: i18n("Provider Name") })}
166
+ ${Input({
167
+ value: this.name,
168
+ placeholder: i18n("e.g., My Ollama Server"),
169
+ onInput: (e: Event) => {
170
+ this.name = (e.target as HTMLInputElement).value;
171
+ this.requestUpdate();
172
+ },
173
+ })}
174
+ </div>
175
+
176
+ <div class="flex flex-col gap-2">
177
+ ${Label({ htmlFor: "provider-type", children: i18n("Provider Type") })}
178
+ ${Select({
179
+ value: this.type,
180
+ options: providerTypes.map((pt) => ({
181
+ value: pt.value,
182
+ label: pt.label,
183
+ })),
184
+ onChange: (value: string) => {
185
+ this.type = value as CustomProviderType;
186
+ this.baseUrl = "";
187
+ this.updateDefaultBaseUrl();
188
+ this.requestUpdate();
189
+ },
190
+ width: "100%",
191
+ })}
192
+ </div>
193
+
194
+ <div class="flex flex-col gap-2">
195
+ ${Label({ htmlFor: "base-url", children: i18n("Base URL") })}
196
+ ${Input({
197
+ value: this.baseUrl,
198
+ placeholder: i18n("e.g., http://localhost:11434"),
199
+ onInput: (e: Event) => {
200
+ this.baseUrl = (e.target as HTMLInputElement).value;
201
+ this.requestUpdate();
202
+ },
203
+ })}
204
+ </div>
205
+
206
+ <div class="flex flex-col gap-2">
207
+ ${Label({ htmlFor: "api-key", children: i18n("API Key (Optional)") })}
208
+ ${Input({
209
+ type: "password",
210
+ value: this.apiKey,
211
+ placeholder: i18n("Leave empty if not required"),
212
+ onInput: (e: Event) => {
213
+ this.apiKey = (e.target as HTMLInputElement).value;
214
+ this.requestUpdate();
215
+ },
216
+ })}
217
+ </div>
218
+
219
+ ${
220
+ this.isAutoDiscoveryType()
221
+ ? html`
222
+ <div class="flex flex-col gap-2">
223
+ ${Button({
224
+ onClick: () => this.testConnection(),
225
+ variant: "outline",
226
+ disabled: this.testing || !this.baseUrl,
227
+ children: this.testing ? i18n("Testing...") : i18n("Test Connection"),
228
+ })}
229
+ ${this.testError ? html` <div class="text-sm text-destructive">${this.testError}</div> ` : ""}
230
+ ${
231
+ this.discoveredModels.length > 0
232
+ ? html`
233
+ <div class="text-sm text-muted-foreground">
234
+ ${i18n("Discovered")} ${this.discoveredModels.length} ${i18n("models")}:
235
+ <ul class="list-disc list-inside mt-2">
236
+ ${this.discoveredModels.slice(0, 5).map((model) => html`<li>${model.name}</li>`)}
237
+ ${
238
+ this.discoveredModels.length > 5
239
+ ? html`<li>...${i18n("and")} ${this.discoveredModels.length - 5} ${i18n("more")}</li>`
240
+ : ""
241
+ }
242
+ </ul>
243
+ </div>
244
+ `
245
+ : ""
246
+ }
247
+ </div>
248
+ `
249
+ : html` <div class="text-sm text-muted-foreground">
250
+ ${i18n("For manual provider types, add models after saving the provider.")}
251
+ </div>`
252
+ }
253
+ </div>
254
+ </div>
255
+
256
+ <div class="p-6 flex-shrink-0 border-t border-border flex justify-end gap-2">
257
+ ${Button({
258
+ onClick: () => this.close(),
259
+ variant: "ghost",
260
+ children: i18n("Cancel"),
261
+ })}
262
+ ${Button({
263
+ onClick: () => this.save(),
264
+ variant: "default",
265
+ disabled: !this.name || !this.baseUrl,
266
+ children: i18n("Save"),
267
+ })}
268
+ </div>
269
+ </div>
270
+ `;
271
+ }
272
+ }
273
+
274
+ customElements.define("custom-provider-dialog", CustomProviderDialog);
@@ -0,0 +1,367 @@
1
+ import { icon } from "@mariozechner/mini-lit";
2
+ import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
3
+ import { Button } from "@mariozechner/mini-lit/dist/Button.js";
4
+ import { DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js";
5
+ import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
6
+ import { getModels, getProviders, type Model, modelsAreEqual } from "@mariozechner/pi-ai";
7
+ import { html, type PropertyValues, type TemplateResult } from "lit";
8
+ import { customElement, state } from "lit/decorators.js";
9
+ import { createRef, ref } from "lit/directives/ref.js";
10
+ import { Brain, Image as ImageIcon } from "lucide";
11
+ import { Input } from "../components/Input.js";
12
+ import { getAppStorage } from "../storage/app-storage.js";
13
+ import type { AutoDiscoveryProviderType } from "../storage/stores/custom-providers-store.js";
14
+ import { formatModelCost } from "../utils/format.js";
15
+ import { i18n } from "../utils/i18n.js";
16
+ import { discoverModels } from "../utils/model-discovery.js";
17
+
18
+ /**
19
+ * Score a query against a text using subsequence matching.
20
+ * All query characters must appear in order in the text.
21
+ * Higher score = tighter match (fewer gaps between matched characters).
22
+ * Returns 0 if no match.
23
+ */
24
+ function subsequenceScore(query: string, text: string): number {
25
+ let qi = 0;
26
+ let ti = 0;
27
+ let gaps = 0;
28
+ let lastMatchIndex = -1;
29
+
30
+ while (qi < query.length && ti < text.length) {
31
+ if (query[qi] === text[ti]) {
32
+ if (lastMatchIndex >= 0) {
33
+ gaps += ti - lastMatchIndex - 1;
34
+ }
35
+ lastMatchIndex = ti;
36
+ qi++;
37
+ }
38
+ ti++;
39
+ }
40
+
41
+ // All query chars must match
42
+ if (qi < query.length) return 0;
43
+
44
+ // Score: longer query match = better, fewer gaps = better
45
+ // Normalize so exact substring gets highest score
46
+ return query.length / (query.length + gaps);
47
+ }
48
+
49
+ @customElement("agent-model-selector")
50
+ export class ModelSelector extends DialogBase {
51
+ @state() currentModel: Model<any> | null = null;
52
+ @state() searchQuery = "";
53
+ @state() filterThinking = false;
54
+ @state() filterVision = false;
55
+ @state() customProvidersLoading = false;
56
+ @state() selectedIndex = 0;
57
+ @state() private navigationMode: "mouse" | "keyboard" = "mouse";
58
+ @state() private customProviderModels: Model<any>[] = [];
59
+
60
+ private onSelectCallback?: (model: Model<any>) => void;
61
+ private allowedProviders?: Set<string>;
62
+ private scrollContainerRef = createRef<HTMLDivElement>();
63
+ private searchInputRef = createRef<HTMLInputElement>();
64
+ private lastMousePosition = { x: 0, y: 0 };
65
+
66
+ protected override modalWidth = "min(400px, 90vw)";
67
+
68
+ static async open(
69
+ currentModel: Model<any> | null,
70
+ onSelect: (model: Model<any>) => void,
71
+ allowedProviders?: string[],
72
+ ) {
73
+ const selector = new ModelSelector();
74
+ selector.currentModel = currentModel;
75
+ selector.onSelectCallback = onSelect;
76
+ if (allowedProviders) {
77
+ selector.allowedProviders = new Set(allowedProviders);
78
+ }
79
+ selector.open();
80
+ selector.loadCustomProviders();
81
+ }
82
+
83
+ override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
84
+ super.firstUpdated(changedProperties);
85
+ // Wait for dialog to be fully rendered
86
+ await this.updateComplete;
87
+ // Focus the search input when dialog opens
88
+ this.searchInputRef.value?.focus();
89
+
90
+ // Track actual mouse movement
91
+ this.addEventListener("mousemove", (e: MouseEvent) => {
92
+ // Check if mouse actually moved
93
+ if (e.clientX !== this.lastMousePosition.x || e.clientY !== this.lastMousePosition.y) {
94
+ this.lastMousePosition = { x: e.clientX, y: e.clientY };
95
+ // Only switch to mouse mode on actual mouse movement
96
+ if (this.navigationMode === "keyboard") {
97
+ this.navigationMode = "mouse";
98
+ // Update selection to the item under the mouse
99
+ const target = e.target as HTMLElement;
100
+ const modelItem = target.closest("[data-model-item]");
101
+ if (modelItem) {
102
+ const allItems = this.scrollContainerRef.value?.querySelectorAll("[data-model-item]");
103
+ if (allItems) {
104
+ const index = Array.from(allItems).indexOf(modelItem);
105
+ if (index !== -1) {
106
+ this.selectedIndex = index;
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ });
113
+
114
+ // Add global keyboard handler for the dialog
115
+ this.addEventListener("keydown", (e: KeyboardEvent) => {
116
+ // Get filtered models to know the bounds
117
+ const filteredModels = this.getFilteredModels();
118
+
119
+ if (e.key === "ArrowDown") {
120
+ e.preventDefault();
121
+ this.navigationMode = "keyboard";
122
+ this.selectedIndex = Math.min(this.selectedIndex + 1, filteredModels.length - 1);
123
+ this.scrollToSelected();
124
+ } else if (e.key === "ArrowUp") {
125
+ e.preventDefault();
126
+ this.navigationMode = "keyboard";
127
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
128
+ this.scrollToSelected();
129
+ } else if (e.key === "Enter") {
130
+ e.preventDefault();
131
+ if (filteredModels[this.selectedIndex]) {
132
+ this.handleSelect(filteredModels[this.selectedIndex].model);
133
+ }
134
+ }
135
+ });
136
+ }
137
+
138
+ private async loadCustomProviders() {
139
+ this.customProvidersLoading = true;
140
+ const allCustomModels: Model<any>[] = [];
141
+
142
+ try {
143
+ const storage = getAppStorage();
144
+ const customProviders = await storage.customProviders.getAll();
145
+
146
+ // Load models from custom providers
147
+ for (const provider of customProviders) {
148
+ const isAutoDiscovery: boolean =
149
+ provider.type === "ollama" ||
150
+ provider.type === "llama.cpp" ||
151
+ provider.type === "vllm" ||
152
+ provider.type === "lmstudio";
153
+
154
+ if (isAutoDiscovery) {
155
+ try {
156
+ const models = await discoverModels(
157
+ provider.type as AutoDiscoveryProviderType,
158
+ provider.baseUrl,
159
+ provider.apiKey,
160
+ );
161
+
162
+ const modelsWithProvider = models.map((model) => ({
163
+ ...model,
164
+ provider: provider.name,
165
+ }));
166
+
167
+ allCustomModels.push(...modelsWithProvider);
168
+ } catch (error) {
169
+ console.debug(`Failed to load models from ${provider.name}:`, error);
170
+ }
171
+ } else if (provider.models) {
172
+ // Manual provider - models already defined
173
+ allCustomModels.push(...provider.models);
174
+ }
175
+ }
176
+ } catch (error) {
177
+ console.error("Failed to load custom providers:", error);
178
+ } finally {
179
+ this.customProviderModels = allCustomModels;
180
+ this.customProvidersLoading = false;
181
+ this.requestUpdate();
182
+ }
183
+ }
184
+
185
+ private formatTokens(tokens: number): string {
186
+ if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(0)}M`;
187
+ if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}`;
188
+ return String(tokens);
189
+ }
190
+
191
+ private handleSelect(model: Model<any>) {
192
+ if (model) {
193
+ this.onSelectCallback?.(model);
194
+ this.close();
195
+ }
196
+ }
197
+
198
+ private getFilteredModels(): Array<{ provider: string; id: string; model: any }> {
199
+ // Collect all models from known providers
200
+ const allModels: Array<{ provider: string; id: string; model: any }> = [];
201
+ const knownProviders = getProviders();
202
+
203
+ for (const provider of knownProviders) {
204
+ const models = getModels(provider as any);
205
+ for (const model of models) {
206
+ allModels.push({ provider, id: model.id, model });
207
+ }
208
+ }
209
+
210
+ // Add custom provider models
211
+ for (const model of this.customProviderModels) {
212
+ allModels.push({ provider: model.provider, id: model.id, model });
213
+ }
214
+
215
+ // Filter by allowed providers if set
216
+ if (this.allowedProviders) {
217
+ const allowed = this.allowedProviders;
218
+ allModels.splice(0, allModels.length, ...allModels.filter(({ provider }) => allowed.has(provider)));
219
+ }
220
+
221
+ // Filter models based on search and capability filters
222
+ let filteredModels = allModels;
223
+
224
+ // Apply search filter (subsequence match: characters must appear in order)
225
+ if (this.searchQuery) {
226
+ const query = this.searchQuery.toLowerCase().replace(/\s+/g, "");
227
+ if (query) {
228
+ const scored: Array<{ item: (typeof allModels)[0]; score: number }> = [];
229
+ for (const entry of filteredModels) {
230
+ const searchText = `${entry.provider} ${entry.id} ${entry.model.name}`.toLowerCase();
231
+ const score = subsequenceScore(query, searchText);
232
+ if (score > 0) {
233
+ scored.push({ item: entry, score });
234
+ }
235
+ }
236
+ scored.sort((a, b) => b.score - a.score);
237
+ filteredModels = scored.map((s) => s.item);
238
+ }
239
+ }
240
+
241
+ // Apply capability filters
242
+ if (this.filterThinking) {
243
+ filteredModels = filteredModels.filter(({ model }) => model.reasoning);
244
+ }
245
+ if (this.filterVision) {
246
+ filteredModels = filteredModels.filter(({ model }) => model.input.includes("image"));
247
+ }
248
+
249
+ // Sort: when not searching, current model first then by provider.
250
+ // When searching, preserve the score-based order from above,
251
+ // but still float the current model to the top.
252
+ if (!this.searchQuery) {
253
+ filteredModels.sort((a, b) => {
254
+ const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
255
+ const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
256
+ if (aIsCurrent && !bIsCurrent) return -1;
257
+ if (!aIsCurrent && bIsCurrent) return 1;
258
+ return a.provider.localeCompare(b.provider);
259
+ });
260
+ }
261
+
262
+ return filteredModels;
263
+ }
264
+
265
+ private scrollToSelected() {
266
+ requestAnimationFrame(() => {
267
+ const scrollContainer = this.scrollContainerRef.value;
268
+ const selectedElement = scrollContainer?.querySelectorAll("[data-model-item]")[
269
+ this.selectedIndex
270
+ ] as HTMLElement;
271
+ if (selectedElement) {
272
+ selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" });
273
+ }
274
+ });
275
+ }
276
+
277
+ protected override renderContent(): TemplateResult {
278
+ const filteredModels = this.getFilteredModels();
279
+
280
+ return html`
281
+ <!-- Header and Search -->
282
+ <div class="p-6 pb-4 flex flex-col gap-4 border-b border-border flex-shrink-0">
283
+ ${DialogHeader({ title: i18n("Select Model") })}
284
+ ${Input({
285
+ placeholder: i18n("Search models..."),
286
+ value: this.searchQuery,
287
+ inputRef: this.searchInputRef,
288
+ onInput: (e: Event) => {
289
+ this.searchQuery = (e.target as HTMLInputElement).value;
290
+ this.selectedIndex = 0;
291
+ // Reset scroll position when search changes
292
+ if (this.scrollContainerRef.value) {
293
+ this.scrollContainerRef.value.scrollTop = 0;
294
+ }
295
+ },
296
+ })}
297
+ <div class="flex gap-2">
298
+ ${Button({
299
+ variant: this.filterThinking ? "default" : "secondary",
300
+ size: "sm",
301
+ onClick: () => {
302
+ this.filterThinking = !this.filterThinking;
303
+ this.selectedIndex = 0;
304
+ if (this.scrollContainerRef.value) {
305
+ this.scrollContainerRef.value.scrollTop = 0;
306
+ }
307
+ },
308
+ className: "rounded-full",
309
+ children: html`<span class="inline-flex items-center gap-1">${icon(Brain, "sm")} ${i18n("Thinking")}</span>`,
310
+ })}
311
+ ${Button({
312
+ variant: this.filterVision ? "default" : "secondary",
313
+ size: "sm",
314
+ onClick: () => {
315
+ this.filterVision = !this.filterVision;
316
+ this.selectedIndex = 0;
317
+ if (this.scrollContainerRef.value) {
318
+ this.scrollContainerRef.value.scrollTop = 0;
319
+ }
320
+ },
321
+ className: "rounded-full",
322
+ children: html`<span class="inline-flex items-center gap-1">${icon(ImageIcon, "sm")} ${i18n("Vision")}</span>`,
323
+ })}
324
+ </div>
325
+ </div>
326
+
327
+ <!-- Scrollable model list -->
328
+ <div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
329
+ ${filteredModels.map(({ provider, id, model }, index) => {
330
+ const isCurrent = modelsAreEqual(this.currentModel, model);
331
+ const isSelected = index === this.selectedIndex;
332
+ return html`
333
+ <div
334
+ data-model-item
335
+ class="px-4 py-3 ${
336
+ this.navigationMode === "mouse" ? "hover:bg-muted" : ""
337
+ } cursor-pointer border-b border-border ${isSelected ? "bg-accent" : ""}"
338
+ @click=${() => this.handleSelect(model)}
339
+ @mouseenter=${() => {
340
+ // Only update selection in mouse mode
341
+ if (this.navigationMode === "mouse") {
342
+ this.selectedIndex = index;
343
+ }
344
+ }}
345
+ >
346
+ <div class="flex items-center justify-between gap-2 mb-1">
347
+ <div class="flex items-center gap-2 flex-1 min-w-0">
348
+ <span class="text-sm font-medium text-foreground truncate">${id}</span>
349
+ ${isCurrent ? html`<span class="text-green-500">✓</span>` : ""}
350
+ </div>
351
+ ${Badge(provider, "outline")}
352
+ </div>
353
+ <div class="flex items-center justify-between text-xs text-muted-foreground">
354
+ <div class="flex items-center gap-2">
355
+ <span class="${model.reasoning ? "" : "opacity-30"}">${icon(Brain, "sm")}</span>
356
+ <span class="${model.input.includes("image") ? "" : "opacity-30"}">${icon(ImageIcon, "sm")}</span>
357
+ <span>${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K</span>
358
+ </div>
359
+ <span>${formatModelCost(model.cost)}</span>
360
+ </div>
361
+ </div>
362
+ `;
363
+ })}
364
+ </div>
365
+ `;
366
+ }
367
+ }