blob: 8e03add93765dde234c417da1872e2e3b398c888 [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 }
284 `;
285
286 constructor() {
287 super();
288 }
289
290 connectedCallback() {
291 super.connectedCallback();
292 }
293
294 disconnectedCallback() {
295 super.disconnectedCallback();
296 }
297
298 render() {
299 return html`
300 <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
301 <span slot="summary" class="summary-text">${JSON.parse(this.toolCall?.input)?.command}</span>
302 <div slot="input" class="input"><pre>${JSON.parse(this.toolCall?.input)?.command}</pre></div>
303 ${
304 this.toolCall?.result_message
305 ? html` ${this.toolCall?.result_message.tool_result
306 ? html`<div slot="result" class="result">
307 <pre class="tool-call-result">
308${this.toolCall?.result_message.tool_result}</pre
309 >
310 </div>`
311 : ""}`
312 : ""
313 }</div>
314 </sketch-tool-card>`;
315 }
316}
317
318@customElement("sketch-tool-card-codereview")
319export class SketchToolCardCodeReview extends LitElement {
320 @property()
321 toolCall: ToolCall;
322
323 @property()
324 open: boolean;
325
326 static styles = css``;
327
328 constructor() {
329 super();
330 }
331
332 connectedCallback() {
333 super.connectedCallback();
334 }
335
336 disconnectedCallback() {
337 super.disconnectedCallback();
338 }
339 render() {
340 return html` <sketch-tool-card
341 .open=${this.open}
342 .toolCall=${this.toolCall}
343 >
344 <span slot="summary" class="summary-text">
345 ${this.toolCall?.result_message?.tool_result == "OK" ? "✔️" : "⛔"}
346 </span>
347 <div slot="result">
348 <pre>${this.toolCall?.result_message?.tool_result}</pre>
349 </div>
350 </sketch-tool-card>`;
351 }
352}
353
354@customElement("sketch-tool-card-done")
355export class SketchToolCardDone extends LitElement {
356 @property()
357 toolCall: ToolCall;
358
359 @property()
360 open: boolean;
361
362 static styles = css``;
363
364 constructor() {
365 super();
366 }
367
368 connectedCallback() {
369 super.connectedCallback();
370 }
371
372 disconnectedCallback() {
373 super.disconnectedCallback();
374 }
375
376 render() {
377 const doneInput = JSON.parse(this.toolCall.input);
378 return html` <sketch-tool-card
379 .open=${this.open}
380 .toolCall=${this.toolCall}
381 >
382 <span slot="summary" class="summary-text"> </span>
383 <div slot="result">
384 ${Object.keys(doneInput.checklist_items).map((key) => {
385 const item = doneInput.checklist_items[key];
386 let statusIcon = "⛔";
387 if (item.status == "yes") {
388 statusIcon = "👍";
389 } else if (item.status == "not applicable") {
390 statusIcon = "🤷‍♂️";
391 }
392 return html`<div>
393 <span>${statusIcon}</span> ${key}:${item.status}
394 </div>`;
395 })}
396 </div>
397 </sketch-tool-card>`;
398 }
399}
400
401@customElement("sketch-tool-card-patch")
402export class SketchToolCardPatch extends LitElement {
403 @property()
404 toolCall: ToolCall;
405
406 @property()
407 open: boolean;
408
409 static styles = css`
410 .summary-text {
411 color: #555;
412 font-family: monospace;
413 overflow: hidden;
414 text-overflow: ellipsis;
415 white-space: nowrap;
416 border-radius: 3px;
417 }
418 `;
419
420 constructor() {
421 super();
422 }
423
424 connectedCallback() {
425 super.connectedCallback();
426 }
427
428 disconnectedCallback() {
429 super.disconnectedCallback();
430 }
431
432 render() {
433 const patchInput = JSON.parse(this.toolCall?.input);
434 return html` <sketch-tool-card
435 .open=${this.open}
436 .toolCall=${this.toolCall}
437 >
438 <span slot="summary" class="summary-text">
439 ${patchInput?.path}: ${patchInput.patches.length}
440 edit${patchInput.patches.length > 1 ? "s" : ""}
441 </span>
442 <div slot="input">
443 ${patchInput.patches.map((patch) => {
444 return html` Patch operation: <b>${patch.operation}</b>
445 <pre>${patch.newText}</pre>`;
446 })}
447 </div>
448 <div slot="result">
449 <pre>${this.toolCall?.result_message?.tool_result}</pre>
450 </div>
451 </sketch-tool-card>`;
452 }
453}
454
455@customElement("sketch-tool-card-think")
456export class SketchToolCardThink extends LitElement {
457 @property()
458 toolCall: ToolCall;
459
460 @property()
461 open: boolean;
462
463 static styles = css`
464 .thought-bubble {
465 overflow-x: auto;
466 margin-bottom: 3px;
467 font-family: monospace;
468 padding: 3px 5px;
469 background: rgb(236, 236, 236);
470 border-radius: 6px;
471 user-select: text;
472 cursor: text;
473 -webkit-user-select: text;
474 -moz-user-select: text;
475 -ms-user-select: text;
476 font-size: 13px;
477 line-height: 1.3;
478 }
479 .summary-text {
480 overflow: hidden;
481 text-overflow: ellipsis;
482 font-family: monospace;
483 max-width: 50%;
484 }
485 `;
486
487 constructor() {
488 super();
489 }
490
491 connectedCallback() {
492 super.connectedCallback();
493 }
494
495 disconnectedCallback() {
496 super.disconnectedCallback();
497 }
498
499 render() {
500 return html`
501 <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
502 <span slot="summary" class="summary-text"
503 >${JSON.parse(this.toolCall?.input)?.thoughts}</span
504 >
505 <div slot="input" class="thought-bubble">
506 <div class="markdown-content">
507 ${unsafeHTML(
508 renderMarkdown(JSON.parse(this.toolCall?.input)?.thoughts),
509 )}
510 </div>
511 </div>
512 </sketch-tool-card>
513 `;
514 }
515}
516
517@customElement("sketch-tool-card-title")
518export class SketchToolCardTitle extends LitElement {
519 @property()
520 toolCall: ToolCall;
521
522 @property()
523 open: boolean;
524
525 static styles = css`
526 .summary-text {
527 font-style: italic;
528 }
529 `;
530 constructor() {
531 super();
532 }
533
534 connectedCallback() {
535 super.connectedCallback();
536 }
537
538 disconnectedCallback() {
539 super.disconnectedCallback();
540 }
541
542 render() {
543 return html`
544 <span class="summary-text"
545 >I've set the title of this sketch to
546 <b>"${JSON.parse(this.toolCall?.input)?.title}"</b></span
547 >
548 `;
549 }
550}
551
552@customElement("sketch-tool-card-generic")
553export class SketchToolCardGeneric extends LitElement {
554 @property()
555 toolCall: ToolCall;
556
557 @property()
558 open: boolean;
559
560 constructor() {
561 super();
562 }
563
564 connectedCallback() {
565 super.connectedCallback();
566 }
567
568 disconnectedCallback() {
569 super.disconnectedCallback();
570 }
571
572 render() {
573 return html` <sketch-tool-card
574 .open=${this.open}
575 .toolCall=${this.toolCall}
576 >
577 <span slot="summary" class="summary-text">${this.toolCall?.input}</span>
578 <div slot="input">
579 Input:
580 <pre>${this.toolCall?.input}</pre>
581 </div>
582 <div slot="result">
583 Result:
584 ${this.toolCall?.result_message
585 ? html` ${this.toolCall?.result_message.tool_result
586 ? html`<pre>${this.toolCall?.result_message.tool_result}</pre>`
587 : ""}`
588 : ""}
589 </div>
590 </sketch-tool-card>`;
591 }
592}
593
594declare global {
595 interface HTMLElementTagNameMap {
596 "sketch-tool-card": SketchToolCard;
597 "sketch-tool-card-generic": SketchToolCardGeneric;
598 "sketch-tool-card-bash": SketchToolCardBash;
599 "sketch-tool-card-codereview": SketchToolCardCodeReview;
600 "sketch-tool-card-done": SketchToolCardDone;
601 "sketch-tool-card-patch": SketchToolCardPatch;
602 "sketch-tool-card-think": SketchToolCardThink;
603 "sketch-tool-card-title": SketchToolCardTitle;
604 }
605}