@oh-my-pi/pi-web-ui 1.337.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 +96 -0
  2. package/README.md +609 -0
  3. package/example/README.md +61 -0
  4. package/example/index.html +13 -0
  5. package/example/package.json +24 -0
  6. package/example/src/app.css +1 -0
  7. package/example/src/custom-messages.ts +99 -0
  8. package/example/src/main.ts +420 -0
  9. package/example/tsconfig.json +23 -0
  10. package/example/vite.config.ts +6 -0
  11. package/package.json +57 -0
  12. package/scripts/count-prompt-tokens.ts +88 -0
  13. package/src/ChatPanel.ts +218 -0
  14. package/src/app.css +68 -0
  15. package/src/components/AgentInterface.ts +390 -0
  16. package/src/components/AttachmentTile.ts +107 -0
  17. package/src/components/ConsoleBlock.ts +74 -0
  18. package/src/components/CustomProviderCard.ts +96 -0
  19. package/src/components/ExpandableSection.ts +46 -0
  20. package/src/components/Input.ts +113 -0
  21. package/src/components/MessageEditor.ts +404 -0
  22. package/src/components/MessageList.ts +97 -0
  23. package/src/components/Messages.ts +384 -0
  24. package/src/components/ProviderKeyInput.ts +152 -0
  25. package/src/components/SandboxedIframe.ts +626 -0
  26. package/src/components/StreamingMessageContainer.ts +107 -0
  27. package/src/components/ThinkingBlock.ts +45 -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 +640 -0
  38. package/src/dialogs/CustomProviderDialog.ts +274 -0
  39. package/src/dialogs/ModelSelector.ts +314 -0
  40. package/src/dialogs/PersistentStorageDialog.ts +146 -0
  41. package/src/dialogs/ProvidersModelsTab.ts +212 -0
  42. package/src/dialogs/SessionListDialog.ts +157 -0
  43. package/src/dialogs/SettingsDialog.ts +216 -0
  44. package/src/index.ts +115 -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 +102 -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 +118 -0
  60. package/src/tools/artifacts/HtmlArtifact.ts +203 -0
  61. package/src/tools/artifacts/ImageArtifact.ts +116 -0
  62. package/src/tools/artifacts/MarkdownArtifact.ts +83 -0
  63. package/src/tools/artifacts/PdfArtifact.ts +201 -0
  64. package/src/tools/artifacts/SvgArtifact.ts +82 -0
  65. package/src/tools/artifacts/TextArtifact.ts +148 -0
  66. package/src/tools/artifacts/artifacts-tool-renderer.ts +371 -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 +271 -0
  70. package/src/tools/index.ts +46 -0
  71. package/src/tools/javascript-repl.ts +316 -0
  72. package/src/tools/renderer-registry.ts +127 -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 +95 -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 +134 -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 "@oh-my-pi/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,314 @@
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 "@oh-my-pi/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
+ @customElement("agent-model-selector")
19
+ export class ModelSelector extends DialogBase {
20
+ @state() currentModel: Model<any> | null = null;
21
+ @state() searchQuery = "";
22
+ @state() filterThinking = false;
23
+ @state() filterVision = false;
24
+ @state() customProvidersLoading = false;
25
+ @state() selectedIndex = 0;
26
+ @state() private navigationMode: "mouse" | "keyboard" = "mouse";
27
+ @state() private customProviderModels: Model<any>[] = [];
28
+
29
+ private onSelectCallback?: (model: Model<any>) => void;
30
+ private scrollContainerRef = createRef<HTMLDivElement>();
31
+ private searchInputRef = createRef<HTMLInputElement>();
32
+ private lastMousePosition = { x: 0, y: 0 };
33
+
34
+ protected override modalWidth = "min(400px, 90vw)";
35
+
36
+ static async open(currentModel: Model<any> | null, onSelect: (model: Model<any>) => void) {
37
+ const selector = new ModelSelector();
38
+ selector.currentModel = currentModel;
39
+ selector.onSelectCallback = onSelect;
40
+ selector.open();
41
+ selector.loadCustomProviders();
42
+ }
43
+
44
+ override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
45
+ super.firstUpdated(changedProperties);
46
+ // Wait for dialog to be fully rendered
47
+ await this.updateComplete;
48
+ // Focus the search input when dialog opens
49
+ this.searchInputRef.value?.focus();
50
+
51
+ // Track actual mouse movement
52
+ this.addEventListener("mousemove", (e: MouseEvent) => {
53
+ // Check if mouse actually moved
54
+ if (e.clientX !== this.lastMousePosition.x || e.clientY !== this.lastMousePosition.y) {
55
+ this.lastMousePosition = { x: e.clientX, y: e.clientY };
56
+ // Only switch to mouse mode on actual mouse movement
57
+ if (this.navigationMode === "keyboard") {
58
+ this.navigationMode = "mouse";
59
+ // Update selection to the item under the mouse
60
+ const target = e.target as HTMLElement;
61
+ const modelItem = target.closest("[data-model-item]");
62
+ if (modelItem) {
63
+ const allItems = this.scrollContainerRef.value?.querySelectorAll("[data-model-item]");
64
+ if (allItems) {
65
+ const index = Array.from(allItems).indexOf(modelItem);
66
+ if (index !== -1) {
67
+ this.selectedIndex = index;
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ });
74
+
75
+ // Add global keyboard handler for the dialog
76
+ this.addEventListener("keydown", (e: KeyboardEvent) => {
77
+ // Get filtered models to know the bounds
78
+ const filteredModels = this.getFilteredModels();
79
+
80
+ if (e.key === "ArrowDown") {
81
+ e.preventDefault();
82
+ this.navigationMode = "keyboard";
83
+ this.selectedIndex = Math.min(this.selectedIndex + 1, filteredModels.length - 1);
84
+ this.scrollToSelected();
85
+ } else if (e.key === "ArrowUp") {
86
+ e.preventDefault();
87
+ this.navigationMode = "keyboard";
88
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
89
+ this.scrollToSelected();
90
+ } else if (e.key === "Enter") {
91
+ e.preventDefault();
92
+ if (filteredModels[this.selectedIndex]) {
93
+ this.handleSelect(filteredModels[this.selectedIndex].model);
94
+ }
95
+ }
96
+ });
97
+ }
98
+
99
+ private async loadCustomProviders() {
100
+ this.customProvidersLoading = true;
101
+ const allCustomModels: Model<any>[] = [];
102
+
103
+ try {
104
+ const storage = getAppStorage();
105
+ const customProviders = await storage.customProviders.getAll();
106
+
107
+ // Load models from custom providers
108
+ for (const provider of customProviders) {
109
+ const isAutoDiscovery: boolean =
110
+ provider.type === "ollama" ||
111
+ provider.type === "llama.cpp" ||
112
+ provider.type === "vllm" ||
113
+ provider.type === "lmstudio";
114
+
115
+ if (isAutoDiscovery) {
116
+ try {
117
+ const models = await discoverModels(
118
+ provider.type as AutoDiscoveryProviderType,
119
+ provider.baseUrl,
120
+ provider.apiKey,
121
+ );
122
+
123
+ const modelsWithProvider = models.map((model) => ({
124
+ ...model,
125
+ provider: provider.name,
126
+ }));
127
+
128
+ allCustomModels.push(...modelsWithProvider);
129
+ } catch (error) {
130
+ console.debug(`Failed to load models from ${provider.name}:`, error);
131
+ }
132
+ } else if (provider.models) {
133
+ // Manual provider - models already defined
134
+ allCustomModels.push(...provider.models);
135
+ }
136
+ }
137
+ } catch (error) {
138
+ console.error("Failed to load custom providers:", error);
139
+ } finally {
140
+ this.customProviderModels = allCustomModels;
141
+ this.customProvidersLoading = false;
142
+ this.requestUpdate();
143
+ }
144
+ }
145
+
146
+ private formatTokens(tokens: number): string {
147
+ if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(0)}M`;
148
+ if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}`;
149
+ return String(tokens);
150
+ }
151
+
152
+ private handleSelect(model: Model<any>) {
153
+ if (model) {
154
+ this.onSelectCallback?.(model);
155
+ this.close();
156
+ }
157
+ }
158
+
159
+ private getFilteredModels(): Array<{ provider: string; id: string; model: any }> {
160
+ // Collect all models from known providers
161
+ const allModels: Array<{ provider: string; id: string; model: any }> = [];
162
+ const knownProviders = getProviders();
163
+
164
+ for (const provider of knownProviders) {
165
+ const models = getModels(provider as any);
166
+ for (const model of models) {
167
+ allModels.push({ provider, id: model.id, model });
168
+ }
169
+ }
170
+
171
+ // Add custom provider models
172
+ for (const model of this.customProviderModels) {
173
+ allModels.push({ provider: model.provider, id: model.id, model });
174
+ }
175
+
176
+ // Filter models based on search and capability filters
177
+ let filteredModels = allModels;
178
+
179
+ // Apply search filter
180
+ if (this.searchQuery) {
181
+ filteredModels = filteredModels.filter(({ provider, id, model }) => {
182
+ const searchTokens = this.searchQuery.split(/\s+/).filter((t) => t);
183
+ const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
184
+ return searchTokens.every((token) => searchText.includes(token));
185
+ });
186
+ }
187
+
188
+ // Apply capability filters
189
+ if (this.filterThinking) {
190
+ filteredModels = filteredModels.filter(({ model }) => model.reasoning);
191
+ }
192
+ if (this.filterVision) {
193
+ filteredModels = filteredModels.filter(({ model }) => model.input.includes("image"));
194
+ }
195
+
196
+ // Sort: current model first, then by provider
197
+ filteredModels.sort((a, b) => {
198
+ const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
199
+ const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
200
+ if (aIsCurrent && !bIsCurrent) return -1;
201
+ if (!aIsCurrent && bIsCurrent) return 1;
202
+ return a.provider.localeCompare(b.provider);
203
+ });
204
+
205
+ return filteredModels;
206
+ }
207
+
208
+ private scrollToSelected() {
209
+ requestAnimationFrame(() => {
210
+ const scrollContainer = this.scrollContainerRef.value;
211
+ const selectedElement = scrollContainer?.querySelectorAll("[data-model-item]")[
212
+ this.selectedIndex
213
+ ] as HTMLElement;
214
+ if (selectedElement) {
215
+ selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" });
216
+ }
217
+ });
218
+ }
219
+
220
+ protected override renderContent(): TemplateResult {
221
+ const filteredModels = this.getFilteredModels();
222
+
223
+ return html`
224
+ <!-- Header and Search -->
225
+ <div class="p-6 pb-4 flex flex-col gap-4 border-b border-border flex-shrink-0">
226
+ ${DialogHeader({ title: i18n("Select Model") })}
227
+ ${Input({
228
+ placeholder: i18n("Search models..."),
229
+ value: this.searchQuery,
230
+ inputRef: this.searchInputRef,
231
+ onInput: (e: Event) => {
232
+ this.searchQuery = (e.target as HTMLInputElement).value;
233
+ this.selectedIndex = 0;
234
+ // Reset scroll position when search changes
235
+ if (this.scrollContainerRef.value) {
236
+ this.scrollContainerRef.value.scrollTop = 0;
237
+ }
238
+ },
239
+ })}
240
+ <div class="flex gap-2">
241
+ ${Button({
242
+ variant: this.filterThinking ? "default" : "secondary",
243
+ size: "sm",
244
+ onClick: () => {
245
+ this.filterThinking = !this.filterThinking;
246
+ this.selectedIndex = 0;
247
+ if (this.scrollContainerRef.value) {
248
+ this.scrollContainerRef.value.scrollTop = 0;
249
+ }
250
+ },
251
+ className: "rounded-full",
252
+ children: html`<span class="inline-flex items-center gap-1"
253
+ >${icon(Brain, "sm")} ${i18n("Thinking")}</span
254
+ >`,
255
+ })}
256
+ ${Button({
257
+ variant: this.filterVision ? "default" : "secondary",
258
+ size: "sm",
259
+ onClick: () => {
260
+ this.filterVision = !this.filterVision;
261
+ this.selectedIndex = 0;
262
+ if (this.scrollContainerRef.value) {
263
+ this.scrollContainerRef.value.scrollTop = 0;
264
+ }
265
+ },
266
+ className: "rounded-full",
267
+ children: html`<span class="inline-flex items-center gap-1"
268
+ >${icon(ImageIcon, "sm")} ${i18n("Vision")}</span
269
+ >`,
270
+ })}
271
+ </div>
272
+ </div>
273
+
274
+ <!-- Scrollable model list -->
275
+ <div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
276
+ ${filteredModels.map(({ provider, id, model }, index) => {
277
+ const isCurrent = modelsAreEqual(this.currentModel, model);
278
+ const isSelected = index === this.selectedIndex;
279
+ return html`
280
+ <div
281
+ data-model-item
282
+ class="px-4 py-3 ${
283
+ this.navigationMode === "mouse" ? "hover:bg-muted" : ""
284
+ } cursor-pointer border-b border-border ${isSelected ? "bg-accent" : ""}"
285
+ @click=${() => this.handleSelect(model)}
286
+ @mouseenter=${() => {
287
+ // Only update selection in mouse mode
288
+ if (this.navigationMode === "mouse") {
289
+ this.selectedIndex = index;
290
+ }
291
+ }}
292
+ >
293
+ <div class="flex items-center justify-between gap-2 mb-1">
294
+ <div class="flex items-center gap-2 flex-1 min-w-0">
295
+ <span class="text-sm font-medium text-foreground truncate">${id}</span>
296
+ ${isCurrent ? html`<span class="text-green-500">✓</span>` : ""}
297
+ </div>
298
+ ${Badge(provider, "outline")}
299
+ </div>
300
+ <div class="flex items-center justify-between text-xs text-muted-foreground">
301
+ <div class="flex items-center gap-2">
302
+ <span class="${model.reasoning ? "" : "opacity-30"}">${icon(Brain, "sm")}</span>
303
+ <span class="${model.input.includes("image") ? "" : "opacity-30"}">${icon(ImageIcon, "sm")}</span>
304
+ <span>${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K</span>
305
+ </div>
306
+ <span>${formatModelCost(model.cost)}</span>
307
+ </div>
308
+ </div>
309
+ `;
310
+ })}
311
+ </div>
312
+ `;
313
+ }
314
+ }