blob: 800c6654e206c2f57c11d8dbf7f23b1bc0cf0221 [file] [log] [blame]
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001import { css, html, LitElement } from "lit";
2import { unsafeHTML } from "lit/directives/unsafe-html.js";
3import { repeat } from "lit/directives/repeat.js";
4import { customElement, property } from "lit/decorators.js";
5import { State, ToolCall } from "../types";
6import { marked, MarkedOptions } from "marked";
7
8function renderMarkdown(markdownContent: string): string {
9 try {
10 // Set markdown options for proper code block highlighting and safety
11 const markedOptions: MarkedOptions = {
12 gfm: true, // GitHub Flavored Markdown
13 breaks: true, // Convert newlines to <br>
14 async: false,
15 // DOMPurify is recommended for production, but not included in this implementation
16 };
17 return marked.parse(markdownContent, markedOptions) as string;
18 } catch (error) {
19 console.error("Error rendering markdown:", error);
20 // Fallback to plain text if markdown parsing fails
21 return markdownContent;
22 }
23}
24
25@customElement("sketch-tool-card")
26export class SketchToolCard extends LitElement {
27 @property()
28 toolCall: ToolCall;
29
30 @property()
31 open: boolean;
32
33 static styles = css`
34 .tool-call {
35 display: flex;
36 align-items: center;
37 gap: 8px;
38 white-space: nowrap;
39 }
40
41 .tool-call-status {
42 margin-right: 4px;
43 text-align: center;
44 }
45
46 .tool-call-status.spinner {
47 animation: spin 1s infinite linear;
48 display: inline-block;
49 width: 1em;
50 }
51
52 @keyframes spin {
53 0% {
54 transform: rotate(0deg);
55 }
56 100% {
57 transform: rotate(360deg);
58 }
59 }
60
61 .title {
62 font-style: italic;
63 }
64
65 .cancel-button {
66 background: rgb(76, 175, 80);
67 color: white;
68 border: none;
69 padding: 4px 10px;
70 border-radius: 4px;
71 cursor: pointer;
72 font-size: 12px;
73 margin: 5px;
74 }
75
76 .cancel-button:hover {
77 background: rgb(200, 35, 51) !important;
78 }
79
80 .codereview-OK {
81 color: green;
82 }
83
84 details {
85 border-radius: 4px;
86 padding: 0.25em;
87 margin: 0.25em;
88 display: flex;
89 flex-direction: column;
90 align-items: start;
91 }
92
93 details summary {
94 list-style: none;
95 &::before {
96 cursor: hand;
97 font-family: monospace;
98 content: "+";
99 color: white;
100 background-color: darkgray;
101 border-radius: 1em;
102 padding-left: 0.5em;
103 margin: 0.25em;
104 min-width: 1em;
105 }
106 [open] &::before {
107 content: "-";
108 }
109 }
110
111 details summary:hover {
112 list-style: none;
113 &::before {
114 background-color: gray;
115 }
116 }
117 summary {
118 display: flex;
119 flex-direction: row;
120 flex-wrap: nowrap;
121 justify-content: flex-start;
122 align-items: baseline;
123 }
124
125 summary .tool-name {
126 font-family: monospace;
127 color: white;
128 background: rgb(124 145 160);
129 border-radius: 4px;
130 padding: 0.25em;
131 margin: 0.25em;
132 white-space: pre;
133 }
134
135 .summary-text {
136 padding: 0.25em;
137 display: flex;
138 max-width: 50%;
139 overflow: hidden;
140 text-overflow: ellipsis;
141 }
142
143 details[open] .summary-text {
144 /*display: none;*/
145 }
146
147 .tool-error-message {
148 font-style: italic;
149 color: #aa0909;
150 }
151 `;
152
153 constructor() {
154 super();
155 }
156
157 connectedCallback() {
158 super.connectedCallback();
159 }
160
161 disconnectedCallback() {
162 super.disconnectedCallback();
163 }
164
165 _cancelToolCall = async (tool_call_id: string, button: HTMLButtonElement) => {
166 console.log("cancelToolCall", tool_call_id, button);
167 button.innerText = "Cancelling";
168 button.disabled = true;
169 try {
170 const response = await fetch("cancel", {
171 method: "POST",
172 headers: {
173 "Content-Type": "application/json",
174 },
175 body: JSON.stringify({
176 tool_call_id: tool_call_id,
177 reason: "user requested cancellation",
178 }),
179 });
180 if (response.ok) {
181 console.log("cancel", tool_call_id, response);
182 button.parentElement.removeChild(button);
183 } else {
184 button.innerText = "Cancel";
185 console.log(`error trying to cancel ${tool_call_id}: `, response);
186 }
187 } catch (e) {
188 console.error("cancel", tool_call_id, e);
189 }
190 };
191
192 render() {
193 const toolCallStatus = this.toolCall?.result_message
194 ? this.toolCall?.result_message.tool_error
195 ? html`❌
196 <span class="tool-error-message"
197 >${this.toolCall?.result_message.tool_error}</span
198 >`
199 : ""
200 : "⏳";
201
202 const cancelButton = this.toolCall?.result_message
203 ? ""
204 : html`<button
205 class="cancel-button"
206 title="Cancel this operation"
207 @click=${(e: Event) => {
208 e.stopPropagation();
209 const button = e.target as HTMLButtonElement;
210 this._cancelToolCall(this.toolCall?.tool_call_id, button);
211 }}
212 >
213 Cancel
214 </button>`;
215
216 const status = html`<span
217 class="tool-call-status ${this.toolCall?.result_message ? "" : "spinner"}"
218 >${toolCallStatus}</span
219 >`;
220
221 const ret = html`<div class="tool-call">
222 <details ?open=${this.open}>
223 <summary>
224 <span class="tool-name">${this.toolCall?.name}</span>
225 <span class="summary-text"><slot name="summary"></slot></span>
226 ${status} ${cancelButton}
227 </summary>
228 <slot name="input"></slot>
229 <slot name="result"></slot>
230 </details>
231 </div> `;
232 if (true) {
233 return ret;
234 }
235 }
236}
237
238@customElement("sketch-tool-card-bash")
239export class SketchToolCardBash extends LitElement {
240 @property()
241 toolCall: ToolCall;
242
243 @property()
244 open: boolean;
245
246 static styles = css`
247 pre {
248 background: black;
249 color: white;
250 padding: 0.5em;
251 border-radius: 4px;
252 }
253 .summary-text {
254 overflow: hidden;
255 text-overflow: ellipsis;
256 font-family: monospace;
257 }
258 .input {
259 display: flex;
260 }
261 .input pre {
262 width: 100%;
263 margin-bottom: 0;
264 border-radius: 4px 4px 0 0;
265 }
266 .result pre {
267 margin-top: 0;
268 color: gray;
269 border-radius: 0 0 4px 4px;
270 }
271 `;
272
273 constructor() {
274 super();
275 }
276
277 connectedCallback() {
278 super.connectedCallback();
279 }
280
281 disconnectedCallback() {
282 super.disconnectedCallback();
283 }
284
285 render() {
286 return html`
287 <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
288 <span slot="summary" class="summary-text">${JSON.parse(this.toolCall?.input)?.command}</span>
289 <div slot="input" class="input"><pre>${JSON.parse(this.toolCall?.input)?.command}</pre></div>
290 ${
291 this.toolCall?.result_message
292 ? html` ${this.toolCall?.result_message.tool_result
293 ? html`<div slot="result" class="result">
294 <pre class="tool-call-result">
295${this.toolCall?.result_message.tool_result}</pre
296 >
297 </div>`
298 : ""}`
299 : ""
300 }</div>
301 </sketch-tool-card>`;
302 }
303}
304
305@customElement("sketch-tool-card-codereview")
306export class SketchToolCardCodeReview extends LitElement {
307 @property()
308 toolCall: ToolCall;
309
310 @property()
311 open: boolean;
312
313 static styles = css``;
314
315 constructor() {
316 super();
317 }
318
319 connectedCallback() {
320 super.connectedCallback();
321 }
322
323 disconnectedCallback() {
324 super.disconnectedCallback();
325 }
326 render() {
327 return html` <sketch-tool-card
328 .open=${this.open}
329 .toolCall=${this.toolCall}
330 >
331 <span slot="summary" class="summary-text">
332 ${this.toolCall?.result_message?.tool_result == "OK" ? "✔️" : "⛔"}
333 </span>
334 <div slot="result">
335 <pre>${this.toolCall?.result_message?.tool_result}</pre>
336 </div>
337 </sketch-tool-card>`;
338 }
339}
340
341@customElement("sketch-tool-card-done")
342export class SketchToolCardDone extends LitElement {
343 @property()
344 toolCall: ToolCall;
345
346 @property()
347 open: boolean;
348
349 static styles = css``;
350
351 constructor() {
352 super();
353 }
354
355 connectedCallback() {
356 super.connectedCallback();
357 }
358
359 disconnectedCallback() {
360 super.disconnectedCallback();
361 }
362
363 render() {
364 const doneInput = JSON.parse(this.toolCall.input);
365 return html` <sketch-tool-card
366 .open=${this.open}
367 .toolCall=${this.toolCall}
368 >
369 <span slot="summary" class="summary-text"> </span>
370 <div slot="result">
371 ${Object.keys(doneInput.checklist_items).map((key) => {
372 const item = doneInput.checklist_items[key];
373 let statusIcon = "⛔";
374 if (item.status == "yes") {
375 statusIcon = "👍";
376 } else if (item.status == "not applicable") {
377 statusIcon = "🤷‍♂️";
378 }
379 return html`<div>
380 <span>${statusIcon}</span> ${key}:${item.status}
381 </div>`;
382 })}
383 </div>
384 </sketch-tool-card>`;
385 }
386}
387
388@customElement("sketch-tool-card-patch")
389export class SketchToolCardPatch extends LitElement {
390 @property()
391 toolCall: ToolCall;
392
393 @property()
394 open: boolean;
395
396 static styles = css`
397 .summary-text {
398 color: #555;
399 font-family: monospace;
400 overflow: hidden;
401 text-overflow: ellipsis;
402 white-space: nowrap;
403 border-radius: 3px;
404 }
405 `;
406
407 constructor() {
408 super();
409 }
410
411 connectedCallback() {
412 super.connectedCallback();
413 }
414
415 disconnectedCallback() {
416 super.disconnectedCallback();
417 }
418
419 render() {
420 const patchInput = JSON.parse(this.toolCall?.input);
421 return html` <sketch-tool-card
422 .open=${this.open}
423 .toolCall=${this.toolCall}
424 >
425 <span slot="summary" class="summary-text">
426 ${patchInput?.path}: ${patchInput.patches.length}
427 edit${patchInput.patches.length > 1 ? "s" : ""}
428 </span>
429 <div slot="input">
430 ${patchInput.patches.map((patch) => {
431 return html` Patch operation: <b>${patch.operation}</b>
432 <pre>${patch.newText}</pre>`;
433 })}
434 </div>
435 <div slot="result">
436 <pre>${this.toolCall?.result_message?.tool_result}</pre>
437 </div>
438 </sketch-tool-card>`;
439 }
440}
441
442@customElement("sketch-tool-card-think")
443export class SketchToolCardThink extends LitElement {
444 @property()
445 toolCall: ToolCall;
446
447 @property()
448 open: boolean;
449
450 static styles = css`
451 .thought-bubble {
452 overflow-x: auto;
453 margin-bottom: 3px;
454 font-family: monospace;
455 padding: 3px 5px;
456 background: rgb(236, 236, 236);
457 border-radius: 6px;
458 user-select: text;
459 cursor: text;
460 -webkit-user-select: text;
461 -moz-user-select: text;
462 -ms-user-select: text;
463 font-size: 13px;
464 line-height: 1.3;
465 }
466 .summary-text {
467 overflow: hidden;
468 text-overflow: ellipsis;
469 font-family: monospace;
470 max-width: 50%;
471 }
472 `;
473
474 constructor() {
475 super();
476 }
477
478 connectedCallback() {
479 super.connectedCallback();
480 }
481
482 disconnectedCallback() {
483 super.disconnectedCallback();
484 }
485
486 render() {
487 return html`
488 <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
489 <span slot="summary" class="summary-text"
490 >${JSON.parse(this.toolCall?.input)?.thoughts}</span
491 >
492 <div slot="input" class="thought-bubble">
493 <div class="markdown-content">
494 ${unsafeHTML(
495 renderMarkdown(JSON.parse(this.toolCall?.input)?.thoughts),
496 )}
497 </div>
498 </div>
499 </sketch-tool-card>
500 `;
501 }
502}
503
504@customElement("sketch-tool-card-title")
505export class SketchToolCardTitle extends LitElement {
506 @property()
507 toolCall: ToolCall;
508
509 @property()
510 open: boolean;
511
512 static styles = css`
513 .summary-text {
514 font-style: italic;
515 }
516 `;
517 constructor() {
518 super();
519 }
520
521 connectedCallback() {
522 super.connectedCallback();
523 }
524
525 disconnectedCallback() {
526 super.disconnectedCallback();
527 }
528
529 render() {
530 return html`
531 <span class="summary-text"
532 >I've set the title of this sketch to
533 <b>"${JSON.parse(this.toolCall?.input)?.title}"</b></span
534 >
535 `;
536 }
537}
538
539@customElement("sketch-tool-card-generic")
540export class SketchToolCardGeneric extends LitElement {
541 @property()
542 toolCall: ToolCall;
543
544 @property()
545 open: boolean;
546
547 constructor() {
548 super();
549 }
550
551 connectedCallback() {
552 super.connectedCallback();
553 }
554
555 disconnectedCallback() {
556 super.disconnectedCallback();
557 }
558
559 render() {
560 return html` <sketch-tool-card
561 .open=${this.open}
562 .toolCall=${this.toolCall}
563 >
564 <span slot="summary" class="summary-text">${this.toolCall?.input}</span>
565 <div slot="input">
566 Input:
567 <pre>${this.toolCall?.input}</pre>
568 </div>
569 <div slot="result">
570 Result:
571 ${this.toolCall?.result_message
572 ? html` ${this.toolCall?.result_message.tool_result
573 ? html`<pre>${this.toolCall?.result_message.tool_result}</pre>`
574 : ""}`
575 : ""}
576 </div>
577 </sketch-tool-card>`;
578 }
579}
580
581declare global {
582 interface HTMLElementTagNameMap {
583 "sketch-tool-card": SketchToolCard;
584 "sketch-tool-card-generic": SketchToolCardGeneric;
585 "sketch-tool-card-bash": SketchToolCardBash;
586 "sketch-tool-card-codereview": SketchToolCardCodeReview;
587 "sketch-tool-card-done": SketchToolCardDone;
588 "sketch-tool-card-patch": SketchToolCardPatch;
589 "sketch-tool-card-think": SketchToolCardThink;
590 "sketch-tool-card-title": SketchToolCardTitle;
591 }
592}