@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,46 @@
1
+ import { icon } from "@mariozechner/mini-lit";
2
+ import { html, LitElement, type TemplateResult } from "lit";
3
+ import { customElement, property, state } from "lit/decorators.js";
4
+ import { ChevronDown, ChevronRight } from "lucide";
5
+
6
+ /**
7
+ * Reusable expandable section component for tool renderers.
8
+ * Captures children in connectedCallback and re-renders them in the details area.
9
+ */
10
+ @customElement("expandable-section")
11
+ export class ExpandableSection extends LitElement {
12
+ @property() summary!: string;
13
+ @property({ type: Boolean }) defaultExpanded = false;
14
+ @state() private expanded = false;
15
+ private capturedChildren: Node[] = [];
16
+
17
+ protected createRenderRoot() {
18
+ return this; // light DOM
19
+ }
20
+
21
+ override connectedCallback() {
22
+ super.connectedCallback();
23
+ // Capture children before first render
24
+ this.capturedChildren = Array.from(this.childNodes);
25
+ // Clear children (we'll re-insert them in render)
26
+ this.innerHTML = "";
27
+ this.expanded = this.defaultExpanded;
28
+ }
29
+
30
+ override render(): TemplateResult {
31
+ return html`
32
+ <div>
33
+ <button
34
+ @click=${() => {
35
+ this.expanded = !this.expanded;
36
+ }}
37
+ class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full text-left"
38
+ >
39
+ ${icon(this.expanded ? ChevronDown : ChevronRight, "sm")}
40
+ <span>${this.summary}</span>
41
+ </button>
42
+ ${this.expanded ? html`<div class="mt-2">${this.capturedChildren}</div>` : ""}
43
+ </div>
44
+ `;
45
+ }
46
+ }
@@ -0,0 +1,113 @@
1
+ import { type BaseComponentProps, fc } from "@mariozechner/mini-lit/dist/mini.js";
2
+ import { html } from "lit";
3
+ import { type Ref, ref } from "lit/directives/ref.js";
4
+ import { i18n } from "../utils/i18n.js";
5
+
6
+ export type InputType = "text" | "email" | "password" | "number" | "url" | "tel" | "search";
7
+ export type InputSize = "sm" | "md" | "lg";
8
+
9
+ export interface InputProps extends BaseComponentProps {
10
+ type?: InputType;
11
+ size?: InputSize;
12
+ value?: string;
13
+ placeholder?: string;
14
+ label?: string;
15
+ error?: string;
16
+ disabled?: boolean;
17
+ required?: boolean;
18
+ name?: string;
19
+ autocomplete?: string;
20
+ min?: number;
21
+ max?: number;
22
+ step?: number;
23
+ inputRef?: Ref<HTMLInputElement>;
24
+ onInput?: (e: Event) => void;
25
+ onChange?: (e: Event) => void;
26
+ onKeyDown?: (e: KeyboardEvent) => void;
27
+ onKeyUp?: (e: KeyboardEvent) => void;
28
+ }
29
+
30
+ export const Input = fc<InputProps>(
31
+ ({
32
+ type = "text",
33
+ size = "md",
34
+ value = "",
35
+ placeholder = "",
36
+ label = "",
37
+ error = "",
38
+ disabled = false,
39
+ required = false,
40
+ name = "",
41
+ autocomplete = "",
42
+ min,
43
+ max,
44
+ step,
45
+ inputRef,
46
+ onInput,
47
+ onChange,
48
+ onKeyDown,
49
+ onKeyUp,
50
+ className = "",
51
+ }) => {
52
+ const sizeClasses = {
53
+ sm: "h-8 px-3 py-1 text-sm",
54
+ md: "h-9 px-3 py-1 text-sm md:text-sm",
55
+ lg: "h-10 px-4 py-1 text-base",
56
+ };
57
+
58
+ const baseClasses =
59
+ "flex w-full min-w-0 rounded-md border bg-transparent text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium";
60
+ const interactionClasses =
61
+ "placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground";
62
+ const focusClasses = "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]";
63
+ const darkClasses = "dark:bg-input/30";
64
+ const stateClasses = error
65
+ ? "border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
66
+ : "border-input";
67
+ const disabledClasses = "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
68
+
69
+ const handleInput = (e: Event) => {
70
+ onInput?.(e);
71
+ };
72
+
73
+ const handleChange = (e: Event) => {
74
+ onChange?.(e);
75
+ };
76
+
77
+ return html`
78
+ <div class="flex flex-col gap-1.5 ${className}">
79
+ ${
80
+ label
81
+ ? html`
82
+ <label class="text-sm font-medium text-foreground">
83
+ ${label} ${required ? html`<span class="text-destructive">${i18n("*")}</span>` : ""}
84
+ </label>
85
+ `
86
+ : ""
87
+ }
88
+ <input
89
+ type="${type}"
90
+ class="${baseClasses} ${
91
+ sizeClasses[size]
92
+ } ${interactionClasses} ${focusClasses} ${darkClasses} ${stateClasses} ${disabledClasses}"
93
+ .value=${value}
94
+ placeholder="${placeholder}"
95
+ ?disabled=${disabled}
96
+ ?required=${required}
97
+ ?aria-invalid=${!!error}
98
+ name="${name}"
99
+ autocomplete="${autocomplete}"
100
+ min="${min ?? ""}"
101
+ max="${max ?? ""}"
102
+ step="${step ?? ""}"
103
+ @input=${handleInput}
104
+ @change=${handleChange}
105
+ @keydown=${onKeyDown}
106
+ @keyup=${onKeyUp}
107
+ ${inputRef ? ref(inputRef) : ""}
108
+ />
109
+ ${error ? html`<span class="text-sm text-destructive">${error}</span>` : ""}
110
+ </div>
111
+ `;
112
+ },
113
+ );
@@ -0,0 +1,404 @@
1
+ import { icon } from "@mariozechner/mini-lit";
2
+ import { Button } from "@mariozechner/mini-lit/dist/Button.js";
3
+ import { Select, type SelectOption } from "@mariozechner/mini-lit/dist/Select.js";
4
+ import type { Model } from "@oh-my-pi/pi-ai";
5
+ import { html, LitElement } from "lit";
6
+ import { customElement, property, state } from "lit/decorators.js";
7
+ import { createRef, ref } from "lit/directives/ref.js";
8
+ import { Brain, Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
9
+ import { type Attachment, loadAttachment } from "../utils/attachment-utils.js";
10
+ import { i18n } from "../utils/i18n.js";
11
+ import "./AttachmentTile.js";
12
+ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
13
+
14
+ @customElement("message-editor")
15
+ export class MessageEditor extends LitElement {
16
+ private _value = "";
17
+ private textareaRef = createRef<HTMLTextAreaElement>();
18
+
19
+ @property()
20
+ get value() {
21
+ return this._value;
22
+ }
23
+
24
+ set value(val: string) {
25
+ const oldValue = this._value;
26
+ this._value = val;
27
+ this.requestUpdate("value", oldValue);
28
+ }
29
+
30
+ @property() isStreaming = false;
31
+ @property() currentModel?: Model<any>;
32
+ @property() thinkingLevel: ThinkingLevel = "off";
33
+ @property() showAttachmentButton = true;
34
+ @property() showModelSelector = true;
35
+ @property() showThinkingSelector = true;
36
+ @property() onInput?: (value: string) => void;
37
+ @property() onSend?: (input: string, attachments: Attachment[]) => void;
38
+ @property() onAbort?: () => void;
39
+ @property() onModelSelect?: () => void;
40
+ @property() onThinkingChange?: (level: "off" | "minimal" | "low" | "medium" | "high") => void;
41
+ @property() onFilesChange?: (files: Attachment[]) => void;
42
+ @property() attachments: Attachment[] = [];
43
+ @property() maxFiles = 10;
44
+ @property() maxFileSize = 20 * 1024 * 1024; // 20MB
45
+ @property() acceptedTypes =
46
+ "image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
47
+
48
+ @state() processingFiles = false;
49
+ @state() isDragging = false;
50
+ private fileInputRef = createRef<HTMLInputElement>();
51
+
52
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
53
+ return this;
54
+ }
55
+
56
+ private handleTextareaInput = (e: Event) => {
57
+ const textarea = e.target as HTMLTextAreaElement;
58
+ this.value = textarea.value;
59
+ this.onInput?.(this.value);
60
+ };
61
+
62
+ private handleKeyDown = (e: KeyboardEvent) => {
63
+ if (e.key === "Enter" && !e.shiftKey) {
64
+ e.preventDefault();
65
+ if (!this.isStreaming && !this.processingFiles && (this.value.trim() || this.attachments.length > 0)) {
66
+ this.handleSend();
67
+ }
68
+ } else if (e.key === "Escape" && this.isStreaming) {
69
+ e.preventDefault();
70
+ this.onAbort?.();
71
+ }
72
+ };
73
+
74
+ private handlePaste = async (e: ClipboardEvent) => {
75
+ const items = e.clipboardData?.items;
76
+ if (!items) return;
77
+
78
+ const imageFiles: File[] = [];
79
+
80
+ // Check for image items in clipboard
81
+ for (let i = 0; i < items.length; i++) {
82
+ const item = items[i];
83
+ if (item.type.startsWith("image/")) {
84
+ const file = item.getAsFile();
85
+ if (file) {
86
+ imageFiles.push(file);
87
+ }
88
+ }
89
+ }
90
+
91
+ // If we found images, process them
92
+ if (imageFiles.length > 0) {
93
+ e.preventDefault(); // Prevent default paste behavior
94
+
95
+ if (imageFiles.length + this.attachments.length > this.maxFiles) {
96
+ alert(`Maximum ${this.maxFiles} files allowed`);
97
+ return;
98
+ }
99
+
100
+ this.processingFiles = true;
101
+ const newAttachments: Attachment[] = [];
102
+
103
+ for (const file of imageFiles) {
104
+ try {
105
+ if (file.size > this.maxFileSize) {
106
+ alert(`Image exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
107
+ continue;
108
+ }
109
+
110
+ const attachment = await loadAttachment(file);
111
+ newAttachments.push(attachment);
112
+ } catch (error) {
113
+ console.error("Error processing pasted image:", error);
114
+ alert(`Failed to process pasted image: ${String(error)}`);
115
+ }
116
+ }
117
+
118
+ this.attachments = [...this.attachments, ...newAttachments];
119
+ this.onFilesChange?.(this.attachments);
120
+ this.processingFiles = false;
121
+ }
122
+ };
123
+
124
+ private handleSend = () => {
125
+ this.onSend?.(this.value, this.attachments);
126
+ };
127
+
128
+ private handleAttachmentClick = () => {
129
+ this.fileInputRef.value?.click();
130
+ };
131
+
132
+ private async handleFilesSelected(e: Event) {
133
+ const input = e.target as HTMLInputElement;
134
+ const files = Array.from(input.files || []);
135
+ if (files.length === 0) return;
136
+
137
+ if (files.length + this.attachments.length > this.maxFiles) {
138
+ alert(`Maximum ${this.maxFiles} files allowed`);
139
+ input.value = "";
140
+ return;
141
+ }
142
+
143
+ this.processingFiles = true;
144
+ const newAttachments: Attachment[] = [];
145
+
146
+ for (const file of files) {
147
+ try {
148
+ if (file.size > this.maxFileSize) {
149
+ alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
150
+ continue;
151
+ }
152
+
153
+ const attachment = await loadAttachment(file);
154
+ newAttachments.push(attachment);
155
+ } catch (error) {
156
+ console.error(`Error processing ${file.name}:`, error);
157
+ alert(`Failed to process ${file.name}: ${String(error)}`);
158
+ }
159
+ }
160
+
161
+ this.attachments = [...this.attachments, ...newAttachments];
162
+ this.onFilesChange?.(this.attachments);
163
+ this.processingFiles = false;
164
+ input.value = ""; // Reset input
165
+ }
166
+
167
+ private removeFile(fileId: string) {
168
+ this.attachments = this.attachments.filter((f) => f.id !== fileId);
169
+ this.onFilesChange?.(this.attachments);
170
+ }
171
+
172
+ private handleDragOver = (e: DragEvent) => {
173
+ e.preventDefault();
174
+ e.stopPropagation();
175
+ if (!this.isDragging) {
176
+ this.isDragging = true;
177
+ }
178
+ };
179
+
180
+ private handleDragLeave = (e: DragEvent) => {
181
+ e.preventDefault();
182
+ e.stopPropagation();
183
+ // Only set isDragging to false if we're leaving the entire component
184
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
185
+ const x = e.clientX;
186
+ const y = e.clientY;
187
+ if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
188
+ this.isDragging = false;
189
+ }
190
+ };
191
+
192
+ private handleDrop = async (e: DragEvent) => {
193
+ e.preventDefault();
194
+ e.stopPropagation();
195
+ this.isDragging = false;
196
+
197
+ const files = Array.from(e.dataTransfer?.files || []);
198
+ if (files.length === 0) return;
199
+
200
+ if (files.length + this.attachments.length > this.maxFiles) {
201
+ alert(`Maximum ${this.maxFiles} files allowed`);
202
+ return;
203
+ }
204
+
205
+ this.processingFiles = true;
206
+ const newAttachments: Attachment[] = [];
207
+
208
+ for (const file of files) {
209
+ try {
210
+ if (file.size > this.maxFileSize) {
211
+ alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
212
+ continue;
213
+ }
214
+
215
+ const attachment = await loadAttachment(file);
216
+ newAttachments.push(attachment);
217
+ } catch (error) {
218
+ console.error(`Error processing ${file.name}:`, error);
219
+ alert(`Failed to process ${file.name}: ${String(error)}`);
220
+ }
221
+ }
222
+
223
+ this.attachments = [...this.attachments, ...newAttachments];
224
+ this.onFilesChange?.(this.attachments);
225
+ this.processingFiles = false;
226
+ };
227
+
228
+ override firstUpdated() {
229
+ const textarea = this.textareaRef.value;
230
+ if (textarea) {
231
+ textarea.focus();
232
+ }
233
+ }
234
+
235
+ override render() {
236
+ // Check if current model supports thinking/reasoning
237
+ const model = this.currentModel;
238
+ const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking
239
+
240
+ return html`
241
+ <div
242
+ class="bg-card rounded-xl border shadow-sm relative ${
243
+ this.isDragging ? "border-primary border-2 bg-primary/5" : "border-border"
244
+ }"
245
+ @dragover=${this.handleDragOver}
246
+ @dragleave=${this.handleDragLeave}
247
+ @drop=${this.handleDrop}
248
+ >
249
+ <!-- Drag overlay -->
250
+ ${
251
+ this.isDragging
252
+ ? html`
253
+ <div
254
+ class="absolute inset-0 bg-primary/10 rounded-xl pointer-events-none z-10 flex items-center justify-center"
255
+ >
256
+ <div class="text-primary font-medium">${i18n("Drop files here")}</div>
257
+ </div>
258
+ `
259
+ : ""
260
+ }
261
+
262
+ <!-- Attachments -->
263
+ ${
264
+ this.attachments.length > 0
265
+ ? html`
266
+ <div class="px-4 pt-3 pb-2 flex flex-wrap gap-2">
267
+ ${this.attachments.map(
268
+ (attachment) => html`
269
+ <attachment-tile
270
+ .attachment=${attachment}
271
+ .showDelete=${true}
272
+ .onDelete=${() => this.removeFile(attachment.id)}
273
+ ></attachment-tile>
274
+ `,
275
+ )}
276
+ </div>
277
+ `
278
+ : ""
279
+ }
280
+
281
+ <textarea
282
+ class="w-full bg-transparent p-4 text-foreground placeholder-muted-foreground outline-none resize-none overflow-y-auto"
283
+ placeholder=${i18n("Type a message...")}
284
+ rows="1"
285
+ style="max-height: 200px; field-sizing: content; min-height: 1lh; height: auto;"
286
+ .value=${this.value}
287
+ @input=${this.handleTextareaInput}
288
+ @keydown=${this.handleKeyDown}
289
+ @paste=${this.handlePaste}
290
+ ${ref(this.textareaRef)}
291
+ ></textarea>
292
+
293
+ <!-- Hidden file input -->
294
+ <input
295
+ type="file"
296
+ ${ref(this.fileInputRef)}
297
+ @change=${this.handleFilesSelected}
298
+ accept=${this.acceptedTypes}
299
+ multiple
300
+ style="display: none;"
301
+ />
302
+
303
+ <!-- Button Row -->
304
+ <div class="px-2 pb-2 flex items-center justify-between">
305
+ <!-- Left side - attachment and thinking selector -->
306
+ <div class="flex gap-2 items-center">
307
+ ${
308
+ this.showAttachmentButton
309
+ ? this.processingFiles
310
+ ? html`
311
+ <div class="h-8 w-8 flex items-center justify-center">
312
+ ${icon(Loader2, "sm", "animate-spin text-muted-foreground")}
313
+ </div>
314
+ `
315
+ : html`
316
+ ${Button({
317
+ variant: "ghost",
318
+ size: "icon",
319
+ className: "h-8 w-8",
320
+ onClick: this.handleAttachmentClick,
321
+ children: icon(Paperclip, "sm"),
322
+ })}
323
+ `
324
+ : ""
325
+ }
326
+ ${
327
+ supportsThinking && this.showThinkingSelector
328
+ ? html`
329
+ ${Select({
330
+ value: this.thinkingLevel,
331
+ placeholder: i18n("Off"),
332
+ options: [
333
+ { value: "off", label: i18n("Off"), icon: icon(Brain, "sm") },
334
+ { value: "minimal", label: i18n("Minimal"), icon: icon(Brain, "sm") },
335
+ { value: "low", label: i18n("Low"), icon: icon(Brain, "sm") },
336
+ { value: "medium", label: i18n("Medium"), icon: icon(Brain, "sm") },
337
+ { value: "high", label: i18n("High"), icon: icon(Brain, "sm") },
338
+ ] as SelectOption[],
339
+ onChange: (value: string) => {
340
+ this.onThinkingChange?.(value as "off" | "minimal" | "low" | "medium" | "high");
341
+ },
342
+ width: "80px",
343
+ size: "sm",
344
+ variant: "ghost",
345
+ fitContent: true,
346
+ })}
347
+ `
348
+ : ""
349
+ }
350
+ </div>
351
+
352
+ <!-- Model selector and send on the right -->
353
+ <div class="flex gap-2 items-center">
354
+ ${
355
+ this.showModelSelector && this.currentModel
356
+ ? html`
357
+ ${Button({
358
+ variant: "ghost",
359
+ size: "sm",
360
+ onClick: () => {
361
+ // Focus textarea before opening model selector so focus returns there
362
+ this.textareaRef.value?.focus();
363
+ // Wait for next frame to ensure focus takes effect before dialog captures it
364
+ requestAnimationFrame(() => {
365
+ this.onModelSelect?.();
366
+ });
367
+ },
368
+ children: html`
369
+ ${icon(Sparkles, "sm")}
370
+ <span class="ml-1">${this.currentModel.id}</span>
371
+ `,
372
+ className: "h-8 text-xs truncate",
373
+ })}
374
+ `
375
+ : ""
376
+ }
377
+ ${
378
+ this.isStreaming
379
+ ? html`
380
+ ${Button({
381
+ variant: "ghost",
382
+ size: "icon",
383
+ onClick: this.onAbort,
384
+ children: icon(Square, "sm"),
385
+ className: "h-8 w-8",
386
+ })}
387
+ `
388
+ : html`
389
+ ${Button({
390
+ variant: "ghost",
391
+ size: "icon",
392
+ onClick: this.handleSend,
393
+ disabled: (!this.value.trim() && this.attachments.length === 0) || this.processingFiles,
394
+ children: html`<div style="transform: rotate(-45deg)">${icon(Send, "sm")}</div>`,
395
+ className: "h-8 w-8",
396
+ })}
397
+ `
398
+ }
399
+ </div>
400
+ </div>
401
+ </div>
402
+ `;
403
+ }
404
+ }
@@ -0,0 +1,97 @@
1
+ import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import type {
3
+ AssistantMessage as AssistantMessageType,
4
+ ToolResultMessage as ToolResultMessageType,
5
+ } from "@oh-my-pi/pi-ai";
6
+ import { html, LitElement, type TemplateResult } from "lit";
7
+ import { property } from "lit/decorators.js";
8
+ import { repeat } from "lit/directives/repeat.js";
9
+ import { renderMessage } from "./message-renderer-registry.js";
10
+
11
+ export class MessageList extends LitElement {
12
+ @property({ type: Array }) messages: AgentMessage[] = [];
13
+ @property({ type: Array }) tools: AgentTool[] = [];
14
+ @property({ type: Object }) pendingToolCalls?: Set<string>;
15
+ @property({ type: Boolean }) isStreaming: boolean = false;
16
+ @property({ attribute: false }) onCostClick?: () => void;
17
+
18
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
19
+ return this;
20
+ }
21
+
22
+ override connectedCallback(): void {
23
+ super.connectedCallback();
24
+ this.style.display = "block";
25
+ }
26
+
27
+ private buildRenderItems() {
28
+ // Map tool results by call id for quick lookup
29
+ const resultByCallId = new Map<string, ToolResultMessageType>();
30
+ for (const message of this.messages) {
31
+ if (message.role === "toolResult") {
32
+ resultByCallId.set(message.toolCallId, message);
33
+ }
34
+ }
35
+
36
+ const items: Array<{ key: string; template: TemplateResult }> = [];
37
+ let index = 0;
38
+ for (const msg of this.messages) {
39
+ // Skip artifact messages - they're for session persistence only, not UI display
40
+ if (msg.role === "artifact") {
41
+ continue;
42
+ }
43
+
44
+ // Try custom renderer first
45
+ const customTemplate = renderMessage(msg);
46
+ if (customTemplate) {
47
+ items.push({ key: `msg:${index}`, template: customTemplate });
48
+ index++;
49
+ continue;
50
+ }
51
+
52
+ // Fall back to built-in renderers
53
+ if (msg.role === "user" || msg.role === "user-with-attachments") {
54
+ items.push({
55
+ key: `msg:${index}`,
56
+ template: html`<user-message .message=${msg}></user-message>`,
57
+ });
58
+ index++;
59
+ } else if (msg.role === "assistant") {
60
+ const amsg = msg as AssistantMessageType;
61
+ items.push({
62
+ key: `msg:${index}`,
63
+ template: html`<assistant-message
64
+ .message=${amsg}
65
+ .tools=${this.tools}
66
+ .isStreaming=${false}
67
+ .pendingToolCalls=${this.pendingToolCalls}
68
+ .toolResultsById=${resultByCallId}
69
+ .hideToolCalls=${false}
70
+ .onCostClick=${this.onCostClick}
71
+ ></assistant-message>`,
72
+ });
73
+ index++;
74
+ } else {
75
+ // Skip standalone toolResult messages; they are rendered via paired tool-message above
76
+ // Skip unknown roles
77
+ }
78
+ }
79
+ return items;
80
+ }
81
+
82
+ override render() {
83
+ const items = this.buildRenderItems();
84
+ return html`<div class="flex flex-col gap-3">
85
+ ${repeat(
86
+ items,
87
+ (it) => it.key,
88
+ (it) => it.template,
89
+ )}
90
+ </div>`;
91
+ }
92
+ }
93
+
94
+ // Register custom element
95
+ if (!customElements.get("message-list")) {
96
+ customElements.define("message-list", MessageList);
97
+ }