webui: Simplify tool call rendering code
diff --git a/webui/src/web-components/sketch-tool-card.ts b/webui/src/web-components/sketch-tool-card.ts
index a6fcdf9..b4bdd64 100644
--- a/webui/src/web-components/sketch-tool-card.ts
+++ b/webui/src/web-components/sketch-tool-card.ts
@@ -4,33 +4,48 @@
import { ToolCall, MultipleChoiceOption, MultipleChoiceParams } from "../types";
import { marked, MarkedOptions } from "marked";
+// Shared utility function for markdown rendering
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>
+ return marked.parse(markdownContent, {
+ gfm: true,
+ breaks: true,
async: false,
- // DOMPurify is recommended for production, but not included in this implementation
- };
- return marked.parse(markdownContent, markedOptions) as string;
+ }) as string;
} catch (error) {
console.error("Error rendering markdown:", error);
- // Fallback to plain text if markdown parsing fails
return markdownContent;
}
}
+// Common styles shared across all tool cards
+const commonStyles = css`
+ pre {
+ background: rgb(236, 236, 236);
+ color: black;
+ padding: 0.5em;
+ border-radius: 4px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-width: 100%;
+ width: 100%;
+ box-sizing: border-box;
+ overflow-wrap: break-word;
+ }
+ .summary-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+ font-family: monospace;
+ }
+`;
+
@customElement("sketch-tool-card")
export class SketchToolCard extends LitElement {
- @property()
- toolCall: ToolCall;
-
- @property()
- open: boolean;
-
- @state()
- detailsVisible: boolean = false;
+ @property() toolCall: ToolCall;
+ @property() open: boolean;
+ @state() detailsVisible: boolean = false;
static styles = css`
.tool-call {
@@ -38,24 +53,21 @@
flex-direction: column;
width: 100%;
}
-
.tool-row {
display: flex;
width: 100%;
box-sizing: border-box;
padding: 6px 8px 6px 12px;
align-items: center;
- gap: 8px; /* Reduce gap slightly to accommodate longer tool names */
+ gap: 8px;
cursor: pointer;
border-radius: 4px;
position: relative;
- overflow: hidden; /* Changed to hidden to prevent horizontal scrolling */
+ overflow: hidden;
}
-
.tool-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
-
.tool-name {
font-family: monospace;
font-weight: 500;
@@ -65,28 +77,22 @@
padding: 2px 6px;
flex-shrink: 0;
min-width: 45px;
- /* Remove max-width to prevent truncation */
font-size: 12px;
text-align: center;
- /* Remove overflow/ellipsis to ensure names are fully visible */
white-space: nowrap;
}
-
.tool-success {
color: #5cb85c;
font-size: 14px;
}
-
.tool-error {
color: #6c757d;
font-size: 14px;
}
-
.tool-pending {
color: #f0ad4e;
font-size: 14px;
}
-
.summary-text {
white-space: nowrap;
text-overflow: ellipsis;
@@ -98,33 +104,27 @@
font-size: 12px;
padding: 0 4px;
min-width: 50px;
- max-width: calc(
- 100% - 250px
- ); /* More space for tool-name and tool-status */
- display: inline-block; /* Ensure proper truncation */
+ max-width: calc(100% - 250px);
+ display: inline-block;
}
-
.tool-status {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
flex-shrink: 0;
- min-width: 120px; /* Increased width to prevent cutoff */
+ min-width: 120px;
justify-content: flex-end;
padding-right: 8px;
}
-
.tool-call-status {
display: flex;
align-items: center;
justify-content: center;
}
-
.tool-call-status.spinner {
animation: spin 1s infinite linear;
}
-
@keyframes spin {
0% {
transform: rotate(0deg);
@@ -133,7 +133,6 @@
transform: rotate(360deg);
}
}
-
.elapsed {
font-size: 11px;
color: #777;
@@ -141,7 +140,6 @@
min-width: 40px;
text-align: right;
}
-
.tool-details {
padding: 8px;
background-color: rgba(0, 0, 0, 0.02);
@@ -155,21 +153,11 @@
max-width: 100%;
width: 100%;
box-sizing: border-box;
- overflow: hidden; /* Hide overflow at container level */
+ overflow: hidden;
}
-
.tool-details.visible {
display: block;
}
-
- .expand-indicator {
- color: #aaa;
- font-size: 10px;
- width: 12px;
- display: inline-block;
- text-align: center;
- }
-
.cancel-button {
cursor: pointer;
color: white;
@@ -181,59 +169,31 @@
white-space: nowrap;
min-width: 50px;
}
-
.cancel-button:hover {
background-color: #c9302c;
}
-
.cancel-button[disabled] {
background-color: #999;
cursor: not-allowed;
}
-
- .tool-error-message {
- font-style: italic;
- color: #6c757d;
- }
-
- .codereview-OK {
- color: green;
- }
`;
- 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",
- },
+ 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);
@@ -246,19 +206,14 @@
}
render() {
- // Determine the status indicator based on the tool call result
- let statusIcon;
- if (!this.toolCall?.result_message) {
- // Pending status with spinner
- statusIcon = html`<span class="tool-call-status spinner tool-pending"
- >⏳</span
- >`;
- } else if (this.toolCall?.result_message.tool_error) {
- // Error status
- statusIcon = html`<span class="tool-call-status tool-error">🔔</span>`;
- } else {
- // Success status
- statusIcon = html`<span class="tool-call-status tool-success">✓</span>`;
+ // Status indicator based on result
+ let statusIcon = html`<span class="tool-call-status spinner tool-pending"
+ >⏳</span
+ >`;
+ if (this.toolCall?.result_message) {
+ statusIcon = this.toolCall?.result_message.tool_error
+ ? html`<span class="tool-call-status tool-error">🔔</span>`
+ : html`<span class="tool-call-status tool-success">✓</span>`;
}
// Cancel button for pending operations
@@ -269,8 +224,10 @@
title="Cancel this operation"
@click=${(e: Event) => {
e.stopPropagation();
- const button = e.target as HTMLButtonElement;
- this._cancelToolCall(this.toolCall?.tool_call_id, button);
+ this._cancelToolCall(
+ this.toolCall?.tool_call_id,
+ e.target as HTMLButtonElement,
+ );
}}
>
Cancel
@@ -281,7 +238,7 @@
? html`<span class="elapsed"
>${(this.toolCall?.result_message?.elapsed / 1e9).toFixed(1)}s</span
>`
- : html`<span class="elapsed"></span>`; // Empty span to maintain layout
+ : html`<span class="elapsed"></span>`;
// Initialize details visibility based on open property
if (this.open && !this.detailsVisible) {
@@ -304,159 +261,97 @@
@customElement("sketch-tool-card-bash")
export class SketchToolCardBash extends LitElement {
- @property()
- toolCall: ToolCall;
+ @property() toolCall: ToolCall;
+ @property() open: boolean;
- @property()
- open: boolean;
-
- static styles = css`
- pre {
- background: rgb(236, 236, 236);
- color: black;
- padding: 0.5em;
- border-radius: 4px;
- white-space: pre-wrap; /* Always wrap long lines */
- word-break: break-word; /* Use break-word for a more readable break */
- max-width: 100%;
- width: 100%;
- box-sizing: border-box;
- overflow-wrap: break-word; /* Additional property for better wrapping */
- }
- .summary-text {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 100%;
- font-family: monospace;
- }
-
- .command-wrapper {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 100%;
- }
- .input {
- display: flex;
- width: 100%;
- flex-direction: column; /* Change to column layout */
- }
- .input pre {
- width: 100%;
- margin-bottom: 0;
- border-radius: 4px 4px 0 0;
- box-sizing: border-box; /* Include padding in width calculation */
- }
- .result pre {
- margin-top: 0;
- color: #555;
- border-radius: 0 0 4px 4px;
- width: 100%; /* Ensure it uses full width */
- box-sizing: border-box; /* Include padding in width calculation */
- overflow-wrap: break-word; /* Ensure long words wrap */
- }
-
- /* Add a special class for long output that should be scrollable on hover */
- .result pre.scrollable-on-hover {
- max-height: 300px;
- overflow-y: auto;
- }
-
- /* Container for tool call results with proper text wrapping */
- .tool-call-result-container {
- width: 100%;
- position: relative;
- }
- .background-badge {
- display: inline-block;
- background-color: #6200ea;
- color: white;
- font-size: 10px;
- font-weight: bold;
- padding: 2px 6px;
- border-radius: 10px;
- margin-left: 8px;
- vertical-align: middle;
- }
- .command-wrapper {
- display: inline-block;
- max-width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- `;
-
- constructor() {
- super();
- }
-
- connectedCallback() {
- super.connectedCallback();
- }
-
- disconnectedCallback() {
- super.disconnectedCallback();
- }
+ static styles = [
+ commonStyles,
+ css`
+ .input {
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ }
+ .input pre {
+ width: 100%;
+ margin-bottom: 0;
+ border-radius: 4px 4px 0 0;
+ box-sizing: border-box;
+ }
+ .result pre {
+ margin-top: 0;
+ color: #555;
+ border-radius: 0 0 4px 4px;
+ width: 100%;
+ box-sizing: border-box;
+ }
+ .result pre.scrollable-on-hover {
+ max-height: 300px;
+ overflow-y: auto;
+ }
+ .tool-call-result-container {
+ width: 100%;
+ position: relative;
+ }
+ .background-badge {
+ display: inline-block;
+ background-color: #6200ea;
+ color: white;
+ font-size: 10px;
+ font-weight: bold;
+ padding: 2px 6px;
+ border-radius: 10px;
+ margin-left: 8px;
+ vertical-align: middle;
+ }
+ .command-wrapper {
+ display: inline-block;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ `,
+ ];
render() {
const inputData = JSON.parse(this.toolCall?.input || "{}");
const isBackground = inputData?.background === true;
const backgroundIcon = isBackground ? "🔄 " : "";
- return html`
- <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
- <span slot="summary" class="summary-text">
- <div class="command-wrapper">
- ${backgroundIcon}${inputData?.command}
+ return html` <sketch-tool-card
+ .open=${this.open}
+ .toolCall=${this.toolCall}
+ >
+ <span slot="summary" class="summary-text">
+ <div class="command-wrapper">
+ ${backgroundIcon}${inputData?.command}
+ </div>
+ </span>
+ <div slot="input" class="input">
+ <div class="tool-call-result-container">
+ <pre>${backgroundIcon}${inputData?.command}</pre>
+ </div>
</div>
- </span>
- <div slot="input" class="input">
- <div class="tool-call-result-container">
- <pre>${backgroundIcon}${inputData?.command}</pre>
- </div>
- </div>
- ${
- this.toolCall?.result_message
- ? html` ${this.toolCall?.result_message.tool_result
- ? html`<div slot="result" class="result">
- <div class="tool-call-result-container">
- <pre class="tool-call-result">
+ ${this.toolCall?.result_message?.tool_result
+ ? html`<div slot="result" class="result">
+ <div class="tool-call-result-container">
+ <pre class="tool-call-result">
${this.toolCall?.result_message.tool_result}</pre
- >
- </div>
- </div>`
- : ""}`
- : ""
- }</div>
+ >
+ </div>
+ </div>`
+ : ""}
</sketch-tool-card>`;
}
}
@customElement("sketch-tool-card-codereview")
export class SketchToolCardCodeReview extends LitElement {
- @property()
- toolCall: ToolCall;
+ @property() toolCall: ToolCall;
+ @property() open: boolean;
- @property()
- open: boolean;
-
- static styles = css``;
-
- constructor() {
- super();
- }
-
- connectedCallback() {
- super.connectedCallback();
- }
-
- disconnectedCallback() {
- super.disconnectedCallback();
- }
// Determine the status icon based on the content of the result message
- // This corresponds to the output format in claudetool/differential.go:Run
getStatusIcon(resultText: string): string {
if (!resultText) return "";
if (resultText === "OK") return "✔️";
@@ -472,47 +367,22 @@
const resultText = this.toolCall?.result_message?.tool_result || "";
const statusIcon = this.getStatusIcon(resultText);
- return html` <sketch-tool-card
- .open=${this.open}
- .toolCall=${this.toolCall}
- >
- <span slot="summary" class="summary-text"> ${statusIcon} </span>
- <div slot="result">
- <pre>${resultText}</pre>
- </div>
+ return html`<sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
+ <span slot="summary" class="summary-text">${statusIcon}</span>
+ <div slot="result"><pre>${resultText}</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();
- }
+ @property() toolCall: ToolCall;
+ @property() open: boolean;
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>
+ 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];
@@ -533,11 +403,8 @@
@customElement("sketch-tool-card-patch")
export class SketchToolCardPatch extends LitElement {
- @property()
- toolCall: ToolCall;
-
- @property()
- open: boolean;
+ @property() toolCall: ToolCall;
+ @property() open: boolean;
static styles = css`
.summary-text {
@@ -550,31 +417,16 @@
}
`;
- 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}
- >
+ 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>
+ return html`Patch operation: <b>${patch.operation}</b>
<pre>${patch.newText}</pre>`;
})}
</div>
@@ -587,11 +439,8 @@
@customElement("sketch-tool-card-think")
export class SketchToolCardThink extends LitElement {
- @property()
- toolCall: ToolCall;
-
- @property()
- open: boolean;
+ @property() toolCall: ToolCall;
+ @property() open: boolean;
static styles = css`
.thought-bubble {
@@ -616,24 +465,12 @@
}
`;
- 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?.split("\n")[0]}</span
- >
+ <span slot="summary" class="summary-text">
+ ${JSON.parse(this.toolCall?.input)?.thoughts?.split("\n")[0]}
+ </span>
<div slot="input" class="thought-bubble">
<div class="markdown-content">
${unsafeHTML(
@@ -648,11 +485,8 @@
@customElement("sketch-tool-card-title")
export class SketchToolCardTitle extends LitElement {
- @property()
- toolCall: ToolCall;
-
- @property()
- open: boolean;
+ @property() toolCall: ToolCall;
+ @property() open: boolean;
static styles = css`
.summary-text {
@@ -667,17 +501,6 @@
margin: 0;
}
`;
- constructor() {
- super();
- }
-
- connectedCallback() {
- super.connectedCallback();
- }
-
- disconnectedCallback() {
- super.disconnectedCallback();
- }
render() {
const inputData = JSON.parse(this.toolCall?.input || "{}");
@@ -697,14 +520,9 @@
@customElement("sketch-tool-card-multiple-choice")
export class SketchToolCardMultipleChoice extends LitElement {
- @property()
- toolCall: ToolCall;
-
- @property()
- open: boolean;
-
- @property()
- selectedOption: MultipleChoiceOption = null;
+ @property() toolCall: ToolCall;
+ @property() open: boolean;
+ @property() selectedOption: MultipleChoiceOption = null;
static styles = css`
.options-container {
@@ -714,7 +532,6 @@
gap: 8px;
margin: 10px 0;
}
-
.option {
display: inline-flex;
align-items: center;
@@ -726,74 +543,43 @@
border: 1px solid transparent;
user-select: none;
}
-
.option:hover {
background-color: #e0e0e0;
border-color: #ccc;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
-
.option:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
background-color: #d5d5d5;
}
-
.option.selected {
background-color: #e3f2fd;
border-color: #2196f3;
border-width: 1px;
border-style: solid;
}
-
- .option-index {
- font-size: 0.8em;
- opacity: 0.7;
- margin-right: 6px;
- }
-
- .option-label {
- font-family: sans-serif;
- }
-
.option-checkmark {
margin-left: 6px;
color: #2196f3;
}
-
.summary-text {
font-style: italic;
padding: 0.5em;
}
-
.summary-text strong {
font-style: normal;
color: #2196f3;
font-weight: 600;
}
-
- p {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- margin-bottom: 10px;
- }
`;
- constructor() {
- super();
- }
-
connectedCallback() {
super.connectedCallback();
this.updateSelectedOption();
}
- disconnectedCallback() {
- super.disconnectedCallback();
- }
-
updated(changedProps) {
if (changedProps.has("toolCall")) {
this.updateSelectedOption();
@@ -801,7 +587,6 @@
}
updateSelectedOption() {
- // Get selected option from result if available
if (this.toolCall?.result_message?.tool_result) {
try {
this.selectedOption = JSON.parse(
@@ -816,15 +601,8 @@
}
async handleOptionClick(choice) {
- // If this option is already selected, unselect it (toggle behavior)
- if (this.selectedOption === choice) {
- this.selectedOption = null;
- } else {
- // Otherwise, select the clicked option
- this.selectedOption = choice;
- }
+ this.selectedOption = this.selectedOption === choice ? null : choice;
- // Dispatch a custom event that can be listened to by parent components
const event = new CustomEvent("multiple-choice-selected", {
detail: {
responseText: this.selectedOption.responseText,
@@ -837,7 +615,6 @@
}
render() {
- // Parse the input to get choices if available
let choices = [];
let question = "";
try {
@@ -850,12 +627,11 @@
console.error("Error parsing multiple-choice input:", e);
}
- // Determine what to show in the summary slot
const summaryContent =
this.selectedOption !== null
- ? html`<span class="summary-text"
- >${question}: <strong>${this.selectedOption.caption}</strong></span
- >`
+ ? html`<span class="summary-text">
+ ${question}: <strong>${this.selectedOption.caption}</strong>
+ </span>`
: html`<span class="summary-text">${question}</span>`;
return html`
@@ -886,29 +662,11 @@
@customElement("sketch-tool-card-generic")
export class SketchToolCardGeneric extends LitElement {
- @property()
- toolCall: ToolCall;
-
- @property()
- open: boolean;
-
- constructor() {
- super();
- }
-
- connectedCallback() {
- super.connectedCallback();
- }
-
- disconnectedCallback() {
- super.disconnectedCallback();
- }
+ @property() toolCall: ToolCall;
+ @property() open: boolean;
render() {
- return html` <sketch-tool-card
- .open=${this.open}
- .toolCall=${this.toolCall}
- >
+ 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:
@@ -916,10 +674,8 @@
</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>`
- : ""}`
+ ${this.toolCall?.result_message?.tool_result
+ ? html`<pre>${this.toolCall?.result_message.tool_result}</pre>`
: ""}
</div>
</sketch-tool-card>`;