blob: 35843f78867bc9569a9b30f996e6ca4ef469334f [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;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700137 overflow: hidden;
138 text-overflow: ellipsis;
139 }
140
141 details[open] .summary-text {
142 /*display: none;*/
143 }
144
145 .tool-error-message {
146 font-style: italic;
147 color: #aa0909;
148 }
Sean McCullough2deac842025-04-21 18:17:57 -0700149
150 .elapsed {
151 font-size: 10px;
152 color: #888;
153 font-style: italic;
154 margin-left: 3px;
155 }
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700156 `;
157
158 constructor() {
159 super();
160 }
161
162 connectedCallback() {
163 super.connectedCallback();
164 }
165
166 disconnectedCallback() {
167 super.disconnectedCallback();
168 }
169
170 _cancelToolCall = async (tool_call_id: string, button: HTMLButtonElement) => {
171 console.log("cancelToolCall", tool_call_id, button);
172 button.innerText = "Cancelling";
173 button.disabled = true;
174 try {
175 const response = await fetch("cancel", {
176 method: "POST",
177 headers: {
178 "Content-Type": "application/json",
179 },
180 body: JSON.stringify({
181 tool_call_id: tool_call_id,
182 reason: "user requested cancellation",
183 }),
184 });
185 if (response.ok) {
186 console.log("cancel", tool_call_id, response);
187 button.parentElement.removeChild(button);
188 } else {
189 button.innerText = "Cancel";
190 console.log(`error trying to cancel ${tool_call_id}: `, response);
191 }
192 } catch (e) {
193 console.error("cancel", tool_call_id, e);
194 }
195 };
196
197 render() {
198 const toolCallStatus = this.toolCall?.result_message
199 ? this.toolCall?.result_message.tool_error
200 ? html`❌
201 <span class="tool-error-message"
Sean McCullough2deac842025-04-21 18:17:57 -0700202 >${this.toolCall?.result_message.tool_result}</span
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700203 >`
204 : ""
205 : "⏳";
206
207 const cancelButton = this.toolCall?.result_message
208 ? ""
209 : html`<button
210 class="cancel-button"
211 title="Cancel this operation"
212 @click=${(e: Event) => {
213 e.stopPropagation();
214 const button = e.target as HTMLButtonElement;
215 this._cancelToolCall(this.toolCall?.tool_call_id, button);
216 }}
217 >
218 Cancel
219 </button>`;
220
221 const status = html`<span
222 class="tool-call-status ${this.toolCall?.result_message ? "" : "spinner"}"
223 >${toolCallStatus}</span
224 >`;
225
Sean McCullough2deac842025-04-21 18:17:57 -0700226 const elapsed = html`${this.toolCall?.result_message?.elapsed
227 ? html`<span class="elapsed"
228 >${(this.toolCall?.result_message?.elapsed / 1e9).toFixed(2)}s
229 elapsed</span
230 >`
231 : ""}`;
232
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700233 const ret = html`<div class="tool-call">
234 <details ?open=${this.open}>
235 <summary>
236 <span class="tool-name">${this.toolCall?.name}</span>
237 <span class="summary-text"><slot name="summary"></slot></span>
Sean McCullough2deac842025-04-21 18:17:57 -0700238 ${status} ${cancelButton} ${elapsed}
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700239 </summary>
240 <slot name="input"></slot>
241 <slot name="result"></slot>
242 </details>
243 </div> `;
244 if (true) {
245 return ret;
246 }
247 }
248}
249
250@customElement("sketch-tool-card-bash")
251export class SketchToolCardBash extends LitElement {
252 @property()
253 toolCall: ToolCall;
254
255 @property()
256 open: boolean;
257
258 static styles = css`
259 pre {
Philip Zeyligera54c6a32025-04-23 02:13:36 +0000260 background: rgb(236, 236, 236);
261 color: black;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700262 padding: 0.5em;
263 border-radius: 4px;
264 }
265 .summary-text {
266 overflow: hidden;
267 text-overflow: ellipsis;
268 font-family: monospace;
269 }
270 .input {
271 display: flex;
272 }
273 .input pre {
274 width: 100%;
275 margin-bottom: 0;
276 border-radius: 4px 4px 0 0;
277 }
278 .result pre {
279 margin-top: 0;
Philip Zeyligera54c6a32025-04-23 02:13:36 +0000280 color: #555;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700281 border-radius: 0 0 4px 4px;
282 }
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000283 .background-badge {
284 display: inline-block;
285 background-color: #6200ea;
286 color: white;
287 font-size: 10px;
288 font-weight: bold;
289 padding: 2px 6px;
290 border-radius: 10px;
291 margin-left: 8px;
292 vertical-align: middle;
293 }
294 .command-wrapper {
295 display: flex;
296 align-items: center;
297 }
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700298 `;
299
300 constructor() {
301 super();
302 }
303
304 connectedCallback() {
305 super.connectedCallback();
306 }
307
308 disconnectedCallback() {
309 super.disconnectedCallback();
310 }
311
312 render() {
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000313 const inputData = JSON.parse(this.toolCall?.input || "{}");
314 const isBackground = inputData?.background === true;
315 const backgroundIcon = isBackground ? "🔄 " : "";
316
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700317 return html`
318 <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
Philip Zeyligerb60f0f22025-04-23 18:19:32 +0000319 <span slot="summary" class="summary-text">
320 <div class="command-wrapper">
321 🖥️ ${backgroundIcon}${inputData?.command}
322 </div>
323 </span>
324 <div slot="input" class="input">
325 <pre>🖥️ ${backgroundIcon}${inputData?.command}</pre>
326 </div>
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700327 ${
328 this.toolCall?.result_message
329 ? html` ${this.toolCall?.result_message.tool_result
330 ? html`<div slot="result" class="result">
331 <pre class="tool-call-result">
332${this.toolCall?.result_message.tool_result}</pre
333 >
334 </div>`
335 : ""}`
336 : ""
337 }</div>
338 </sketch-tool-card>`;
339 }
340}
341
342@customElement("sketch-tool-card-codereview")
343export class SketchToolCardCodeReview extends LitElement {
344 @property()
345 toolCall: ToolCall;
346
347 @property()
348 open: boolean;
349
350 static styles = css``;
351
352 constructor() {
353 super();
354 }
355
356 connectedCallback() {
357 super.connectedCallback();
358 }
359
360 disconnectedCallback() {
361 super.disconnectedCallback();
362 }
363 render() {
364 return html` <sketch-tool-card
365 .open=${this.open}
366 .toolCall=${this.toolCall}
367 >
368 <span slot="summary" class="summary-text">
369 ${this.toolCall?.result_message?.tool_result == "OK" ? "✔️" : "⛔"}
370 </span>
371 <div slot="result">
372 <pre>${this.toolCall?.result_message?.tool_result}</pre>
373 </div>
374 </sketch-tool-card>`;
375 }
376}
377
378@customElement("sketch-tool-card-done")
379export class SketchToolCardDone extends LitElement {
380 @property()
381 toolCall: ToolCall;
382
383 @property()
384 open: boolean;
385
386 static styles = css``;
387
388 constructor() {
389 super();
390 }
391
392 connectedCallback() {
393 super.connectedCallback();
394 }
395
396 disconnectedCallback() {
397 super.disconnectedCallback();
398 }
399
400 render() {
401 const doneInput = JSON.parse(this.toolCall.input);
402 return html` <sketch-tool-card
403 .open=${this.open}
404 .toolCall=${this.toolCall}
405 >
406 <span slot="summary" class="summary-text"> </span>
407 <div slot="result">
408 ${Object.keys(doneInput.checklist_items).map((key) => {
409 const item = doneInput.checklist_items[key];
410 let statusIcon = "⛔";
411 if (item.status == "yes") {
412 statusIcon = "👍";
413 } else if (item.status == "not applicable") {
414 statusIcon = "🤷‍♂️";
415 }
416 return html`<div>
417 <span>${statusIcon}</span> ${key}:${item.status}
418 </div>`;
419 })}
420 </div>
421 </sketch-tool-card>`;
422 }
423}
424
425@customElement("sketch-tool-card-patch")
426export class SketchToolCardPatch extends LitElement {
427 @property()
428 toolCall: ToolCall;
429
430 @property()
431 open: boolean;
432
433 static styles = css`
434 .summary-text {
435 color: #555;
436 font-family: monospace;
437 overflow: hidden;
438 text-overflow: ellipsis;
439 white-space: nowrap;
440 border-radius: 3px;
441 }
442 `;
443
444 constructor() {
445 super();
446 }
447
448 connectedCallback() {
449 super.connectedCallback();
450 }
451
452 disconnectedCallback() {
453 super.disconnectedCallback();
454 }
455
456 render() {
457 const patchInput = JSON.parse(this.toolCall?.input);
458 return html` <sketch-tool-card
459 .open=${this.open}
460 .toolCall=${this.toolCall}
461 >
462 <span slot="summary" class="summary-text">
463 ${patchInput?.path}: ${patchInput.patches.length}
464 edit${patchInput.patches.length > 1 ? "s" : ""}
465 </span>
466 <div slot="input">
467 ${patchInput.patches.map((patch) => {
468 return html` Patch operation: <b>${patch.operation}</b>
469 <pre>${patch.newText}</pre>`;
470 })}
471 </div>
472 <div slot="result">
473 <pre>${this.toolCall?.result_message?.tool_result}</pre>
474 </div>
475 </sketch-tool-card>`;
476 }
477}
478
479@customElement("sketch-tool-card-think")
480export class SketchToolCardThink extends LitElement {
481 @property()
482 toolCall: ToolCall;
483
484 @property()
485 open: boolean;
486
487 static styles = css`
488 .thought-bubble {
489 overflow-x: auto;
490 margin-bottom: 3px;
491 font-family: monospace;
492 padding: 3px 5px;
493 background: rgb(236, 236, 236);
494 border-radius: 6px;
495 user-select: text;
496 cursor: text;
497 -webkit-user-select: text;
498 -moz-user-select: text;
499 -ms-user-select: text;
500 font-size: 13px;
501 line-height: 1.3;
502 }
503 .summary-text {
504 overflow: hidden;
505 text-overflow: ellipsis;
506 font-family: monospace;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700507 }
508 `;
509
510 constructor() {
511 super();
512 }
513
514 connectedCallback() {
515 super.connectedCallback();
516 }
517
518 disconnectedCallback() {
519 super.disconnectedCallback();
520 }
521
522 render() {
523 return html`
524 <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
525 <span slot="summary" class="summary-text"
Sean McCulloughffb58a32025-04-28 13:50:56 -0700526 >${JSON.parse(this.toolCall?.input)?.thoughts?.split("\n")[0]}</span
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700527 >
528 <div slot="input" class="thought-bubble">
529 <div class="markdown-content">
530 ${unsafeHTML(
531 renderMarkdown(JSON.parse(this.toolCall?.input)?.thoughts),
532 )}
533 </div>
534 </div>
535 </sketch-tool-card>
536 `;
537 }
538}
539
540@customElement("sketch-tool-card-title")
541export class SketchToolCardTitle extends LitElement {
542 @property()
543 toolCall: ToolCall;
544
545 @property()
546 open: boolean;
547
548 static styles = css`
549 .summary-text {
550 font-style: italic;
551 }
552 `;
553 constructor() {
554 super();
555 }
556
557 connectedCallback() {
558 super.connectedCallback();
559 }
560
561 disconnectedCallback() {
562 super.disconnectedCallback();
563 }
564
565 render() {
566 return html`
567 <span class="summary-text"
568 >I've set the title of this sketch to
569 <b>"${JSON.parse(this.toolCall?.input)?.title}"</b></span
570 >
571 `;
572 }
573}
574
Sean McCulloughfa40c412025-04-28 20:10:04 +0000575@customElement("sketch-tool-card-multiple-choice")
576export class SketchToolCardMultipleChoice extends LitElement {
577 @property()
578 toolCall: ToolCall;
579
580 @property()
581 open: boolean;
582
583 @property()
584 selectedOption: string | number | null = null;
585
586 static styles = css`
587 .options-container {
588 display: flex;
589 flex-direction: row;
590 flex-wrap: wrap;
591 gap: 8px;
592 margin: 10px 0;
593 }
594
595 .option {
596 display: inline-flex;
597 align-items: center;
598 padding: 8px 12px;
599 border-radius: 4px;
600 background-color: #f5f5f5;
601 cursor: pointer;
602 transition: all 0.2s;
603 border: 1px solid transparent;
604 user-select: none;
605 }
606
607 .option:hover {
608 background-color: #e0e0e0;
609 border-color: #ccc;
610 transform: translateY(-1px);
611 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
612 }
613
614 .option:active {
615 transform: translateY(0);
616 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
617 background-color: #d5d5d5;
618 }
619
620 .option.selected {
621 background-color: #e3f2fd;
622 border-color: #2196f3;
623 border-width: 1px;
624 border-style: solid;
625 }
626
627 .option-index {
628 font-size: 0.8em;
629 opacity: 0.7;
630 margin-right: 6px;
631 }
632
633 .option-label {
634 font-family: sans-serif;
635 }
636
637 .option-checkmark {
638 margin-left: 6px;
639 color: #2196f3;
640 }
641
642 .summary-text {
643 font-style: italic;
644 }
645
646 .summary-text strong {
647 font-style: normal;
648 color: #2196f3;
649 font-weight: 600;
650 }
651
652 p {
653 display: flex;
654 align-items: center;
655 flex-wrap: wrap;
656 margin-bottom: 10px;
657 }
658 `;
659
660 constructor() {
661 super();
662 }
663
664 connectedCallback() {
665 super.connectedCallback();
666 this.updateSelectedOption();
667 }
668
669 disconnectedCallback() {
670 super.disconnectedCallback();
671 }
672
673 updated(changedProps) {
674 if (changedProps.has("toolCall")) {
675 this.updateSelectedOption();
676 }
677 }
678
679 updateSelectedOption() {
680 // Get selected option from result if available
681 if (this.toolCall?.result_message?.tool_result) {
682 try {
683 this.selectedOption = JSON.parse(
684 this.toolCall.result_message.tool_result,
685 ).selected;
686 } catch (e) {
687 console.error("Error parsing result:", e);
688 this.selectedOption = this.toolCall.result_message.tool_result;
689 }
690 } else {
691 this.selectedOption = null;
692 }
693 }
694
695 handleOptionClick(choice) {
696 // If this option is already selected, unselect it (toggle behavior)
697 if (this.selectedOption === choice) {
698 this.selectedOption = null;
699 } else {
700 // Otherwise, select the clicked option
701 this.selectedOption = choice;
702 }
703
704 // Dispatch a custom event that can be listened to by parent components
705 const event = new CustomEvent("option-selected", {
706 detail: { selected: this.selectedOption },
707 bubbles: true,
708 composed: true,
709 });
710 this.dispatchEvent(event);
711 }
712
713 render() {
714 // Parse the input to get choices if available
715 let choices = [];
716 let question = "";
717 try {
718 const inputData = JSON.parse(this.toolCall?.input || "{}");
719 choices = inputData.choices || [];
720 question = inputData.question || "Please select an option:";
721 } catch (e) {
722 console.error("Error parsing multiple-choice input:", e);
723 }
724
725 // Determine what to show in the summary slot
726 const summaryContent =
727 this.selectedOption !== null
728 ? html`<span class="summary-text"
729 >${question}: <strong>${this.selectedOption}</strong></span
730 >`
731 : html`<span class="summary-text">${question}</span>`;
732
733 return html` <sketch-tool-card
734 .open=${this.open}
735 .toolCall=${this.toolCall}
736 >
737 <span slot="summary">${summaryContent}</span>
738 <div slot="input">
739 <p>${question}</p>
740 <div class="options-container">
741 ${choices.map((choice, index) => {
742 const isSelected =
743 this.selectedOption !== null &&
744 (this.selectedOption === choice || this.selectedOption === index);
745 return html`
746 <div
747 class="option ${isSelected ? "selected" : ""}"
748 @click=${() => this.handleOptionClick(choice)}
749 >
750 <span class="option-index">${index + 1}</span>
751 <span class="option-label">${choice}</span>
752 ${isSelected
753 ? html`<span class="option-checkmark">✓</span>`
754 : ""}
755 </div>
756 `;
757 })}
758 </div>
759 </div>
760 <div slot="result">
761 ${this.toolCall?.result_message && this.selectedOption
762 ? html`<p>Selected: <strong>${this.selectedOption}</strong></p>`
763 : ""}
764 </div>
765 </sketch-tool-card>`;
766 }
767}
768
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700769@customElement("sketch-tool-card-generic")
770export class SketchToolCardGeneric extends LitElement {
771 @property()
772 toolCall: ToolCall;
773
774 @property()
775 open: boolean;
776
777 constructor() {
778 super();
779 }
780
781 connectedCallback() {
782 super.connectedCallback();
783 }
784
785 disconnectedCallback() {
786 super.disconnectedCallback();
787 }
788
789 render() {
790 return html` <sketch-tool-card
791 .open=${this.open}
792 .toolCall=${this.toolCall}
793 >
794 <span slot="summary" class="summary-text">${this.toolCall?.input}</span>
795 <div slot="input">
796 Input:
797 <pre>${this.toolCall?.input}</pre>
798 </div>
799 <div slot="result">
800 Result:
801 ${this.toolCall?.result_message
802 ? html` ${this.toolCall?.result_message.tool_result
803 ? html`<pre>${this.toolCall?.result_message.tool_result}</pre>`
804 : ""}`
805 : ""}
806 </div>
807 </sketch-tool-card>`;
808 }
809}
810
811declare global {
812 interface HTMLElementTagNameMap {
813 "sketch-tool-card": SketchToolCard;
814 "sketch-tool-card-generic": SketchToolCardGeneric;
815 "sketch-tool-card-bash": SketchToolCardBash;
816 "sketch-tool-card-codereview": SketchToolCardCodeReview;
817 "sketch-tool-card-done": SketchToolCardDone;
818 "sketch-tool-card-patch": SketchToolCardPatch;
819 "sketch-tool-card-think": SketchToolCardThink;
820 "sketch-tool-card-title": SketchToolCardTitle;
Sean McCulloughfa40c412025-04-28 20:10:04 +0000821 "sketch-tool-card-multiple-choice": SketchToolCardMultipleChoice;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700822 }
823}