blob: fdcebaed561af81ff050ffa0cd75aa043b58eaab [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
Josh Bleecher Snyder5fabc742025-04-29 14:09:50 +0000200 ? html`🙈
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700201 <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 }
Josh Bleecher Snyder2dc86b92025-04-29 14:11:58 +0000363 // Determine the status icon based on the content of the result message
364 // This corresponds to the output format in claudetool/differential.go:Run
365 getStatusIcon(resultText: string): string {
366 if (!resultText) return "";
367 if (resultText === "OK") return "✔️";
368 if (resultText.includes("# Errors")) return "⛔";
369 if (resultText.includes("# Info")) return "ℹ️";
370 if (resultText.includes("uncommitted changes in repo")) return "🧹";
371 if (resultText.includes("no new commits have been added")) return "🐣";
372 if (resultText.includes("git repo is not clean")) return "🧼";
373 return "❓";
374 }
375
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700376 render() {
Josh Bleecher Snyder2dc86b92025-04-29 14:11:58 +0000377 const resultText = this.toolCall?.result_message?.tool_result || "";
378 const statusIcon = this.getStatusIcon(resultText);
379
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700380 return html` <sketch-tool-card
381 .open=${this.open}
382 .toolCall=${this.toolCall}
383 >
Josh Bleecher Snyder2dc86b92025-04-29 14:11:58 +0000384 <span slot="summary" class="summary-text"> ${statusIcon} </span>
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700385 <div slot="result">
Josh Bleecher Snyder2dc86b92025-04-29 14:11:58 +0000386 <pre>${resultText}</pre>
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700387 </div>
388 </sketch-tool-card>`;
389 }
390}
391
392@customElement("sketch-tool-card-done")
393export class SketchToolCardDone extends LitElement {
394 @property()
395 toolCall: ToolCall;
396
397 @property()
398 open: boolean;
399
400 static styles = css``;
401
402 constructor() {
403 super();
404 }
405
406 connectedCallback() {
407 super.connectedCallback();
408 }
409
410 disconnectedCallback() {
411 super.disconnectedCallback();
412 }
413
414 render() {
415 const doneInput = JSON.parse(this.toolCall.input);
416 return html` <sketch-tool-card
417 .open=${this.open}
418 .toolCall=${this.toolCall}
419 >
420 <span slot="summary" class="summary-text"> </span>
421 <div slot="result">
422 ${Object.keys(doneInput.checklist_items).map((key) => {
423 const item = doneInput.checklist_items[key];
424 let statusIcon = "⛔";
425 if (item.status == "yes") {
426 statusIcon = "👍";
427 } else if (item.status == "not applicable") {
428 statusIcon = "🤷‍♂️";
429 }
430 return html`<div>
431 <span>${statusIcon}</span> ${key}:${item.status}
432 </div>`;
433 })}
434 </div>
435 </sketch-tool-card>`;
436 }
437}
438
439@customElement("sketch-tool-card-patch")
440export class SketchToolCardPatch extends LitElement {
441 @property()
442 toolCall: ToolCall;
443
444 @property()
445 open: boolean;
446
447 static styles = css`
448 .summary-text {
449 color: #555;
450 font-family: monospace;
451 overflow: hidden;
452 text-overflow: ellipsis;
453 white-space: nowrap;
454 border-radius: 3px;
455 }
456 `;
457
458 constructor() {
459 super();
460 }
461
462 connectedCallback() {
463 super.connectedCallback();
464 }
465
466 disconnectedCallback() {
467 super.disconnectedCallback();
468 }
469
470 render() {
471 const patchInput = JSON.parse(this.toolCall?.input);
472 return html` <sketch-tool-card
473 .open=${this.open}
474 .toolCall=${this.toolCall}
475 >
476 <span slot="summary" class="summary-text">
477 ${patchInput?.path}: ${patchInput.patches.length}
478 edit${patchInput.patches.length > 1 ? "s" : ""}
479 </span>
480 <div slot="input">
481 ${patchInput.patches.map((patch) => {
482 return html` Patch operation: <b>${patch.operation}</b>
483 <pre>${patch.newText}</pre>`;
484 })}
485 </div>
486 <div slot="result">
487 <pre>${this.toolCall?.result_message?.tool_result}</pre>
488 </div>
489 </sketch-tool-card>`;
490 }
491}
492
493@customElement("sketch-tool-card-think")
494export class SketchToolCardThink extends LitElement {
495 @property()
496 toolCall: ToolCall;
497
498 @property()
499 open: boolean;
500
501 static styles = css`
502 .thought-bubble {
503 overflow-x: auto;
504 margin-bottom: 3px;
505 font-family: monospace;
506 padding: 3px 5px;
507 background: rgb(236, 236, 236);
508 border-radius: 6px;
509 user-select: text;
510 cursor: text;
511 -webkit-user-select: text;
512 -moz-user-select: text;
513 -ms-user-select: text;
514 font-size: 13px;
515 line-height: 1.3;
516 }
517 .summary-text {
518 overflow: hidden;
519 text-overflow: ellipsis;
520 font-family: monospace;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700521 }
522 `;
523
524 constructor() {
525 super();
526 }
527
528 connectedCallback() {
529 super.connectedCallback();
530 }
531
532 disconnectedCallback() {
533 super.disconnectedCallback();
534 }
535
536 render() {
537 return html`
538 <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
539 <span slot="summary" class="summary-text"
Sean McCulloughffb58a32025-04-28 13:50:56 -0700540 >${JSON.parse(this.toolCall?.input)?.thoughts?.split("\n")[0]}</span
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700541 >
542 <div slot="input" class="thought-bubble">
543 <div class="markdown-content">
544 ${unsafeHTML(
545 renderMarkdown(JSON.parse(this.toolCall?.input)?.thoughts),
546 )}
547 </div>
548 </div>
549 </sketch-tool-card>
550 `;
551 }
552}
553
554@customElement("sketch-tool-card-title")
555export class SketchToolCardTitle extends LitElement {
556 @property()
557 toolCall: ToolCall;
558
559 @property()
560 open: boolean;
561
562 static styles = css`
563 .summary-text {
564 font-style: italic;
565 }
566 `;
567 constructor() {
568 super();
569 }
570
571 connectedCallback() {
572 super.connectedCallback();
573 }
574
575 disconnectedCallback() {
576 super.disconnectedCallback();
577 }
578
579 render() {
580 return html`
581 <span class="summary-text"
582 >I've set the title of this sketch to
583 <b>"${JSON.parse(this.toolCall?.input)?.title}"</b></span
584 >
585 `;
586 }
587}
588
Sean McCulloughfa40c412025-04-28 20:10:04 +0000589@customElement("sketch-tool-card-multiple-choice")
590export class SketchToolCardMultipleChoice extends LitElement {
591 @property()
592 toolCall: ToolCall;
593
594 @property()
595 open: boolean;
596
597 @property()
598 selectedOption: string | number | null = null;
599
600 static styles = css`
601 .options-container {
602 display: flex;
603 flex-direction: row;
604 flex-wrap: wrap;
605 gap: 8px;
606 margin: 10px 0;
607 }
608
609 .option {
610 display: inline-flex;
611 align-items: center;
612 padding: 8px 12px;
613 border-radius: 4px;
614 background-color: #f5f5f5;
615 cursor: pointer;
616 transition: all 0.2s;
617 border: 1px solid transparent;
618 user-select: none;
619 }
620
621 .option:hover {
622 background-color: #e0e0e0;
623 border-color: #ccc;
624 transform: translateY(-1px);
625 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
626 }
627
628 .option:active {
629 transform: translateY(0);
630 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
631 background-color: #d5d5d5;
632 }
633
634 .option.selected {
635 background-color: #e3f2fd;
636 border-color: #2196f3;
637 border-width: 1px;
638 border-style: solid;
639 }
640
641 .option-index {
642 font-size: 0.8em;
643 opacity: 0.7;
644 margin-right: 6px;
645 }
646
647 .option-label {
648 font-family: sans-serif;
649 }
650
651 .option-checkmark {
652 margin-left: 6px;
653 color: #2196f3;
654 }
655
656 .summary-text {
657 font-style: italic;
658 }
659
660 .summary-text strong {
661 font-style: normal;
662 color: #2196f3;
663 font-weight: 600;
664 }
665
666 p {
667 display: flex;
668 align-items: center;
669 flex-wrap: wrap;
670 margin-bottom: 10px;
671 }
672 `;
673
674 constructor() {
675 super();
676 }
677
678 connectedCallback() {
679 super.connectedCallback();
680 this.updateSelectedOption();
681 }
682
683 disconnectedCallback() {
684 super.disconnectedCallback();
685 }
686
687 updated(changedProps) {
688 if (changedProps.has("toolCall")) {
689 this.updateSelectedOption();
690 }
691 }
692
693 updateSelectedOption() {
694 // Get selected option from result if available
695 if (this.toolCall?.result_message?.tool_result) {
696 try {
697 this.selectedOption = JSON.parse(
698 this.toolCall.result_message.tool_result,
699 ).selected;
700 } catch (e) {
701 console.error("Error parsing result:", e);
702 this.selectedOption = this.toolCall.result_message.tool_result;
703 }
704 } else {
705 this.selectedOption = null;
706 }
707 }
708
709 handleOptionClick(choice) {
710 // If this option is already selected, unselect it (toggle behavior)
711 if (this.selectedOption === choice) {
712 this.selectedOption = null;
713 } else {
714 // Otherwise, select the clicked option
715 this.selectedOption = choice;
716 }
717
718 // Dispatch a custom event that can be listened to by parent components
719 const event = new CustomEvent("option-selected", {
720 detail: { selected: this.selectedOption },
721 bubbles: true,
722 composed: true,
723 });
724 this.dispatchEvent(event);
725 }
726
727 render() {
728 // Parse the input to get choices if available
729 let choices = [];
730 let question = "";
731 try {
732 const inputData = JSON.parse(this.toolCall?.input || "{}");
733 choices = inputData.choices || [];
734 question = inputData.question || "Please select an option:";
735 } catch (e) {
736 console.error("Error parsing multiple-choice input:", e);
737 }
738
739 // Determine what to show in the summary slot
740 const summaryContent =
741 this.selectedOption !== null
742 ? html`<span class="summary-text"
743 >${question}: <strong>${this.selectedOption}</strong></span
744 >`
745 : html`<span class="summary-text">${question}</span>`;
746
747 return html` <sketch-tool-card
748 .open=${this.open}
749 .toolCall=${this.toolCall}
750 >
751 <span slot="summary">${summaryContent}</span>
752 <div slot="input">
753 <p>${question}</p>
754 <div class="options-container">
755 ${choices.map((choice, index) => {
756 const isSelected =
757 this.selectedOption !== null &&
758 (this.selectedOption === choice || this.selectedOption === index);
759 return html`
760 <div
761 class="option ${isSelected ? "selected" : ""}"
762 @click=${() => this.handleOptionClick(choice)}
763 >
764 <span class="option-index">${index + 1}</span>
765 <span class="option-label">${choice}</span>
766 ${isSelected
767 ? html`<span class="option-checkmark">✓</span>`
768 : ""}
769 </div>
770 `;
771 })}
772 </div>
773 </div>
774 <div slot="result">
775 ${this.toolCall?.result_message && this.selectedOption
776 ? html`<p>Selected: <strong>${this.selectedOption}</strong></p>`
777 : ""}
778 </div>
779 </sketch-tool-card>`;
780 }
781}
782
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700783@customElement("sketch-tool-card-generic")
784export class SketchToolCardGeneric extends LitElement {
785 @property()
786 toolCall: ToolCall;
787
788 @property()
789 open: boolean;
790
791 constructor() {
792 super();
793 }
794
795 connectedCallback() {
796 super.connectedCallback();
797 }
798
799 disconnectedCallback() {
800 super.disconnectedCallback();
801 }
802
803 render() {
804 return html` <sketch-tool-card
805 .open=${this.open}
806 .toolCall=${this.toolCall}
807 >
808 <span slot="summary" class="summary-text">${this.toolCall?.input}</span>
809 <div slot="input">
810 Input:
811 <pre>${this.toolCall?.input}</pre>
812 </div>
813 <div slot="result">
814 Result:
815 ${this.toolCall?.result_message
816 ? html` ${this.toolCall?.result_message.tool_result
817 ? html`<pre>${this.toolCall?.result_message.tool_result}</pre>`
818 : ""}`
819 : ""}
820 </div>
821 </sketch-tool-card>`;
822 }
823}
824
825declare global {
826 interface HTMLElementTagNameMap {
827 "sketch-tool-card": SketchToolCard;
828 "sketch-tool-card-generic": SketchToolCardGeneric;
829 "sketch-tool-card-bash": SketchToolCardBash;
830 "sketch-tool-card-codereview": SketchToolCardCodeReview;
831 "sketch-tool-card-done": SketchToolCardDone;
832 "sketch-tool-card-patch": SketchToolCardPatch;
833 "sketch-tool-card-think": SketchToolCardThink;
834 "sketch-tool-card-title": SketchToolCardTitle;
Sean McCulloughfa40c412025-04-28 20:10:04 +0000835 "sketch-tool-card-multiple-choice": SketchToolCardMultipleChoice;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700836 }
837}