blob: 5df88bd3461f7a3cdcd270bc64938a0a0fdefc32 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001/**
2 * Utility functions for rendering tool calls in the timeline
3 */
4
5import { ToolCall, TimelineMessage } from "./types";
6import { 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 */
15export 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 */
218export 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}