webui: split sketch-tool-card into per-tool cards
diff --git a/loop/webui/src/web-components/sketch-tool-card.ts b/loop/webui/src/web-components/sketch-tool-card.ts
new file mode 100644
index 0000000..800c665
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-tool-card.ts
@@ -0,0 +1,592 @@
+import { css, html, LitElement } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { repeat } from "lit/directives/repeat.js";
+import { customElement, property } from "lit/decorators.js";
+import { State, 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;
+    }
+  `;
+
+  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_error}</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 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}
+        </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: black;
+      color: white;
+      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: gray;
+      border-radius: 0 0 4px 4px;
+    }
+  `;
+
+  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)?.command}</span>
+    <div slot="input" class="input"><pre>${JSON.parse(this.toolCall?.input)?.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;
+  }
+}