blob: 0144ba0a86889233cf7841344606949950866f21 [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 }
150 `;
151
152 constructor() {
153 super();
154 }
155
156 connectedCallback() {
157 super.connectedCallback();
158 }
159
160 disconnectedCallback() {
161 super.disconnectedCallback();
162 }
163
164 _cancelToolCall = async (tool_call_id: string, button: HTMLButtonElement) => {
165 console.log("cancelToolCall", tool_call_id, button);
166 button.innerText = "Cancelling";
167 button.disabled = true;
168 try {
169 const response = await fetch("cancel", {
170 method: "POST",
171 headers: {
172 "Content-Type": "application/json",
173 },
174 body: JSON.stringify({
175 tool_call_id: tool_call_id,
176 reason: "user requested cancellation",
177 }),
178 });
179 if (response.ok) {
180 console.log("cancel", tool_call_id, response);
181 button.parentElement.removeChild(button);
182 } else {
183 button.innerText = "Cancel";
184 console.log(`error trying to cancel ${tool_call_id}: `, response);
185 }
186 } catch (e) {
187 console.error("cancel", tool_call_id, e);
188 }
189 };
190
191 render() {
192 const toolCallStatus = this.toolCall?.result_message
193 ? this.toolCall?.result_message.tool_error
194 ? html`❌
195 <span class="tool-error-message"
196 >${this.toolCall?.result_message.tool_error}</span
197 >`
198 : ""
199 : "⏳";
200
201 const cancelButton = this.toolCall?.result_message
202 ? ""
203 : html`<button
204 class="cancel-button"
205 title="Cancel this operation"
206 @click=${(e: Event) => {
207 e.stopPropagation();
208 const button = e.target as HTMLButtonElement;
209 this._cancelToolCall(this.toolCall?.tool_call_id, button);
210 }}
211 >
212 Cancel
213 </button>`;
214
215 const status = html`<span
216 class="tool-call-status ${this.toolCall?.result_message ? "" : "spinner"}"
217 >${toolCallStatus}</span
218 >`;
219
220 const ret = html`<div class="tool-call">
221 <details ?open=${this.open}>
222 <summary>
223 <span class="tool-name">${this.toolCall?.name}</span>
224 <span class="summary-text"><slot name="summary"></slot></span>
225 ${status} ${cancelButton}
226 </summary>
227 <slot name="input"></slot>
228 <slot name="result"></slot>
229 </details>
230 </div> `;
231 if (true) {
232 return ret;
233 }
234 }
235}
236
237@customElement("sketch-tool-card-bash")
238export class SketchToolCardBash extends LitElement {
239 @property()
240 toolCall: ToolCall;
241
242 @property()
243 open: boolean;
244
245 static styles = css`
246 pre {
247 background: black;
248 color: white;
249 padding: 0.5em;
250 border-radius: 4px;
251 }
252 .summary-text {
253 overflow: hidden;
254 text-overflow: ellipsis;
255 font-family: monospace;
256 }
257 .input {
258 display: flex;
259 }
260 .input pre {
261 width: 100%;
262 margin-bottom: 0;
263 border-radius: 4px 4px 0 0;
264 }
265 .result pre {
266 margin-top: 0;
267 color: gray;
268 border-radius: 0 0 4px 4px;
269 }
270 `;
271
272 constructor() {
273 super();
274 }
275
276 connectedCallback() {
277 super.connectedCallback();
278 }
279
280 disconnectedCallback() {
281 super.disconnectedCallback();
282 }
283
284 render() {
285 return html`
286 <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
287 <span slot="summary" class="summary-text">${JSON.parse(this.toolCall?.input)?.command}</span>
288 <div slot="input" class="input"><pre>${JSON.parse(this.toolCall?.input)?.command}</pre></div>
289 ${
290 this.toolCall?.result_message
291 ? html` ${this.toolCall?.result_message.tool_result
292 ? html`<div slot="result" class="result">
293 <pre class="tool-call-result">
294${this.toolCall?.result_message.tool_result}</pre
295 >
296 </div>`
297 : ""}`
298 : ""
299 }</div>
300 </sketch-tool-card>`;
301 }
302}
303
304@customElement("sketch-tool-card-codereview")
305export class SketchToolCardCodeReview extends LitElement {
306 @property()
307 toolCall: ToolCall;
308
309 @property()
310 open: boolean;
311
312 static styles = css``;
313
314 constructor() {
315 super();
316 }
317
318 connectedCallback() {
319 super.connectedCallback();
320 }
321
322 disconnectedCallback() {
323 super.disconnectedCallback();
324 }
325 render() {
326 return html` <sketch-tool-card
327 .open=${this.open}
328 .toolCall=${this.toolCall}
329 >
330 <span slot="summary" class="summary-text">
331 ${this.toolCall?.result_message?.tool_result == "OK" ? "✔️" : "⛔"}
332 </span>
333 <div slot="result">
334 <pre>${this.toolCall?.result_message?.tool_result}</pre>
335 </div>
336 </sketch-tool-card>`;
337 }
338}
339
340@customElement("sketch-tool-card-done")
341export class SketchToolCardDone extends LitElement {
342 @property()
343 toolCall: ToolCall;
344
345 @property()
346 open: boolean;
347
348 static styles = css``;
349
350 constructor() {
351 super();
352 }
353
354 connectedCallback() {
355 super.connectedCallback();
356 }
357
358 disconnectedCallback() {
359 super.disconnectedCallback();
360 }
361
362 render() {
363 const doneInput = JSON.parse(this.toolCall.input);
364 return html` <sketch-tool-card
365 .open=${this.open}
366 .toolCall=${this.toolCall}
367 >
368 <span slot="summary" class="summary-text"> </span>
369 <div slot="result">
370 ${Object.keys(doneInput.checklist_items).map((key) => {
371 const item = doneInput.checklist_items[key];
372 let statusIcon = "⛔";
373 if (item.status == "yes") {
374 statusIcon = "👍";
375 } else if (item.status == "not applicable") {
376 statusIcon = "🤷‍♂️";
377 }
378 return html`<div>
379 <span>${statusIcon}</span> ${key}:${item.status}
380 </div>`;
381 })}
382 </div>
383 </sketch-tool-card>`;
384 }
385}
386
387@customElement("sketch-tool-card-patch")
388export class SketchToolCardPatch extends LitElement {
389 @property()
390 toolCall: ToolCall;
391
392 @property()
393 open: boolean;
394
395 static styles = css`
396 .summary-text {
397 color: #555;
398 font-family: monospace;
399 overflow: hidden;
400 text-overflow: ellipsis;
401 white-space: nowrap;
402 border-radius: 3px;
403 }
404 `;
405
406 constructor() {
407 super();
408 }
409
410 connectedCallback() {
411 super.connectedCallback();
412 }
413
414 disconnectedCallback() {
415 super.disconnectedCallback();
416 }
417
418 render() {
419 const patchInput = JSON.parse(this.toolCall?.input);
420 return html` <sketch-tool-card
421 .open=${this.open}
422 .toolCall=${this.toolCall}
423 >
424 <span slot="summary" class="summary-text">
425 ${patchInput?.path}: ${patchInput.patches.length}
426 edit${patchInput.patches.length > 1 ? "s" : ""}
427 </span>
428 <div slot="input">
429 ${patchInput.patches.map((patch) => {
430 return html` Patch operation: <b>${patch.operation}</b>
431 <pre>${patch.newText}</pre>`;
432 })}
433 </div>
434 <div slot="result">
435 <pre>${this.toolCall?.result_message?.tool_result}</pre>
436 </div>
437 </sketch-tool-card>`;
438 }
439}
440
441@customElement("sketch-tool-card-think")
442export class SketchToolCardThink extends LitElement {
443 @property()
444 toolCall: ToolCall;
445
446 @property()
447 open: boolean;
448
449 static styles = css`
450 .thought-bubble {
451 overflow-x: auto;
452 margin-bottom: 3px;
453 font-family: monospace;
454 padding: 3px 5px;
455 background: rgb(236, 236, 236);
456 border-radius: 6px;
457 user-select: text;
458 cursor: text;
459 -webkit-user-select: text;
460 -moz-user-select: text;
461 -ms-user-select: text;
462 font-size: 13px;
463 line-height: 1.3;
464 }
465 .summary-text {
466 overflow: hidden;
467 text-overflow: ellipsis;
468 font-family: monospace;
469 max-width: 50%;
470 }
471 `;
472
473 constructor() {
474 super();
475 }
476
477 connectedCallback() {
478 super.connectedCallback();
479 }
480
481 disconnectedCallback() {
482 super.disconnectedCallback();
483 }
484
485 render() {
486 return html`
487 <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
488 <span slot="summary" class="summary-text"
489 >${JSON.parse(this.toolCall?.input)?.thoughts}</span
490 >
491 <div slot="input" class="thought-bubble">
492 <div class="markdown-content">
493 ${unsafeHTML(
494 renderMarkdown(JSON.parse(this.toolCall?.input)?.thoughts),
495 )}
496 </div>
497 </div>
498 </sketch-tool-card>
499 `;
500 }
501}
502
503@customElement("sketch-tool-card-title")
504export class SketchToolCardTitle extends LitElement {
505 @property()
506 toolCall: ToolCall;
507
508 @property()
509 open: boolean;
510
511 static styles = css`
512 .summary-text {
513 font-style: italic;
514 }
515 `;
516 constructor() {
517 super();
518 }
519
520 connectedCallback() {
521 super.connectedCallback();
522 }
523
524 disconnectedCallback() {
525 super.disconnectedCallback();
526 }
527
528 render() {
529 return html`
530 <span class="summary-text"
531 >I've set the title of this sketch to
532 <b>"${JSON.parse(this.toolCall?.input)?.title}"</b></span
533 >
534 `;
535 }
536}
537
538@customElement("sketch-tool-card-generic")
539export class SketchToolCardGeneric extends LitElement {
540 @property()
541 toolCall: ToolCall;
542
543 @property()
544 open: boolean;
545
546 constructor() {
547 super();
548 }
549
550 connectedCallback() {
551 super.connectedCallback();
552 }
553
554 disconnectedCallback() {
555 super.disconnectedCallback();
556 }
557
558 render() {
559 return html` <sketch-tool-card
560 .open=${this.open}
561 .toolCall=${this.toolCall}
562 >
563 <span slot="summary" class="summary-text">${this.toolCall?.input}</span>
564 <div slot="input">
565 Input:
566 <pre>${this.toolCall?.input}</pre>
567 </div>
568 <div slot="result">
569 Result:
570 ${this.toolCall?.result_message
571 ? html` ${this.toolCall?.result_message.tool_result
572 ? html`<pre>${this.toolCall?.result_message.tool_result}</pre>`
573 : ""}`
574 : ""}
575 </div>
576 </sketch-tool-card>`;
577 }
578}
579
580declare global {
581 interface HTMLElementTagNameMap {
582 "sketch-tool-card": SketchToolCard;
583 "sketch-tool-card-generic": SketchToolCardGeneric;
584 "sketch-tool-card-bash": SketchToolCardBash;
585 "sketch-tool-card-codereview": SketchToolCardCodeReview;
586 "sketch-tool-card-done": SketchToolCardDone;
587 "sketch-tool-card-patch": SketchToolCardPatch;
588 "sketch-tool-card-think": SketchToolCardThink;
589 "sketch-tool-card-title": SketchToolCardTitle;
590 }
591}