blob: f61cdb88ac73410938853bd505cf836fc60629e6 [file] [log] [blame]
import { html } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { customElement, property } from "lit/decorators.js";
import { ToolCall } from "../types";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { SketchTailwindElement } from "./sketch-tailwind-element";
import "./sketch-tool-card-base";
// Shared utility function for markdown rendering with DOMPurify sanitization
function renderMarkdown(markdownContent: string): string {
try {
// Parse markdown with default settings
const htmlOutput = marked.parse(markdownContent, {
gfm: true,
breaks: true,
async: false,
}) as string;
// Sanitize the output HTML with DOMPurify
return DOMPurify.sanitize(htmlOutput, {
// Allow common safe HTML elements
ALLOWED_TAGS: [
"p",
"br",
"strong",
"em",
"b",
"i",
"u",
"s",
"code",
"pre",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"ol",
"li",
"blockquote",
"a",
],
ALLOWED_ATTR: [
"href",
"title",
"target",
"rel", // For links
"class", // For basic styling
],
// Keep content formatting
KEEP_CONTENT: true,
});
} catch (error) {
console.error("Error rendering markdown:", error);
// Fallback to sanitized plain text if markdown parsing fails
return DOMPurify.sanitize(markdownContent);
}
}
// Shared utility function for creating Tailwind pre elements
function createPreElement(content: string, additionalClasses: string = "") {
return html`<pre
class="bg-gray-200 dark:bg-gray-700 text-black dark:text-gray-100 p-2 rounded whitespace-pre-wrap break-words max-w-full w-full box-border overflow-wrap-break-word ${additionalClasses}"
>
${content}</pre
>`;
}
@customElement("sketch-tool-card-bash")
export class SketchToolCardBash extends SketchTailwindElement {
@property() toolCall: ToolCall;
@property() open: boolean;
render() {
const inputData = JSON.parse(this.toolCall?.input || "{}");
const isBackground = inputData?.background === true;
const isSlowOk = inputData?.slow_ok === true;
const backgroundIcon = isBackground
? html`<span title="Running in background">๐Ÿฅท</span> `
: "";
const slowIcon = isSlowOk
? html`<span title="Extended timeouts">๐Ÿข</span> `
: "";
// Truncate the command if it's too long to display nicely
const command = inputData?.command || "";
const displayCommand =
command.length > 80 ? command.substring(0, 80) + "..." : command;
const summaryContent = html`<div
class="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
>
${backgroundIcon}${slowIcon}${displayCommand}
</div>`;
const inputContent = html`<div
class="flex w-full max-w-full flex-col overflow-wrap-break-word break-words"
>
<div class="w-full relative">
<pre
class="bg-gray-200 dark:bg-gray-700 text-black dark:text-gray-100 p-2 rounded whitespace-pre-wrap break-words max-w-full w-full box-border overflow-wrap-break-word w-full mb-0 rounded-t rounded-b-none box-border"
>
${backgroundIcon}${slowIcon}${inputData?.command}</pre
>
</div>
</div>`;
const resultContent = this.toolCall?.result_message?.tool_result
? html`<div class="w-full relative">
${createPreElement(
this.toolCall.result_message.tool_result,
"mt-0 text-gray-600 rounded-t-none rounded-b w-full box-border max-h-[300px] overflow-y-auto",
)}
</div>`
: "";
return html`<sketch-tool-card-base
.open=${this.open}
.toolCall=${this.toolCall}
.summaryContent=${summaryContent}
.inputContent=${inputContent}
.resultContent=${resultContent}
></sketch-tool-card-base>`;
}
}
@customElement("sketch-tool-card-codereview")
export class SketchToolCardCodeReview extends SketchTailwindElement {
@property() toolCall: ToolCall;
@property() open: boolean;
// Determine the status icon based on the content of the result message
getStatusIcon(resultText: string): string {
if (!resultText) return "";
if (resultText === "OK") return "โœ”๏ธ";
if (resultText.includes("# Errors")) return "โš ๏ธ";
if (resultText.includes("# Info")) return "โ„น๏ธ";
if (resultText.includes("uncommitted changes in repo")) return "๐Ÿงน";
if (resultText.includes("no new commits have been added")) return "๐Ÿฃ";
if (resultText.includes("git repo is not clean")) return "๐Ÿงผ";
return "โ“";
}
render() {
const resultText = this.toolCall?.result_message?.tool_result || "";
const statusIcon = this.getStatusIcon(resultText);
const summaryContent = html`<span>${statusIcon}</span>`;
const resultContent = resultText ? createPreElement(resultText) : "";
return html`<sketch-tool-card-base
.open=${this.open}
.toolCall=${this.toolCall}
.summaryContent=${summaryContent}
.resultContent=${resultContent}
></sketch-tool-card-base>`;
}
}
@customElement("sketch-tool-card-done")
export class SketchToolCardDone extends SketchTailwindElement {
@property() toolCall: ToolCall;
@property() open: boolean;
render() {
const doneInput = JSON.parse(this.toolCall.input);
const summaryContent = html`<span></span>`;
const resultContent = html`<div>
${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 class="mb-1">
<span>${statusIcon}</span> ${key}:${item.status}
</div>`;
})}
</div>`;
return html`<sketch-tool-card-base
.open=${this.open}
.toolCall=${this.toolCall}
.summaryContent=${summaryContent}
.resultContent=${resultContent}
></sketch-tool-card-base>`;
}
}
@customElement("sketch-tool-card-patch")
export class SketchToolCardPatch extends SketchTailwindElement {
@property() toolCall: ToolCall;
@property() open: boolean;
// Render a diff with syntax highlighting
renderDiff(diff: string) {
// Remove ---/+++ header lines and trim leading/trailing blank lines
const lines = diff
.split("\n")
.filter((line) => !line.startsWith("---") && !line.startsWith("+++"))
.join("\n")
.trim()
.split("\n");
const coloredLines = lines.map((line) => {
if (line.startsWith("+")) {
// prettier-ignore
return html`<div class="text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20">${line}</div>`;
} else if (line.startsWith("-")) {
// prettier-ignore
return html`<div class="text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20">${line}</div>`;
} else if (line.startsWith("@@")) {
// prettier-ignore
return html`<div class="text-cyan-600 dark:text-cyan-400 bg-cyan-50 dark:bg-cyan-900/20 font-semibold">${line}</div>`;
} else {
// prettier-ignore
return html`<div class="text-gray-800 dark:text-gray-200">${line}</div>`;
}
});
// prettier-ignore
return html`<pre class="bg-gray-100 dark:bg-gray-800 text-xs p-2 rounded whitespace-pre-wrap break-words max-w-full w-full box-border overflow-x-auto font-mono text-gray-900 dark:text-gray-100">${coloredLines}</pre>`;
}
render() {
const patchInput = JSON.parse(this.toolCall?.input);
const toolFailed = this.toolCall?.result_message?.tool_error;
const summaryContent = html`<span
class="text-gray-600 font-mono overflow-hidden text-ellipsis whitespace-nowrap rounded"
>
${toolFailed
? `${patchInput?.path}: failed`
: `${patchInput?.path}: ${patchInput.patches.length} edit${patchInput.patches.length > 1 ? "s" : ""}`}
</span>`;
const inputContent = html``;
// Show diff if available, otherwise show the regular result
let resultContent;
if (
this.toolCall?.result_message?.display &&
typeof this.toolCall.result_message.display === "string"
) {
// Render the diff with syntax highlighting
resultContent = html`<div class="w-full relative">
${this.renderDiff(this.toolCall.result_message.display)}
</div>`;
} else if (this.toolCall?.result_message?.tool_result) {
resultContent = createPreElement(
this.toolCall.result_message.tool_result,
);
} else {
resultContent = "";
}
return html`<sketch-tool-card-base
.open=${this.open}
.toolCall=${this.toolCall}
.summaryContent=${summaryContent}
.inputContent=${inputContent}
.resultContent=${resultContent}
></sketch-tool-card-base>`;
}
}
@customElement("sketch-tool-card-think")
export class SketchToolCardThink extends SketchTailwindElement {
@property() toolCall: ToolCall;
@property() open: boolean;
render() {
const thoughts = JSON.parse(this.toolCall?.input)?.thoughts || "";
const summaryContent = html`<span
class="overflow-hidden text-ellipsis font-mono"
>
${thoughts.split("\n")[0]}
</span>`;
const inputContent = html`<div
class="overflow-x-auto mb-1 font-mono px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded select-text cursor-text text-sm leading-relaxed text-gray-900 dark:text-gray-100"
>
<div class="markdown-content">
${unsafeHTML(renderMarkdown(thoughts))}
</div>
</div>`;
return html`<sketch-tool-card-base
.open=${this.open}
.toolCall=${this.toolCall}
.summaryContent=${summaryContent}
.inputContent=${inputContent}
></sketch-tool-card-base>`;
}
}
@customElement("sketch-tool-card-todo-write")
export class SketchToolCardTodoWrite extends SketchTailwindElement {
@property() toolCall: ToolCall;
@property() open: boolean;
render() {
const inputData = JSON.parse(this.toolCall?.input || "{}");
const tasks = inputData.tasks || [];
// Generate circles based on task status
const circles = tasks
.map((task) => {
switch (task.status) {
case "completed":
return "โ—"; // full circle
case "in-progress":
return "โ—"; // half circle
case "queued":
default:
return "โ—‹"; // empty circle
}
})
.join(" ");
const summaryContent = html`<span class="italic text-gray-600">
${circles}
</span>`;
const resultContent = this.toolCall?.result_message?.tool_result
? createPreElement(this.toolCall.result_message.tool_result)
: "";
return html`<sketch-tool-card-base
.open=${this.open}
.toolCall=${this.toolCall}
.summaryContent=${summaryContent}
.resultContent=${resultContent}
></sketch-tool-card-base>`;
}
}
@customElement("sketch-tool-card-keyword-search")
export class SketchToolCardKeywordSearch extends SketchTailwindElement {
@property() toolCall: ToolCall;
@property() open: boolean;
render() {
const inputData = JSON.parse(this.toolCall?.input || "{}");
const query = inputData.query || "";
const searchTerms = inputData.search_terms || [];
const summaryContent = html`<div
class="flex flex-col gap-0.5 w-full max-w-full overflow-hidden"
>
<div
class="text-gray-800 text-xs normal-case whitespace-normal break-words leading-tight"
>
๐Ÿ” ${query}
</div>
<div
class="text-gray-600 text-xs normal-case whitespace-normal break-words leading-tight mt-px"
>
๐Ÿ—๏ธ ${searchTerms.join(", ")}
</div>
</div>`;
const inputContent = html`<div>
<div><strong>Query:</strong> ${query}</div>
<div><strong>Search terms:</strong> ${searchTerms.join(", ")}</div>
</div>`;
const resultContent = this.toolCall?.result_message?.tool_result
? createPreElement(this.toolCall.result_message.tool_result)
: "";
return html`<sketch-tool-card-base
.open=${this.open}
.toolCall=${this.toolCall}
.summaryContent=${summaryContent}
.inputContent=${inputContent}
.resultContent=${resultContent}
></sketch-tool-card-base>`;
}
}
@customElement("sketch-tool-card-todo-read")
export class SketchToolCardTodoRead extends SketchTailwindElement {
@property() toolCall: ToolCall;
@property() open: boolean;
render() {
const summaryContent = html`<span class="italic text-gray-600">
Read todo list
</span>`;
const resultContent = this.toolCall?.result_message?.tool_result
? createPreElement(this.toolCall.result_message.tool_result)
: "";
return html`<sketch-tool-card-base
.open=${this.open}
.toolCall=${this.toolCall}
.summaryContent=${summaryContent}
.resultContent=${resultContent}
></sketch-tool-card-base>`;
}
}
@customElement("sketch-tool-card-generic")
export class SketchToolCardGeneric extends SketchTailwindElement {
@property() toolCall: ToolCall;
@property() open: boolean;
render() {
const summaryContent = html`<span
class="block whitespace-normal break-words max-w-full w-full"
>
${this.toolCall?.input}
</span>`;
const inputContent = html`<div class="max-w-full break-words">
Input:
${createPreElement(
this.toolCall?.input || "",
"max-w-full whitespace-pre-wrap break-words",
)}
</div>`;
const resultContent = this.toolCall?.result_message?.tool_result
? html`<div class="max-w-full break-words">
Result: ${createPreElement(this.toolCall.result_message.tool_result)}
</div>`
: "";
return html`<sketch-tool-card-base
.open=${this.open}
.toolCall=${this.toolCall}
.summaryContent=${summaryContent}
.inputContent=${inputContent}
.resultContent=${resultContent}
></sketch-tool-card-base>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"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-todo-write": SketchToolCardTodoWrite;
"sketch-tool-card-todo-read": SketchToolCardTodoRead;
"sketch-tool-card-keyword-search": SketchToolCardKeywordSearch;
}
}