blob: ed058a204620fbfb353f5d2a46657f43321c4553 [file] [log] [blame]
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001import { css, html, LitElement } from "lit";
2import { unsafeHTML } from "lit/directives/unsafe-html.js";
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07003import { customElement, property } from "lit/decorators.js";
Sean McCulloughd9f13372025-04-21 15:08:49 -07004import { ToolCall } from "../types";
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07005import { marked, MarkedOptions } from "marked";
6
7function renderMarkdown(markdownContent: string): string {
8 try {
9 // Set markdown options for proper code block highlighting and safety
10 const markedOptions: MarkedOptions = {
11 gfm: true, // GitHub Flavored Markdown
12 breaks: true, // Convert newlines to <br>
13 async: false,
14 // DOMPurify is recommended for production, but not included in this implementation
15 };
16 return marked.parse(markdownContent, markedOptions) as string;
17 } catch (error) {
18 console.error("Error rendering markdown:", error);
19 // Fallback to plain text if markdown parsing fails
20 return markdownContent;
21 }
22}
23
24@customElement("sketch-tool-card")
25export class SketchToolCard extends LitElement {
26 @property()
27 toolCall: ToolCall;
28
29 @property()
30 open: boolean;
31
32 static styles = css`
33 .tool-call {
34 display: flex;
35 align-items: center;
36 gap: 8px;
37 white-space: nowrap;
38 }
39
40 .tool-call-status {
41 margin-right: 4px;
42 text-align: center;
43 }
44
45 .tool-call-status.spinner {
46 animation: spin 1s infinite linear;
47 display: inline-block;
48 width: 1em;
49 }
50
51 @keyframes spin {
52 0% {
53 transform: rotate(0deg);
54 }
55 100% {
56 transform: rotate(360deg);
57 }
58 }
59
60 .title {
61 font-style: italic;
62 }
63
64 .cancel-button {
65 background: rgb(76, 175, 80);
66 color: white;
67 border: none;
68 padding: 4px 10px;
69 border-radius: 4px;
70 cursor: pointer;
71 font-size: 12px;
72 margin: 5px;
73 }
74
75 .cancel-button:hover {
76 background: rgb(200, 35, 51) !important;
77 }
78
79 .codereview-OK {
80 color: green;
81 }
82
83 details {
84 border-radius: 4px;
85 padding: 0.25em;
86 margin: 0.25em;
87 display: flex;
88 flex-direction: column;
89 align-items: start;
90 }
91
92 details summary {
93 list-style: none;
94 &::before {
95 cursor: hand;
96 font-family: monospace;
97 content: "+";
98 color: white;
99 background-color: darkgray;
100 border-radius: 1em;
101 padding-left: 0.5em;
102 margin: 0.25em;
103 min-width: 1em;
104 }
105 [open] &::before {
106 content: "-";
107 }
108 }
109
110 details summary:hover {
111 list-style: none;
112 &::before {
113 background-color: gray;
114 }
115 }
116 summary {
117 display: flex;
118 flex-direction: row;
119 flex-wrap: nowrap;
120 justify-content: flex-start;
121 align-items: baseline;
122 }
123
124 summary .tool-name {
125 font-family: monospace;
126 color: white;
127 background: rgb(124 145 160);
128 border-radius: 4px;
129 padding: 0.25em;
130 margin: 0.25em;
131 white-space: pre;
132 }
133
134 .summary-text {
135 padding: 0.25em;
136 display: flex;
137 max-width: 50%;
138 overflow: hidden;
139 text-overflow: ellipsis;
140 }
141
142 details[open] .summary-text {
143 /*display: none;*/
144 }
145
146 .tool-error-message {
147 font-style: italic;
148 color: #aa0909;
149 }
Sean McCullough2deac842025-04-21 18:17:57 -0700150
151 .elapsed {
152 font-size: 10px;
153 color: #888;
154 font-style: italic;
155 margin-left: 3px;
156 }
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700157 `;
158
159 constructor() {
160 super();
161 }
162
163 connectedCallback() {
164 super.connectedCallback();
165 }
166
167 disconnectedCallback() {
168 super.disconnectedCallback();
169 }
170
171 _cancelToolCall = async (tool_call_id: string, button: HTMLButtonElement) => {
172 console.log("cancelToolCall", tool_call_id, button);
173 button.innerText = "Cancelling";
174 button.disabled = true;
175 try {
176 const response = await fetch("cancel", {
177 method: "POST",
178 headers: {
179 "Content-Type": "application/json",
180 },
181 body: JSON.stringify({
182 tool_call_id: tool_call_id,
183 reason: "user requested cancellation",
184 }),
185 });
186 if (response.ok) {
187 console.log("cancel", tool_call_id, response);
188 button.parentElement.removeChild(button);
189 } else {
190 button.innerText = "Cancel";
191 console.log(`error trying to cancel ${tool_call_id}: `, response);
192 }
193 } catch (e) {
194 console.error("cancel", tool_call_id, e);
195 }
196 };
197
198 render() {
199 const toolCallStatus = this.toolCall?.result_message
200 ? this.toolCall?.result_message.tool_error
201 ? html`❌
202 <span class="tool-error-message"
Sean McCullough2deac842025-04-21 18:17:57 -0700203 >${this.toolCall?.result_message.tool_result}</span
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700204 >`
205 : ""
206 : "⏳";
207
208 const cancelButton = this.toolCall?.result_message
209 ? ""
210 : html`<button
211 class="cancel-button"
212 title="Cancel this operation"
213 @click=${(e: Event) => {
214 e.stopPropagation();
215 const button = e.target as HTMLButtonElement;
216 this._cancelToolCall(this.toolCall?.tool_call_id, button);
217 }}
218 >
219 Cancel
220 </button>`;
221
222 const status = html`<span
223 class="tool-call-status ${this.toolCall?.result_message ? "" : "spinner"}"
224 >${toolCallStatus}</span
225 >`;
226
Sean McCullough2deac842025-04-21 18:17:57 -0700227 const elapsed = html`${this.toolCall?.result_message?.elapsed
228 ? html`<span class="elapsed"
229 >${(this.toolCall?.result_message?.elapsed / 1e9).toFixed(2)}s
230 elapsed</span
231 >`
232 : ""}`;
233
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700234 const ret = html`<div class="tool-call">
235 <details ?open=${this.open}>
236 <summary>
237 <span class="tool-name">${this.toolCall?.name}</span>
238 <span class="summary-text"><slot name="summary"></slot></span>
Sean McCullough2deac842025-04-21 18:17:57 -0700239 ${status} ${cancelButton} ${elapsed}
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700240 </summary>
241 <slot name="input"></slot>
242 <slot name="result"></slot>
243 </details>
244 </div> `;
245 if (true) {
246 return ret;
247 }
248 }
249}
250
251@customElement("sketch-tool-card-bash")
252export class SketchToolCardBash extends LitElement {
253 @property()
254 toolCall: ToolCall;
255
256 @property()
257 open: boolean;
258
259 static styles = css`
260 pre {
Philip Zeyligera54c6a32025-04-23 02:13:36 +0000261 background: rgb(236, 236, 236);
262 color: black;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700263 padding: 0.5em;
264 border-radius: 4px;
265 }
266 .summary-text {
267 overflow: hidden;
268 text-overflow: ellipsis;
269 font-family: monospace;
270 }
271 .input {
272 display: flex;
273 }
274 .input pre {
275 width: 100%;
276 margin-bottom: 0;
277 border-radius: 4px 4px 0 0;
278 }
279 .result pre {
280 margin-top: 0;
Philip Zeyligera54c6a32025-04-23 02:13:36 +0000281 color: #555;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700282 border-radius: 0 0 4px 4px;
283 }
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000284 .background-badge {
285 display: inline-block;
286 background-color: #6200ea;
287 color: white;
288 font-size: 10px;
289 font-weight: bold;
290 padding: 2px 6px;
291 border-radius: 10px;
292 margin-left: 8px;
293 vertical-align: middle;
294 }
295 .command-wrapper {
296 display: flex;
297 align-items: center;
298 }
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700299 `;
300
301 constructor() {
302 super();
303 }
304
305 connectedCallback() {
306 super.connectedCallback();
307 }
308
309 disconnectedCallback() {
310 super.disconnectedCallback();
311 }
312
313 render() {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000314 const inputData = JSON.parse(this.toolCall?.input || "{}");
315 const isBackground = inputData?.background === true;
316 const backgroundIcon = isBackground ? "🔄 " : "";
317
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700318 return html`
319 <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000320 <span slot="summary" class="summary-text">
321 <div class="command-wrapper">
322 🖥️ ${backgroundIcon}${inputData?.command}
323 </div>
324 </span>
325 <div slot="input" class="input">
326 <pre>🖥️ ${backgroundIcon}${inputData?.command}</pre>
327 </div>
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700328 ${
329 this.toolCall?.result_message
330 ? html` ${this.toolCall?.result_message.tool_result
331 ? html`<div slot="result" class="result">
332 <pre class="tool-call-result">
333${this.toolCall?.result_message.tool_result}</pre
334 >
335 </div>`
336 : ""}`
337 : ""
338 }</div>
339 </sketch-tool-card>`;
340 }
341}
342
343@customElement("sketch-tool-card-codereview")
344export class SketchToolCardCodeReview extends LitElement {
345 @property()
346 toolCall: ToolCall;
347
348 @property()
349 open: boolean;
350
351 static styles = css``;
352
353 constructor() {
354 super();
355 }
356
357 connectedCallback() {
358 super.connectedCallback();
359 }
360
361 disconnectedCallback() {
362 super.disconnectedCallback();
363 }
364 render() {
365 return html` <sketch-tool-card
366 .open=${this.open}
367 .toolCall=${this.toolCall}
368 >
369 <span slot="summary" class="summary-text">
370 ${this.toolCall?.result_message?.tool_result == "OK" ? "✔️" : "⛔"}
371 </span>
372 <div slot="result">
373 <pre>${this.toolCall?.result_message?.tool_result}</pre>
374 </div>
375 </sketch-tool-card>`;
376 }
377}
378
379@customElement("sketch-tool-card-done")
380export class SketchToolCardDone extends LitElement {
381 @property()
382 toolCall: ToolCall;
383
384 @property()
385 open: boolean;
386
387 static styles = css``;
388
389 constructor() {
390 super();
391 }
392
393 connectedCallback() {
394 super.connectedCallback();
395 }
396
397 disconnectedCallback() {
398 super.disconnectedCallback();
399 }
400
401 render() {
402 const doneInput = JSON.parse(this.toolCall.input);
403 return html` <sketch-tool-card
404 .open=${this.open}
405 .toolCall=${this.toolCall}
406 >
407 <span slot="summary" class="summary-text"> </span>
408 <div slot="result">
409 ${Object.keys(doneInput.checklist_items).map((key) => {
410 const item = doneInput.checklist_items[key];
411 let statusIcon = "⛔";
412 if (item.status == "yes") {
413 statusIcon = "👍";
414 } else if (item.status == "not applicable") {
415 statusIcon = "🤷‍♂️";
416 }
417 return html`<div>
418 <span>${statusIcon}</span> ${key}:${item.status}
419 </div>`;
420 })}
421 </div>
422 </sketch-tool-card>`;
423 }
424}
425
426@customElement("sketch-tool-card-patch")
427export class SketchToolCardPatch extends LitElement {
428 @property()
429 toolCall: ToolCall;
430
431 @property()
432 open: boolean;
433
434 static styles = css`
435 .summary-text {
436 color: #555;
437 font-family: monospace;
438 overflow: hidden;
439 text-overflow: ellipsis;
440 white-space: nowrap;
441 border-radius: 3px;
442 }
443 `;
444
445 constructor() {
446 super();
447 }
448
449 connectedCallback() {
450 super.connectedCallback();
451 }
452
453 disconnectedCallback() {
454 super.disconnectedCallback();
455 }
456
457 render() {
458 const patchInput = JSON.parse(this.toolCall?.input);
459 return html` <sketch-tool-card
460 .open=${this.open}
461 .toolCall=${this.toolCall}
462 >
463 <span slot="summary" class="summary-text">
464 ${patchInput?.path}: ${patchInput.patches.length}
465 edit${patchInput.patches.length > 1 ? "s" : ""}
466 </span>
467 <div slot="input">
468 ${patchInput.patches.map((patch) => {
469 return html` Patch operation: <b>${patch.operation}</b>
470 <pre>${patch.newText}</pre>`;
471 })}
472 </div>
473 <div slot="result">
474 <pre>${this.toolCall?.result_message?.tool_result}</pre>
475 </div>
476 </sketch-tool-card>`;
477 }
478}
479
480@customElement("sketch-tool-card-think")
481export class SketchToolCardThink extends LitElement {
482 @property()
483 toolCall: ToolCall;
484
485 @property()
486 open: boolean;
487
488 static styles = css`
489 .thought-bubble {
490 overflow-x: auto;
491 margin-bottom: 3px;
492 font-family: monospace;
493 padding: 3px 5px;
494 background: rgb(236, 236, 236);
495 border-radius: 6px;
496 user-select: text;
497 cursor: text;
498 -webkit-user-select: text;
499 -moz-user-select: text;
500 -ms-user-select: text;
501 font-size: 13px;
502 line-height: 1.3;
503 }
504 .summary-text {
505 overflow: hidden;
506 text-overflow: ellipsis;
507 font-family: monospace;
508 max-width: 50%;
509 }
510 `;
511
512 constructor() {
513 super();
514 }
515
516 connectedCallback() {
517 super.connectedCallback();
518 }
519
520 disconnectedCallback() {
521 super.disconnectedCallback();
522 }
523
524 render() {
525 return html`
526 <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
527 <span slot="summary" class="summary-text"
528 >${JSON.parse(this.toolCall?.input)?.thoughts}</span
529 >
530 <div slot="input" class="thought-bubble">
531 <div class="markdown-content">
532 ${unsafeHTML(
533 renderMarkdown(JSON.parse(this.toolCall?.input)?.thoughts),
534 )}
535 </div>
536 </div>
537 </sketch-tool-card>
538 `;
539 }
540}
541
542@customElement("sketch-tool-card-title")
543export class SketchToolCardTitle extends LitElement {
544 @property()
545 toolCall: ToolCall;
546
547 @property()
548 open: boolean;
549
550 static styles = css`
551 .summary-text {
552 font-style: italic;
553 }
554 `;
555 constructor() {
556 super();
557 }
558
559 connectedCallback() {
560 super.connectedCallback();
561 }
562
563 disconnectedCallback() {
564 super.disconnectedCallback();
565 }
566
567 render() {
568 return html`
569 <span class="summary-text"
570 >I've set the title of this sketch to
571 <b>"${JSON.parse(this.toolCall?.input)?.title}"</b></span
572 >
573 `;
574 }
575}
576
Sean McCulloughfa40c412025-04-28 20:10:04 +0000577@customElement("sketch-tool-card-multiple-choice")
578export class SketchToolCardMultipleChoice extends LitElement {
579 @property()
580 toolCall: ToolCall;
581
582 @property()
583 open: boolean;
584
585 @property()
586 selectedOption: string | number | null = null;
587
588 static styles = css`
589 .options-container {
590 display: flex;
591 flex-direction: row;
592 flex-wrap: wrap;
593 gap: 8px;
594 margin: 10px 0;
595 }
596
597 .option {
598 display: inline-flex;
599 align-items: center;
600 padding: 8px 12px;
601 border-radius: 4px;
602 background-color: #f5f5f5;
603 cursor: pointer;
604 transition: all 0.2s;
605 border: 1px solid transparent;
606 user-select: none;
607 }
608
609 .option:hover {
610 background-color: #e0e0e0;
611 border-color: #ccc;
612 transform: translateY(-1px);
613 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
614 }
615
616 .option:active {
617 transform: translateY(0);
618 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
619 background-color: #d5d5d5;
620 }
621
622 .option.selected {
623 background-color: #e3f2fd;
624 border-color: #2196f3;
625 border-width: 1px;
626 border-style: solid;
627 }
628
629 .option-index {
630 font-size: 0.8em;
631 opacity: 0.7;
632 margin-right: 6px;
633 }
634
635 .option-label {
636 font-family: sans-serif;
637 }
638
639 .option-checkmark {
640 margin-left: 6px;
641 color: #2196f3;
642 }
643
644 .summary-text {
645 font-style: italic;
646 }
647
648 .summary-text strong {
649 font-style: normal;
650 color: #2196f3;
651 font-weight: 600;
652 }
653
654 p {
655 display: flex;
656 align-items: center;
657 flex-wrap: wrap;
658 margin-bottom: 10px;
659 }
660 `;
661
662 constructor() {
663 super();
664 }
665
666 connectedCallback() {
667 super.connectedCallback();
668 this.updateSelectedOption();
669 }
670
671 disconnectedCallback() {
672 super.disconnectedCallback();
673 }
674
675 updated(changedProps) {
676 if (changedProps.has("toolCall")) {
677 this.updateSelectedOption();
678 }
679 }
680
681 updateSelectedOption() {
682 // Get selected option from result if available
683 if (this.toolCall?.result_message?.tool_result) {
684 try {
685 this.selectedOption = JSON.parse(
686 this.toolCall.result_message.tool_result,
687 ).selected;
688 } catch (e) {
689 console.error("Error parsing result:", e);
690 this.selectedOption = this.toolCall.result_message.tool_result;
691 }
692 } else {
693 this.selectedOption = null;
694 }
695 }
696
697 handleOptionClick(choice) {
698 // If this option is already selected, unselect it (toggle behavior)
699 if (this.selectedOption === choice) {
700 this.selectedOption = null;
701 } else {
702 // Otherwise, select the clicked option
703 this.selectedOption = choice;
704 }
705
706 // Dispatch a custom event that can be listened to by parent components
707 const event = new CustomEvent("option-selected", {
708 detail: { selected: this.selectedOption },
709 bubbles: true,
710 composed: true,
711 });
712 this.dispatchEvent(event);
713 }
714
715 render() {
716 // Parse the input to get choices if available
717 let choices = [];
718 let question = "";
719 try {
720 const inputData = JSON.parse(this.toolCall?.input || "{}");
721 choices = inputData.choices || [];
722 question = inputData.question || "Please select an option:";
723 } catch (e) {
724 console.error("Error parsing multiple-choice input:", e);
725 }
726
727 // Determine what to show in the summary slot
728 const summaryContent =
729 this.selectedOption !== null
730 ? html`<span class="summary-text"
731 >${question}: <strong>${this.selectedOption}</strong></span
732 >`
733 : html`<span class="summary-text">${question}</span>`;
734
735 return html` <sketch-tool-card
736 .open=${this.open}
737 .toolCall=${this.toolCall}
738 >
739 <span slot="summary">${summaryContent}</span>
740 <div slot="input">
741 <p>${question}</p>
742 <div class="options-container">
743 ${choices.map((choice, index) => {
744 const isSelected =
745 this.selectedOption !== null &&
746 (this.selectedOption === choice || this.selectedOption === index);
747 return html`
748 <div
749 class="option ${isSelected ? "selected" : ""}"
750 @click=${() => this.handleOptionClick(choice)}
751 >
752 <span class="option-index">${index + 1}</span>
753 <span class="option-label">${choice}</span>
754 ${isSelected
755 ? html`<span class="option-checkmark">✓</span>`
756 : ""}
757 </div>
758 `;
759 })}
760 </div>
761 </div>
762 <div slot="result">
763 ${this.toolCall?.result_message && this.selectedOption
764 ? html`<p>Selected: <strong>${this.selectedOption}</strong></p>`
765 : ""}
766 </div>
767 </sketch-tool-card>`;
768 }
769}
770
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700771@customElement("sketch-tool-card-generic")
772export class SketchToolCardGeneric extends LitElement {
773 @property()
774 toolCall: ToolCall;
775
776 @property()
777 open: boolean;
778
779 constructor() {
780 super();
781 }
782
783 connectedCallback() {
784 super.connectedCallback();
785 }
786
787 disconnectedCallback() {
788 super.disconnectedCallback();
789 }
790
791 render() {
792 return html` <sketch-tool-card
793 .open=${this.open}
794 .toolCall=${this.toolCall}
795 >
796 <span slot="summary" class="summary-text">${this.toolCall?.input}</span>
797 <div slot="input">
798 Input:
799 <pre>${this.toolCall?.input}</pre>
800 </div>
801 <div slot="result">
802 Result:
803 ${this.toolCall?.result_message
804 ? html` ${this.toolCall?.result_message.tool_result
805 ? html`<pre>${this.toolCall?.result_message.tool_result}</pre>`
806 : ""}`
807 : ""}
808 </div>
809 </sketch-tool-card>`;
810 }
811}
812
813declare global {
814 interface HTMLElementTagNameMap {
815 "sketch-tool-card": SketchToolCard;
816 "sketch-tool-card-generic": SketchToolCardGeneric;
817 "sketch-tool-card-bash": SketchToolCardBash;
818 "sketch-tool-card-codereview": SketchToolCardCodeReview;
819 "sketch-tool-card-done": SketchToolCardDone;
820 "sketch-tool-card-patch": SketchToolCardPatch;
821 "sketch-tool-card-think": SketchToolCardThink;
822 "sketch-tool-card-title": SketchToolCardTitle;
Sean McCulloughfa40c412025-04-28 20:10:04 +0000823 "sketch-tool-card-multiple-choice": SketchToolCardMultipleChoice;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700824 }
825}