Collapse tool calls progressively
This commit is contained in:
1 parent
e0968fe71d
commit
2689902fa6
2 files changed
+280
-9
No files matched your search
+108
@@ -270,6 +270,114 @@ textarea {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.progressive-tool-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressive-tool-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progressive-tool-header:hover {
|
||||
color: var(--foreground);
|
||||
background: color-mix(in oklab, var(--accent) 35%, transparent);
|
||||
}
|
||||
|
||||
.progressive-tool-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progressive-tool-title span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progressive-tool-call-summary {
|
||||
min-width: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 400;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.progressive-tool-call-summary::before {
|
||||
content: "—";
|
||||
margin-right: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.progressive-tool-state {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.progressive-tool-status-dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.progressive-tool-card.running .progressive-tool-status-dot,
|
||||
.progressive-tool-card.waiting .progressive-tool-status-dot {
|
||||
background: var(--primary, #2563eb);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.progressive-tool-card.complete .progressive-tool-status-dot {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.progressive-tool-card.error .progressive-tool-status-dot {
|
||||
background: var(--destructive, #dc2626);
|
||||
}
|
||||
|
||||
.progressive-tool-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding: 0 0.7rem 0.7rem;
|
||||
}
|
||||
|
||||
.progressive-tool-section-label,
|
||||
.progressive-tool-input-details summary {
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progressive-tool-input-details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.45; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.model-option-current {
|
||||
background: color-mix(in oklab, var(--accent) 50%, transparent);
|
||||
}
|
||||
|
||||
+172
-9
@@ -28,6 +28,169 @@ import type {
|
||||
SessionListItem,
|
||||
} from "../shared/protocol.js";
|
||||
|
||||
function toolResultText(result: ToolResultMessage | undefined): string {
|
||||
return result?.content
|
||||
?.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
||||
.map((part: any) => part.text)
|
||||
.join("\n") || "";
|
||||
}
|
||||
|
||||
function prettyToolValue(value: unknown): { code: string; language: string } {
|
||||
try {
|
||||
if (typeof value === "string") return { code: JSON.stringify(JSON.parse(value), null, 2), language: "json" };
|
||||
return { code: JSON.stringify(value ?? {}, null, 2), language: "json" };
|
||||
} catch {
|
||||
return { code: typeof value === "string" ? value : String(value ?? ""), language: "text" };
|
||||
}
|
||||
}
|
||||
|
||||
const toolCollapseOverrides = new Map<string, "full" | "collapsed">();
|
||||
const autoCollapsedToolIds = new Set<string>();
|
||||
let activeToolCallId = "";
|
||||
|
||||
type ToolProgressiveMode = "input" | "result" | "full" | "collapsed";
|
||||
|
||||
function toolArgsObject(args: any): Record<string, any> {
|
||||
if (!args) return {};
|
||||
if (typeof args === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return typeof args === "object" && !Array.isArray(args) ? args : {};
|
||||
}
|
||||
|
||||
function truncateInline(text: string, max = 120): string {
|
||||
const compact = text.replace(/\s+/g, " ").trim();
|
||||
return compact.length > max ? `${compact.slice(0, max - 1)}…` : compact;
|
||||
}
|
||||
|
||||
function summarizeToolCall(toolName: string, args: any): string {
|
||||
const p = toolArgsObject(args);
|
||||
if (toolName === "bash") return truncateInline(String(p.command || ""), 160);
|
||||
if (["read", "write", "edit"].includes(toolName)) return truncateInline(String(p.path || ""), 140);
|
||||
if (typeof p.query === "string" || typeof p.q === "string") return truncateInline(String(p.query || p.q), 140);
|
||||
return "";
|
||||
}
|
||||
|
||||
function firstCount(value: any): number | undefined {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
if (Array.isArray(value)) return value.length;
|
||||
for (const key of ["items", "rows", "results", "entries"]) {
|
||||
if (Array.isArray(value[key])) return value[key].length;
|
||||
}
|
||||
for (const child of Object.values(value)) {
|
||||
const count = firstCount(child);
|
||||
if (count !== undefined) return count;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function summarizeToolResult(result: ToolResultMessage | undefined): string {
|
||||
if (!result) return "";
|
||||
const text = toolResultText(result);
|
||||
let parsed: any;
|
||||
try { parsed = text ? JSON.parse(text) : result.details; } catch { parsed = result.details; }
|
||||
const count = firstCount(parsed);
|
||||
const parts = [];
|
||||
if (result.isError) parts.push("error");
|
||||
if (typeof count === "number") parts.push(`${count} item${count === 1 ? "" : "s"}`);
|
||||
if (parsed?.page?.has_more) parts.push("more available");
|
||||
if (parsed?.truncated || parsed?.page?.truncated) parts.push("truncated");
|
||||
if (parts.length > 0) return parts.join(" · ");
|
||||
return text ? `${text.length.toLocaleString()} chars` : "no output";
|
||||
}
|
||||
|
||||
function allVisibleToolCallIds(): string[] {
|
||||
const ids: string[] = [];
|
||||
const scanMessage = (message: any) => {
|
||||
if (message?.role !== "assistant" || !Array.isArray(message.content)) return;
|
||||
for (const part of message.content) {
|
||||
if (part?.type === "toolCall" && typeof part.id === "string") ids.push(part.id);
|
||||
}
|
||||
};
|
||||
for (const message of messages) scanMessage(message);
|
||||
scanMessage(streamingMessage);
|
||||
return ids;
|
||||
}
|
||||
|
||||
function progressiveToolMode(toolCallId: string, hasResult: boolean): ToolProgressiveMode {
|
||||
const override = toolCollapseOverrides.get(toolCallId);
|
||||
if (override) return override;
|
||||
if (autoCollapsedToolIds.has(toolCallId)) return "collapsed";
|
||||
if (!hasResult) return "input";
|
||||
const order = allVisibleToolCallIds();
|
||||
const index = order.indexOf(toolCallId);
|
||||
return index >= 0 && index < order.length - 1 ? "collapsed" : "result";
|
||||
}
|
||||
|
||||
function collapsePreviousToolCallsFor(newToolCallId: string) {
|
||||
if (!newToolCallId || activeToolCallId === newToolCallId) return;
|
||||
for (const id of allVisibleToolCallIds()) {
|
||||
if (id !== newToolCallId && !toolCollapseOverrides.has(id)) autoCollapsedToolIds.add(id);
|
||||
}
|
||||
activeToolCallId = newToolCallId;
|
||||
autoCollapsedToolIds.delete(newToolCallId);
|
||||
}
|
||||
|
||||
function renderProgressiveToolMessage(toolCall: any, result: ToolResultMessage | undefined, pending: boolean, aborted: boolean, isStreaming: boolean, host: any) {
|
||||
const effectiveResult = aborted ? ({ isError: true, content: [], toolCallId: toolCall.id, toolName: toolCall.name, timestamp: Date.now() } as any) : result;
|
||||
const mode = progressiveToolMode(toolCall.id, Boolean(effectiveResult));
|
||||
const args = toolArgsObject(toolCall.arguments);
|
||||
const commandInput = toolCall.name === "bash" && typeof args.command === "string" ? { code: args.command, language: "bash" } : undefined;
|
||||
const input = commandInput || prettyToolValue(toolCall.arguments);
|
||||
const outputText = toolResultText(effectiveResult) || (effectiveResult ? "(no output)" : "");
|
||||
const output = prettyToolValue(outputText);
|
||||
const status = effectiveResult ? (effectiveResult.isError ? "error" : "complete") : pending || isStreaming ? "running" : "waiting";
|
||||
const callSummary = summarizeToolCall(toolCall.name, toolCall.arguments);
|
||||
const resultSummary = summarizeToolResult(effectiveResult);
|
||||
const toggle = (event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toolCollapseOverrides.set(toolCall.id, mode === "collapsed" ? "full" : "collapsed");
|
||||
host.requestUpdate();
|
||||
};
|
||||
const showInput = mode === "input" || mode === "full";
|
||||
const showResult = Boolean(effectiveResult) && (mode === "result" || mode === "full");
|
||||
|
||||
return html`
|
||||
<div class="progressive-tool-card ${mode} ${status}">
|
||||
<button class="progressive-tool-header" type="button" @click=${toggle} aria-expanded=${mode !== "collapsed"}>
|
||||
<span class="progressive-tool-title">
|
||||
<span class="progressive-tool-status-dot"></span>
|
||||
<span>${toolCall.name}</span>
|
||||
${callSummary ? html`<span class="progressive-tool-call-summary">${callSummary}</span>` : nothing}
|
||||
</span>
|
||||
<span class="progressive-tool-state">${resultSummary || (mode === "collapsed" ? "collapsed" : status)}</span>
|
||||
</button>
|
||||
${mode === "collapsed" ? nothing : html`
|
||||
<div class="progressive-tool-body">
|
||||
${showInput ? html`
|
||||
<div class="progressive-tool-section">
|
||||
<div class="progressive-tool-section-label">Input</div>
|
||||
<code-block .code=${input.code} language=${input.language}></code-block>
|
||||
</div>
|
||||
` : effectiveResult ? html`
|
||||
<details class="progressive-tool-input-details">
|
||||
<summary>Input</summary>
|
||||
<code-block .code=${input.code} language=${input.language}></code-block>
|
||||
</details>
|
||||
` : nothing}
|
||||
${showResult ? html`
|
||||
<div class="progressive-tool-section">
|
||||
<div class="progressive-tool-section-label">Result</div>
|
||||
<code-block .code=${output.code} language=${output.language}></code-block>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatTokensPerSecond(message: any): string | undefined {
|
||||
const outputTokens = message?.usage?.output;
|
||||
const startedAt = message?.timestamp;
|
||||
@@ -61,19 +224,11 @@ function installAssistantMetadataRenderer() {
|
||||
} else if (chunk.type === "thinking" && chunk.thinking.trim() !== "") {
|
||||
orderedParts.push(html`<thinking-block .content=${chunk.thinking} .isStreaming=${this.isStreaming}></thinking-block>`);
|
||||
} else if (chunk.type === "toolCall" && !this.hideToolCalls) {
|
||||
const tool = this.tools?.find((t: any) => t.name === chunk.name);
|
||||
const pending = this.pendingToolCalls?.has(chunk.id) ?? false;
|
||||
const result = this.toolResultsById?.get(chunk.id);
|
||||
if (this.hidePendingToolCalls && pending && !result) continue;
|
||||
const aborted = this.message.stopReason === "aborted" && !result;
|
||||
orderedParts.push(html`<tool-message
|
||||
.tool=${tool}
|
||||
.toolCall=${chunk}
|
||||
.result=${result}
|
||||
.pending=${pending}
|
||||
.aborted=${aborted}
|
||||
.isStreaming=${this.isStreaming}
|
||||
></tool-message>`);
|
||||
orderedParts.push(renderProgressiveToolMessage(chunk, result, pending, aborted, this.isStreaming, this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +443,11 @@ function applyStateSync(state: SerializedAgentState) {
|
||||
toolNames = state.tools;
|
||||
if (state.model) currentModel = state.model;
|
||||
if (state.errorMessage) errorMessage = state.errorMessage;
|
||||
if (currentSessionId && currentSessionId !== state.sessionId) {
|
||||
toolCollapseOverrides.clear();
|
||||
autoCollapsedToolIds.clear();
|
||||
activeToolCallId = "";
|
||||
}
|
||||
currentSessionId = state.sessionId;
|
||||
currentSessionName = state.sessionName;
|
||||
currentSessionPath = state.sessionPath;
|
||||
@@ -355,6 +515,9 @@ function handleAgentEvent(event: any) {
|
||||
break;
|
||||
|
||||
case "tool_execution_start":
|
||||
if (typeof event.toolCallId === "string") collapsePreviousToolCallsFor(event.toolCallId);
|
||||
renderApp();
|
||||
break;
|
||||
case "tool_execution_update":
|
||||
case "tool_execution_end":
|
||||
renderApp();
|
||||
|
||||
Reference in new issue
Block a user