Initial commit
diff --git a/loop/webui/src/timeline/toolcalls.ts b/loop/webui/src/timeline/toolcalls.ts
new file mode 100644
index 0000000..5df88bd
--- /dev/null
+++ b/loop/webui/src/timeline/toolcalls.ts
@@ -0,0 +1,259 @@
+/**
+ * Utility functions for rendering tool calls in the timeline
+ */
+
+import { ToolCall, TimelineMessage } from "./types";
+import { html, render } from "lit-html";
+
+/**
+ * Create a tool call card element for display in the timeline
+ * @param toolCall The tool call data to render
+ * @param toolResponse Optional tool response message if available
+ * @param toolCardId Unique ID for this tool card
+ * @returns The created tool card element
+ */
+export function createToolCallCard(
+  toolCall: ToolCall,
+  toolResponse?: TimelineMessage | null,
+  toolCardId?: string
+): HTMLElement {
+  // Create a unique ID for this tool card if not provided
+  const cardId =
+    toolCardId ||
+    `tool-card-${
+      toolCall.tool_call_id || Math.random().toString(36).substring(2, 11)
+    }`;
+
+  // Get input as compact string
+  let inputText = "";
+  try {
+    if (toolCall.input) {
+      const parsedInput = JSON.parse(toolCall.input);
+
+      // For bash commands, use a special format
+      if (toolCall.name === "bash" && parsedInput.command) {
+        inputText = parsedInput.command;
+      } else {
+        // For other tools, use the stringified JSON
+        inputText = JSON.stringify(parsedInput);
+      }
+    }
+  } catch (e) {
+    // Not valid JSON, use as-is
+    inputText = toolCall.input || "";
+  }
+
+  // Truncate input text for display
+  const displayInput =
+    inputText.length > 80 ? inputText.substring(0, 78) + "..." : inputText;
+
+  // Truncate for compact display
+  const shortInput =
+    displayInput.length > 30
+      ? displayInput.substring(0, 28) + "..."
+      : displayInput;
+
+  // Format input for expanded view
+  let formattedInput = displayInput;
+  try {
+    const parsedInput = JSON.parse(toolCall.input || "");
+    formattedInput = JSON.stringify(parsedInput, null, 2);
+  } catch (e) {
+    // Not valid JSON, use display input as-is
+  }
+
+  // Truncate result for compact display if available
+  let shortResult = "";
+  if (toolResponse && toolResponse.tool_result) {
+    shortResult =
+      toolResponse.tool_result.length > 40
+        ? toolResponse.tool_result.substring(0, 38) + "..."
+        : toolResponse.tool_result;
+  }
+
+  // State for collapsed/expanded view
+  let isCollapsed = true;
+
+  // Handler to copy text to clipboard
+  const copyToClipboard = (text: string, button: HTMLElement) => {
+    navigator.clipboard
+      .writeText(text)
+      .then(() => {
+        button.textContent = "Copied!";
+        setTimeout(() => {
+          button.textContent = "Copy";
+        }, 2000);
+      })
+      .catch((err) => {
+        console.error("Failed to copy text:", err);
+        button.textContent = "Failed";
+        setTimeout(() => {
+          button.textContent = "Copy";
+        }, 2000);
+      });
+  };
+
+  const 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" }),
+      });
+      console.log('cancel', tool_call_id, response);
+      button.parentElement.removeChild(button);
+    } catch (e) {
+      console.error('cancel', tool_call_id,e);
+    }
+  };
+
+  // Create the container element
+  const container = document.createElement("div");
+  container.id = cardId;
+  container.className = "tool-call-card collapsed";
+
+  // Function to render the component
+  const renderComponent = () => {
+    const template = html`
+      <div
+        class="tool-call-compact-view"
+        @click=${() => {
+          isCollapsed = !isCollapsed;
+          container.classList.toggle("collapsed");
+          renderComponent();
+        }}
+      >
+        <span class="tool-call-status ${toolResponse ? "" : "spinner"}">
+          ${toolResponse ? (toolResponse.tool_error ? "❌" : "✅") : "⏳"}
+        </span>
+        <span class="tool-call-name">${toolCall.name}</span>
+        <code class="tool-call-input-preview">${shortInput}</code>
+        ${toolResponse && toolResponse.tool_result
+          ? html`<code class="tool-call-result-preview">${shortResult}</code>`
+          : ""}
+        ${toolResponse && toolResponse.elapsed !== undefined
+          ? html`<span class="tool-call-time"
+              >${(toolResponse.elapsed / 1e9).toFixed(2)}s</span
+            >`
+          : ""}
+          ${toolResponse ? "" : 
+            html`<button class="refresh-button stop-button" title="Cancel this operation" @click=${(e: Event) => {
+                e.stopPropagation(); // Don't toggle expansion when clicking cancel
+                const button = e.target as HTMLButtonElement;
+                cancelToolCall(toolCall.tool_call_id, button);
+              }}>Cancel</button>`}
+        <span class="tool-call-expand-icon">${isCollapsed ? "▼" : "▲"}</span>
+      </div>
+
+      <div class="tool-call-expanded-view">
+        <div class="tool-call-section">
+          <div class="tool-call-section-label">
+            Input:
+            <button
+              class="tool-call-copy-btn"
+              title="Copy input to clipboard"
+              @click=${(e: Event) => {
+                e.stopPropagation(); // Don't toggle expansion when clicking copy
+                const button = e.target as HTMLElement;
+                copyToClipboard(toolCall.input || displayInput, button);
+              }}
+            >
+              Copy
+            </button>
+          </div>
+          <div class="tool-call-section-content">
+            <pre class="tool-call-input">${formattedInput}</pre>
+          </div>
+        </div>
+
+        ${toolResponse && toolResponse.tool_result
+          ? html`
+              <div class="tool-call-section">
+                <div class="tool-call-section-label">
+                  Result:
+                  <button
+                    class="tool-call-copy-btn"
+                    title="Copy result to clipboard"
+                    @click=${(e: Event) => {
+                      e.stopPropagation(); // Don't toggle expansion when clicking copy
+                      const button = e.target as HTMLElement;
+                      copyToClipboard(toolResponse.tool_result || "", button);
+                    }}
+                  >
+                    Copy
+                  </button>
+                </div>
+                <div class="tool-call-section-content">
+                  <div class="tool-call-result">
+                    ${toolResponse.tool_result.includes("\n")
+                      ? html`<pre><code>${toolResponse.tool_result}</code></pre>`
+                      : toolResponse.tool_result}
+                  </div>
+                </div>
+              </div>
+            `
+          : ""}
+      </div>
+    `;
+
+    render(template, container);
+  };
+
+  // Initial render
+  renderComponent();
+
+  return container;
+}
+
+/**
+ * Update a tool call card with response data
+ * @param toolCard The tool card element to update
+ * @param toolMessage The tool response message
+ */
+export function updateToolCallCard(
+  toolCard: HTMLElement,
+  toolMessage: TimelineMessage
+): void {
+  if (!toolCard) return;
+
+  // Find the original tool call data to reconstruct the card
+  const toolName = toolCard.querySelector(".tool-call-name")?.textContent || "";
+  const inputPreview =
+    toolCard.querySelector(".tool-call-input-preview")?.textContent || "";
+
+  // Extract the original input from the expanded view
+  let originalInput = "";
+  const inputEl = toolCard.querySelector(".tool-call-input");
+  if (inputEl) {
+    originalInput = inputEl.textContent || "";
+  }
+
+  // Create a minimal ToolCall object from the existing data
+  const toolCall: Partial<ToolCall> = {
+    name: toolName,
+    // Try to reconstruct the original input if possible
+    input: originalInput,
+  };
+
+  // Replace the existing card with a new one
+  const newCard = createToolCallCard(
+    toolCall as ToolCall,
+    toolMessage,
+    toolCard.id
+  );
+
+  // Preserve the collapse state
+  if (!toolCard.classList.contains("collapsed")) {
+    newCard.classList.remove("collapsed");
+  }
+
+  // Replace the old card with the new one
+  if (toolCard.parentNode) {
+    toolCard.parentNode.replaceChild(newCard, toolCard);
+  }
+}