| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1 | /** |
| 2 | * Utility functions for rendering tool calls in the timeline |
| 3 | */ |
| 4 | |
| 5 | import { ToolCall, TimelineMessage } from "./types"; |
| 6 | import { html, render } from "lit-html"; |
| 7 | |
| 8 | /** |
| 9 | * Create a tool call card element for display in the timeline |
| 10 | * @param toolCall The tool call data to render |
| 11 | * @param toolResponse Optional tool response message if available |
| 12 | * @param toolCardId Unique ID for this tool card |
| 13 | * @returns The created tool card element |
| 14 | */ |
| 15 | export function createToolCallCard( |
| 16 | toolCall: ToolCall, |
| 17 | toolResponse?: TimelineMessage | null, |
| 18 | toolCardId?: string |
| 19 | ): HTMLElement { |
| 20 | // Create a unique ID for this tool card if not provided |
| 21 | const cardId = |
| 22 | toolCardId || |
| 23 | `tool-card-${ |
| 24 | toolCall.tool_call_id || Math.random().toString(36).substring(2, 11) |
| 25 | }`; |
| 26 | |
| 27 | // Get input as compact string |
| 28 | let inputText = ""; |
| 29 | try { |
| 30 | if (toolCall.input) { |
| 31 | const parsedInput = JSON.parse(toolCall.input); |
| 32 | |
| 33 | // For bash commands, use a special format |
| 34 | if (toolCall.name === "bash" && parsedInput.command) { |
| 35 | inputText = parsedInput.command; |
| 36 | } else { |
| 37 | // For other tools, use the stringified JSON |
| 38 | inputText = JSON.stringify(parsedInput); |
| 39 | } |
| 40 | } |
| 41 | } catch (e) { |
| 42 | // Not valid JSON, use as-is |
| 43 | inputText = toolCall.input || ""; |
| 44 | } |
| 45 | |
| 46 | // Truncate input text for display |
| 47 | const displayInput = |
| 48 | inputText.length > 80 ? inputText.substring(0, 78) + "..." : inputText; |
| 49 | |
| 50 | // Truncate for compact display |
| 51 | const shortInput = |
| 52 | displayInput.length > 30 |
| 53 | ? displayInput.substring(0, 28) + "..." |
| 54 | : displayInput; |
| 55 | |
| 56 | // Format input for expanded view |
| 57 | let formattedInput = displayInput; |
| 58 | try { |
| 59 | const parsedInput = JSON.parse(toolCall.input || ""); |
| 60 | formattedInput = JSON.stringify(parsedInput, null, 2); |
| 61 | } catch (e) { |
| 62 | // Not valid JSON, use display input as-is |
| 63 | } |
| 64 | |
| 65 | // Truncate result for compact display if available |
| 66 | let shortResult = ""; |
| 67 | if (toolResponse && toolResponse.tool_result) { |
| 68 | shortResult = |
| 69 | toolResponse.tool_result.length > 40 |
| 70 | ? toolResponse.tool_result.substring(0, 38) + "..." |
| 71 | : toolResponse.tool_result; |
| 72 | } |
| 73 | |
| 74 | // State for collapsed/expanded view |
| 75 | let isCollapsed = true; |
| 76 | |
| 77 | // Handler to copy text to clipboard |
| 78 | const copyToClipboard = (text: string, button: HTMLElement) => { |
| 79 | navigator.clipboard |
| 80 | .writeText(text) |
| 81 | .then(() => { |
| 82 | button.textContent = "Copied!"; |
| 83 | setTimeout(() => { |
| 84 | button.textContent = "Copy"; |
| 85 | }, 2000); |
| 86 | }) |
| 87 | .catch((err) => { |
| 88 | console.error("Failed to copy text:", err); |
| 89 | button.textContent = "Failed"; |
| 90 | setTimeout(() => { |
| 91 | button.textContent = "Copy"; |
| 92 | }, 2000); |
| 93 | }); |
| 94 | }; |
| 95 | |
| 96 | const cancelToolCall = async(tool_call_id: string, button: HTMLButtonElement) => { |
| 97 | console.log('cancelToolCall', tool_call_id, button); |
| 98 | button.innerText = 'Cancelling'; |
| 99 | button.disabled = true; |
| 100 | try { |
| 101 | const response = await fetch("cancel", { |
| 102 | method: "POST", |
| 103 | headers: { |
| 104 | "Content-Type": "application/json", |
| 105 | }, |
| 106 | body: JSON.stringify({tool_call_id: tool_call_id, reason: "user requested cancellation" }), |
| 107 | }); |
| 108 | console.log('cancel', tool_call_id, response); |
| 109 | button.parentElement.removeChild(button); |
| 110 | } catch (e) { |
| 111 | console.error('cancel', tool_call_id,e); |
| 112 | } |
| 113 | }; |
| 114 | |
| 115 | // Create the container element |
| 116 | const container = document.createElement("div"); |
| 117 | container.id = cardId; |
| 118 | container.className = "tool-call-card collapsed"; |
| 119 | |
| 120 | // Function to render the component |
| 121 | const renderComponent = () => { |
| 122 | const template = html` |
| 123 | <div |
| 124 | class="tool-call-compact-view" |
| 125 | @click=${() => { |
| 126 | isCollapsed = !isCollapsed; |
| 127 | container.classList.toggle("collapsed"); |
| 128 | renderComponent(); |
| 129 | }} |
| 130 | > |
| 131 | <span class="tool-call-status ${toolResponse ? "" : "spinner"}"> |
| 132 | ${toolResponse ? (toolResponse.tool_error ? "❌" : "✅") : "⏳"} |
| 133 | </span> |
| 134 | <span class="tool-call-name">${toolCall.name}</span> |
| 135 | <code class="tool-call-input-preview">${shortInput}</code> |
| 136 | ${toolResponse && toolResponse.tool_result |
| 137 | ? html`<code class="tool-call-result-preview">${shortResult}</code>` |
| 138 | : ""} |
| 139 | ${toolResponse && toolResponse.elapsed !== undefined |
| 140 | ? html`<span class="tool-call-time" |
| 141 | >${(toolResponse.elapsed / 1e9).toFixed(2)}s</span |
| 142 | >` |
| 143 | : ""} |
| 144 | ${toolResponse ? "" : |
| 145 | html`<button class="refresh-button stop-button" title="Cancel this operation" @click=${(e: Event) => { |
| 146 | e.stopPropagation(); // Don't toggle expansion when clicking cancel |
| 147 | const button = e.target as HTMLButtonElement; |
| 148 | cancelToolCall(toolCall.tool_call_id, button); |
| 149 | }}>Cancel</button>`} |
| 150 | <span class="tool-call-expand-icon">${isCollapsed ? "▼" : "▲"}</span> |
| 151 | </div> |
| 152 | |
| 153 | <div class="tool-call-expanded-view"> |
| 154 | <div class="tool-call-section"> |
| 155 | <div class="tool-call-section-label"> |
| 156 | Input: |
| 157 | <button |
| 158 | class="tool-call-copy-btn" |
| 159 | title="Copy input to clipboard" |
| 160 | @click=${(e: Event) => { |
| 161 | e.stopPropagation(); // Don't toggle expansion when clicking copy |
| 162 | const button = e.target as HTMLElement; |
| 163 | copyToClipboard(toolCall.input || displayInput, button); |
| 164 | }} |
| 165 | > |
| 166 | Copy |
| 167 | </button> |
| 168 | </div> |
| 169 | <div class="tool-call-section-content"> |
| 170 | <pre class="tool-call-input">${formattedInput}</pre> |
| 171 | </div> |
| 172 | </div> |
| 173 | |
| 174 | ${toolResponse && toolResponse.tool_result |
| 175 | ? html` |
| 176 | <div class="tool-call-section"> |
| 177 | <div class="tool-call-section-label"> |
| 178 | Result: |
| 179 | <button |
| 180 | class="tool-call-copy-btn" |
| 181 | title="Copy result to clipboard" |
| 182 | @click=${(e: Event) => { |
| 183 | e.stopPropagation(); // Don't toggle expansion when clicking copy |
| 184 | const button = e.target as HTMLElement; |
| 185 | copyToClipboard(toolResponse.tool_result || "", button); |
| 186 | }} |
| 187 | > |
| 188 | Copy |
| 189 | </button> |
| 190 | </div> |
| 191 | <div class="tool-call-section-content"> |
| 192 | <div class="tool-call-result"> |
| 193 | ${toolResponse.tool_result.includes("\n") |
| 194 | ? html`<pre><code>${toolResponse.tool_result}</code></pre>` |
| 195 | : toolResponse.tool_result} |
| 196 | </div> |
| 197 | </div> |
| 198 | </div> |
| 199 | ` |
| 200 | : ""} |
| 201 | </div> |
| 202 | `; |
| 203 | |
| 204 | render(template, container); |
| 205 | }; |
| 206 | |
| 207 | // Initial render |
| 208 | renderComponent(); |
| 209 | |
| 210 | return container; |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * Update a tool call card with response data |
| 215 | * @param toolCard The tool card element to update |
| 216 | * @param toolMessage The tool response message |
| 217 | */ |
| 218 | export function updateToolCallCard( |
| 219 | toolCard: HTMLElement, |
| 220 | toolMessage: TimelineMessage |
| 221 | ): void { |
| 222 | if (!toolCard) return; |
| 223 | |
| 224 | // Find the original tool call data to reconstruct the card |
| 225 | const toolName = toolCard.querySelector(".tool-call-name")?.textContent || ""; |
| 226 | const inputPreview = |
| 227 | toolCard.querySelector(".tool-call-input-preview")?.textContent || ""; |
| 228 | |
| 229 | // Extract the original input from the expanded view |
| 230 | let originalInput = ""; |
| 231 | const inputEl = toolCard.querySelector(".tool-call-input"); |
| 232 | if (inputEl) { |
| 233 | originalInput = inputEl.textContent || ""; |
| 234 | } |
| 235 | |
| 236 | // Create a minimal ToolCall object from the existing data |
| 237 | const toolCall: Partial<ToolCall> = { |
| 238 | name: toolName, |
| 239 | // Try to reconstruct the original input if possible |
| 240 | input: originalInput, |
| 241 | }; |
| 242 | |
| 243 | // Replace the existing card with a new one |
| 244 | const newCard = createToolCallCard( |
| 245 | toolCall as ToolCall, |
| 246 | toolMessage, |
| 247 | toolCard.id |
| 248 | ); |
| 249 | |
| 250 | // Preserve the collapse state |
| 251 | if (!toolCard.classList.contains("collapsed")) { |
| 252 | newCard.classList.remove("collapsed"); |
| 253 | } |
| 254 | |
| 255 | // Replace the old card with the new one |
| 256 | if (toolCard.parentNode) { |
| 257 | toolCard.parentNode.replaceChild(newCard, toolCard); |
| 258 | } |
| 259 | } |