| import { css, html, LitElement } from "lit"; |
| import { unsafeHTML } from "lit/directives/unsafe-html.js"; |
| import { customElement, property } from "lit/decorators.js"; |
| import { ToolCall } from "../types"; |
| import { marked, MarkedOptions } from "marked"; |
| |
| function renderMarkdown(markdownContent: string): string { |
| try { |
| // Set markdown options for proper code block highlighting and safety |
| const markedOptions: MarkedOptions = { |
| gfm: true, // GitHub Flavored Markdown |
| breaks: true, // Convert newlines to <br> |
| async: false, |
| // DOMPurify is recommended for production, but not included in this implementation |
| }; |
| return marked.parse(markdownContent, markedOptions) as string; |
| } catch (error) { |
| console.error("Error rendering markdown:", error); |
| // Fallback to plain text if markdown parsing fails |
| return markdownContent; |
| } |
| } |
| |
| @customElement("sketch-tool-card") |
| export class SketchToolCard extends LitElement { |
| @property() |
| toolCall: ToolCall; |
| |
| @property() |
| open: boolean; |
| |
| static styles = css` |
| .tool-call { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| white-space: nowrap; |
| } |
| |
| .tool-call-status { |
| margin-right: 4px; |
| text-align: center; |
| } |
| |
| .tool-call-status.spinner { |
| animation: spin 1s infinite linear; |
| display: inline-block; |
| width: 1em; |
| } |
| |
| @keyframes spin { |
| 0% { |
| transform: rotate(0deg); |
| } |
| 100% { |
| transform: rotate(360deg); |
| } |
| } |
| |
| .title { |
| font-style: italic; |
| } |
| |
| .cancel-button { |
| background: rgb(76, 175, 80); |
| color: white; |
| border: none; |
| padding: 4px 10px; |
| border-radius: 4px; |
| cursor: pointer; |
| font-size: 12px; |
| margin: 5px; |
| } |
| |
| .cancel-button:hover { |
| background: rgb(200, 35, 51) !important; |
| } |
| |
| .codereview-OK { |
| color: green; |
| } |
| |
| details { |
| border-radius: 4px; |
| padding: 0.25em; |
| margin: 0.25em; |
| display: flex; |
| flex-direction: column; |
| align-items: start; |
| } |
| |
| details summary { |
| list-style: none; |
| &::before { |
| cursor: hand; |
| font-family: monospace; |
| content: "+"; |
| color: white; |
| background-color: darkgray; |
| border-radius: 1em; |
| padding-left: 0.5em; |
| margin: 0.25em; |
| min-width: 1em; |
| } |
| [open] &::before { |
| content: "-"; |
| } |
| } |
| |
| details summary:hover { |
| list-style: none; |
| &::before { |
| background-color: gray; |
| } |
| } |
| summary { |
| display: flex; |
| flex-direction: row; |
| flex-wrap: nowrap; |
| justify-content: flex-start; |
| align-items: baseline; |
| } |
| |
| summary .tool-name { |
| font-family: monospace; |
| color: white; |
| background: rgb(124 145 160); |
| border-radius: 4px; |
| padding: 0.25em; |
| margin: 0.25em; |
| white-space: pre; |
| } |
| |
| .summary-text { |
| padding: 0.25em; |
| display: flex; |
| max-width: 50%; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| |
| details[open] .summary-text { |
| /*display: none;*/ |
| } |
| |
| .tool-error-message { |
| font-style: italic; |
| color: #aa0909; |
| } |
| |
| .elapsed { |
| font-size: 10px; |
| color: #888; |
| font-style: italic; |
| margin-left: 3px; |
| } |
| `; |
| |
| constructor() { |
| super(); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| } |
| |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| } |
| |
| _cancelToolCall = async (tool_call_id: string, button: HTMLButtonElement) => { |
| console.log("cancelToolCall", tool_call_id, button); |
| button.innerText = "Cancelling"; |
| button.disabled = true; |
| try { |
| const response = await fetch("cancel", { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify({ |
| tool_call_id: tool_call_id, |
| reason: "user requested cancellation", |
| }), |
| }); |
| if (response.ok) { |
| console.log("cancel", tool_call_id, response); |
| button.parentElement.removeChild(button); |
| } else { |
| button.innerText = "Cancel"; |
| console.log(`error trying to cancel ${tool_call_id}: `, response); |
| } |
| } catch (e) { |
| console.error("cancel", tool_call_id, e); |
| } |
| }; |
| |
| render() { |
| const toolCallStatus = this.toolCall?.result_message |
| ? this.toolCall?.result_message.tool_error |
| ? html`❌ |
| <span class="tool-error-message" |
| >${this.toolCall?.result_message.tool_result}</span |
| >` |
| : "" |
| : "⏳"; |
| |
| const cancelButton = this.toolCall?.result_message |
| ? "" |
| : html`<button |
| class="cancel-button" |
| title="Cancel this operation" |
| @click=${(e: Event) => { |
| e.stopPropagation(); |
| const button = e.target as HTMLButtonElement; |
| this._cancelToolCall(this.toolCall?.tool_call_id, button); |
| }} |
| > |
| Cancel |
| </button>`; |
| |
| const status = html`<span |
| class="tool-call-status ${this.toolCall?.result_message ? "" : "spinner"}" |
| >${toolCallStatus}</span |
| >`; |
| |
| const elapsed = html`${this.toolCall?.result_message?.elapsed |
| ? html`<span class="elapsed" |
| >${(this.toolCall?.result_message?.elapsed / 1e9).toFixed(2)}s |
| elapsed</span |
| >` |
| : ""}`; |
| |
| const ret = html`<div class="tool-call"> |
| <details ?open=${this.open}> |
| <summary> |
| <span class="tool-name">${this.toolCall?.name}</span> |
| <span class="summary-text"><slot name="summary"></slot></span> |
| ${status} ${cancelButton} ${elapsed} |
| </summary> |
| <slot name="input"></slot> |
| <slot name="result"></slot> |
| </details> |
| </div> `; |
| if (true) { |
| return ret; |
| } |
| } |
| } |
| |
| @customElement("sketch-tool-card-bash") |
| export class SketchToolCardBash extends LitElement { |
| @property() |
| toolCall: ToolCall; |
| |
| @property() |
| open: boolean; |
| |
| static styles = css` |
| pre { |
| background: rgb(236, 236, 236); |
| color: black; |
| padding: 0.5em; |
| border-radius: 4px; |
| } |
| .summary-text { |
| overflow: hidden; |
| text-overflow: ellipsis; |
| font-family: monospace; |
| } |
| .input { |
| display: flex; |
| } |
| .input pre { |
| width: 100%; |
| margin-bottom: 0; |
| border-radius: 4px 4px 0 0; |
| } |
| .result pre { |
| margin-top: 0; |
| color: #555; |
| border-radius: 0 0 4px 4px; |
| } |
| .background-badge { |
| display: inline-block; |
| background-color: #6200ea; |
| color: white; |
| font-size: 10px; |
| font-weight: bold; |
| padding: 2px 6px; |
| border-radius: 10px; |
| margin-left: 8px; |
| vertical-align: middle; |
| } |
| .command-wrapper { |
| display: flex; |
| align-items: center; |
| } |
| `; |
| |
| constructor() { |
| super(); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| } |
| |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| } |
| |
| render() { |
| const inputData = JSON.parse(this.toolCall?.input || "{}"); |
| const isBackground = inputData?.background === true; |
| const backgroundIcon = isBackground ? "🔄 " : ""; |
| |
| return html` |
| <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}> |
| <span slot="summary" class="summary-text"> |
| <div class="command-wrapper"> |
| 🖥️ ${backgroundIcon}${inputData?.command} |
| </div> |
| </span> |
| <div slot="input" class="input"> |
| <pre>🖥️ ${backgroundIcon}${inputData?.command}</pre> |
| </div> |
| ${ |
| this.toolCall?.result_message |
| ? html` ${this.toolCall?.result_message.tool_result |
| ? html`<div slot="result" class="result"> |
| <pre class="tool-call-result"> |
| ${this.toolCall?.result_message.tool_result}</pre |
| > |
| </div>` |
| : ""}` |
| : "" |
| }</div> |
| </sketch-tool-card>`; |
| } |
| } |
| |
| @customElement("sketch-tool-card-codereview") |
| export class SketchToolCardCodeReview extends LitElement { |
| @property() |
| toolCall: ToolCall; |
| |
| @property() |
| open: boolean; |
| |
| static styles = css``; |
| |
| constructor() { |
| super(); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| } |
| |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| } |
| render() { |
| return html` <sketch-tool-card |
| .open=${this.open} |
| .toolCall=${this.toolCall} |
| > |
| <span slot="summary" class="summary-text"> |
| ${this.toolCall?.result_message?.tool_result == "OK" ? "✔️" : "⛔"} |
| </span> |
| <div slot="result"> |
| <pre>${this.toolCall?.result_message?.tool_result}</pre> |
| </div> |
| </sketch-tool-card>`; |
| } |
| } |
| |
| @customElement("sketch-tool-card-done") |
| export class SketchToolCardDone extends LitElement { |
| @property() |
| toolCall: ToolCall; |
| |
| @property() |
| open: boolean; |
| |
| static styles = css``; |
| |
| constructor() { |
| super(); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| } |
| |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| } |
| |
| render() { |
| const doneInput = JSON.parse(this.toolCall.input); |
| return html` <sketch-tool-card |
| .open=${this.open} |
| .toolCall=${this.toolCall} |
| > |
| <span slot="summary" class="summary-text"> </span> |
| <div slot="result"> |
| ${Object.keys(doneInput.checklist_items).map((key) => { |
| const item = doneInput.checklist_items[key]; |
| let statusIcon = "⛔"; |
| if (item.status == "yes") { |
| statusIcon = "👍"; |
| } else if (item.status == "not applicable") { |
| statusIcon = "🤷♂️"; |
| } |
| return html`<div> |
| <span>${statusIcon}</span> ${key}:${item.status} |
| </div>`; |
| })} |
| </div> |
| </sketch-tool-card>`; |
| } |
| } |
| |
| @customElement("sketch-tool-card-patch") |
| export class SketchToolCardPatch extends LitElement { |
| @property() |
| toolCall: ToolCall; |
| |
| @property() |
| open: boolean; |
| |
| static styles = css` |
| .summary-text { |
| color: #555; |
| font-family: monospace; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| border-radius: 3px; |
| } |
| `; |
| |
| constructor() { |
| super(); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| } |
| |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| } |
| |
| render() { |
| const patchInput = JSON.parse(this.toolCall?.input); |
| return html` <sketch-tool-card |
| .open=${this.open} |
| .toolCall=${this.toolCall} |
| > |
| <span slot="summary" class="summary-text"> |
| ${patchInput?.path}: ${patchInput.patches.length} |
| edit${patchInput.patches.length > 1 ? "s" : ""} |
| </span> |
| <div slot="input"> |
| ${patchInput.patches.map((patch) => { |
| return html` Patch operation: <b>${patch.operation}</b> |
| <pre>${patch.newText}</pre>`; |
| })} |
| </div> |
| <div slot="result"> |
| <pre>${this.toolCall?.result_message?.tool_result}</pre> |
| </div> |
| </sketch-tool-card>`; |
| } |
| } |
| |
| @customElement("sketch-tool-card-think") |
| export class SketchToolCardThink extends LitElement { |
| @property() |
| toolCall: ToolCall; |
| |
| @property() |
| open: boolean; |
| |
| static styles = css` |
| .thought-bubble { |
| overflow-x: auto; |
| margin-bottom: 3px; |
| font-family: monospace; |
| padding: 3px 5px; |
| background: rgb(236, 236, 236); |
| border-radius: 6px; |
| user-select: text; |
| cursor: text; |
| -webkit-user-select: text; |
| -moz-user-select: text; |
| -ms-user-select: text; |
| font-size: 13px; |
| line-height: 1.3; |
| } |
| .summary-text { |
| overflow: hidden; |
| text-overflow: ellipsis; |
| font-family: monospace; |
| max-width: 50%; |
| } |
| `; |
| |
| constructor() { |
| super(); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| } |
| |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| } |
| |
| render() { |
| return html` |
| <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}> |
| <span slot="summary" class="summary-text" |
| >${JSON.parse(this.toolCall?.input)?.thoughts}</span |
| > |
| <div slot="input" class="thought-bubble"> |
| <div class="markdown-content"> |
| ${unsafeHTML( |
| renderMarkdown(JSON.parse(this.toolCall?.input)?.thoughts), |
| )} |
| </div> |
| </div> |
| </sketch-tool-card> |
| `; |
| } |
| } |
| |
| @customElement("sketch-tool-card-title") |
| export class SketchToolCardTitle extends LitElement { |
| @property() |
| toolCall: ToolCall; |
| |
| @property() |
| open: boolean; |
| |
| static styles = css` |
| .summary-text { |
| font-style: italic; |
| } |
| `; |
| constructor() { |
| super(); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| } |
| |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| } |
| |
| render() { |
| return html` |
| <span class="summary-text" |
| >I've set the title of this sketch to |
| <b>"${JSON.parse(this.toolCall?.input)?.title}"</b></span |
| > |
| `; |
| } |
| } |
| |
| @customElement("sketch-tool-card-generic") |
| export class SketchToolCardGeneric extends LitElement { |
| @property() |
| toolCall: ToolCall; |
| |
| @property() |
| open: boolean; |
| |
| constructor() { |
| super(); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| } |
| |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| } |
| |
| render() { |
| return html` <sketch-tool-card |
| .open=${this.open} |
| .toolCall=${this.toolCall} |
| > |
| <span slot="summary" class="summary-text">${this.toolCall?.input}</span> |
| <div slot="input"> |
| Input: |
| <pre>${this.toolCall?.input}</pre> |
| </div> |
| <div slot="result"> |
| Result: |
| ${this.toolCall?.result_message |
| ? html` ${this.toolCall?.result_message.tool_result |
| ? html`<pre>${this.toolCall?.result_message.tool_result}</pre>` |
| : ""}` |
| : ""} |
| </div> |
| </sketch-tool-card>`; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| "sketch-tool-card": SketchToolCard; |
| "sketch-tool-card-generic": SketchToolCardGeneric; |
| "sketch-tool-card-bash": SketchToolCardBash; |
| "sketch-tool-card-codereview": SketchToolCardCodeReview; |
| "sketch-tool-card-done": SketchToolCardDone; |
| "sketch-tool-card-patch": SketchToolCardPatch; |
| "sketch-tool-card-think": SketchToolCardThink; |
| "sketch-tool-card-title": SketchToolCardTitle; |
| } |
| } |