summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/chat/logic/tool-summary.test.ts81
-rw-r--r--src/features/chat/logic/tool-summary.ts89
-rw-r--r--src/features/chat/ui.test.ts10
-rw-r--r--src/features/chat/ui/ChatView.test.ts66
-rw-r--r--src/features/chat/ui/ToolCard.svelte27
5 files changed, 226 insertions, 47 deletions
diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts
index 876ede2..31c7002 100644
--- a/src/features/chat/logic/tool-summary.test.ts
+++ b/src/features/chat/logic/tool-summary.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ToolEntry } from "../../../core/chunks";
-import { viewToolEntry } from "./tool-summary";
+import { toolLabel, viewToolEntry } from "./tool-summary";
function entry(
name: string,
@@ -23,6 +23,44 @@ const view = (name: string, input: unknown, content: string | null = "ok", isErr
viewToolEntry(entry(name, input, content, isError));
const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`);
+describe("tool labels", () => {
+ it.each([
+ ["run_shell", "Run command"],
+ ["read_file", "Read file"],
+ ["edit_file", "Edit file"],
+ ["write_file", "Write file"],
+ ["web_search", "Web search"],
+ ["lsp", "Code intelligence"],
+ ["todo_write", "Update to-dos"],
+ ["load_skill", "Load skill"],
+ ["consult_vision", "Ask vision model"],
+ ["read_image", "Read image"],
+ ["youtube_transcript", "YouTube transcript"],
+ ["summon_agent", "Summon agent"],
+ ["send_agent_message", "Message agent"],
+ ["read_agent", "Read agent reply"],
+ ["open_agent", "Open agent"],
+ ["cancel_agent_message", "Cancel agent message"],
+ ["list_agents", "List agents"],
+ ["list_agent_models", "List agent models"],
+ ])("built-in %s → %s", (name, label) => {
+ expect(toolLabel(name)).toBe(label);
+ expect(view(name, {}).label).toBe(label);
+ expect(view(name, {}).name).toBe(name);
+ });
+ it.each([
+ ["github__create_issue", "Github: Create issue"],
+ ["mcp__github__create_issue", "Github: Create issue"],
+ ["playwright__browserTakeScreenshot", "Playwright: Browser take screenshot"],
+ ["scaffold_echo", "Scaffold echo"],
+ ["fetchUrl", "Fetch url"],
+ ["some-kebab-tool", "Some kebab tool"],
+ ["toString", "To string"],
+ ])("unknown %s is humanized → %s", (name, label) => {
+ expect(toolLabel(name)).toBe(label);
+ });
+});
+
describe("tool summaries", () => {
it.each([
["run_shell", { command: "bun run test\n --run" }, "bun run test --run"],
@@ -156,20 +194,18 @@ describe("tool summaries", () => {
])("generic fallback recognizes %s", (key) => {
expect(view("mcp__foo", { [key]: "target" }).callSummary).toBe("target");
});
- it("generic fallback prefers the first meaningful argument, else compact JSON", () => {
+ it("generic fallback prefers the first meaningful argument, else readable key: value pairs", () => {
expect(view("mcp__foo", { command: " ", path: "a", query: "b" }).callSummary).toBe("a");
- expect(view("mcp__foo", { a: 1, b: "x" }).callSummary).toBe('{"a":1,"b":"x"}');
+ expect(view("mcp__foo", { a: 1, b: "x" }).callSummary).toBe("a: 1 · b: x");
+ // Only nested values → no summary rather than a JSON dump.
+ expect(view("mcp__foo", { opts: { deep: true } }).callSummary).toBe("");
});
it.each([
"not JSON",
"{bad",
- null,
- undefined,
42,
true,
[1, "a"],
- {},
- "null",
'"hi"',
])("tolerates malformed/non-object input %j", (input) => {
expect(() => view("read_file", input)).not.toThrow();
@@ -177,6 +213,20 @@ describe("tool summaries", () => {
expect(view("read_file", input).callDetail.kind).toBe("fields");
});
it.each([
+ null,
+ undefined,
+ {},
+ [],
+ "",
+ " ",
+ "null",
+ "{}",
+ ])("an argument-less call (%j) shows no summary and a 'no arguments' detail — never `{}`", (input) => {
+ const card = view("list_agent_models", input);
+ expect(card.callSummary).toBe("");
+ expect(card.callDetail).toEqual({ kind: "none" });
+ });
+ it.each([
"run_shell",
"read_file",
"edit_file",
@@ -190,10 +240,12 @@ describe("tool summaries", () => {
"summon_agent",
"read_agent",
])("%s falls back safely for missing arguments", (name) => {
- for (const input of [null, undefined, {}, "{bad"]) {
+ for (const input of [null, undefined, {}]) {
expect(() => view(name, input)).not.toThrow();
- expect(view(name, input).callSummary.trim()).not.toBe("");
+ expect(view(name, input).callSummary).toBe("");
+ expect(view(name, input).callDetail).toEqual({ kind: "none" });
}
+ expect(view(name, "{bad").callSummary).toBe("{bad");
});
it("parses JSON-string inputs", () => {
const card = view("run_shell", '{"command":"bun run test"}');
@@ -201,10 +253,11 @@ describe("tool summaries", () => {
expect(card.callDetail).toEqual({ kind: "command", text: "bun run test" });
});
it("handles unexpected built-in argument types and prototype-looking tool names", () => {
- expect(view("todo_write", { todos: "wrong" }).callSummary).toBe('{"todos":"wrong"}');
+ expect(view("todo_write", { todos: "wrong" }).callSummary).toBe("todos: wrong");
expect(view("run_shell", { command: { nested: true } }).callDetail.kind).toBe("fields");
expect(view("constructor", { path: "a" }).callSummary).toBe("a");
- expect(view("__proto__", {}).callSummary).toBe("{}");
+ expect(view("__proto__", {}).callSummary).toBe("");
+ expect(view("__proto__", {}).label).toBe("Proto");
expect(
view("mcp", JSON.parse('{"__proto__":{"command":"bad"},"path":"good"}')).callSummary,
).toBe("good");
@@ -212,7 +265,11 @@ describe("tool summaries", () => {
it("does not throw on unserializable input", () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
- expect(view("mcp", cyclic).callSummary).toBe("[unserializable input]");
+ expect(view("mcp", cyclic).callSummary).toBe("");
+ expect(view("mcp", cyclic).callDetail).toEqual({
+ kind: "fields",
+ fields: [{ key: "self", value: "[unserializable input]", multiline: false }],
+ });
expect(() => view("mcp", { integer: 1n })).not.toThrow();
});
it("caps and whitespace-collapses header strings but retains full detail", () => {
diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts
index 3ea4a8e..75682de 100644
--- a/src/features/chat/logic/tool-summary.ts
+++ b/src/features/chat/logic/tool-summary.ts
@@ -1,7 +1,10 @@
import type { ToolEntry } from "../../../core/chunks";
export interface ToolCardView {
+ /** The raw wire tool name (tooltip / debugging). */
readonly name: string;
+ /** English display name, e.g. `run_shell` → "Run command" (see {@link toolLabel}). */
+ readonly label: string;
readonly status: "pending" | "ok" | "error";
readonly callSummary: string;
readonly callBadges: readonly {
@@ -12,6 +15,8 @@ export interface ToolCardView {
readonly compactResultSummary: string | null;
readonly callDetail:
| { readonly kind: "unavailable"; readonly text: string }
+ /** The call took no arguments (`{}` / absent input) — rendered as a muted note. */
+ | { readonly kind: "none" }
| { readonly kind: "command"; readonly text: string }
| {
readonly kind: "edit";
@@ -130,6 +135,77 @@ function todoSummary(args: Args): string {
const summarizeAgent: CallSummary = (args) => arg(args, "title", "task", "agent", "model");
const summarizeConversation: CallSummary = (args) => arg(args, "conversationId", "message");
+/** English names for Dispatch's built-in tools; anything else goes through {@link humanize}. */
+const TOOL_LABELS: Readonly<Record<string, string>> = {
+ run_shell: "Run command",
+ read_file: "Read file",
+ edit_file: "Edit file",
+ write_file: "Write file",
+ web_search: "Web search",
+ lsp: "Code intelligence",
+ todo_write: "Update to-dos",
+ load_skill: "Load skill",
+ consult_vision: "Ask vision model",
+ read_image: "Read image",
+ youtube_transcript: "YouTube transcript",
+ summon_agent: "Summon agent",
+ send_agent_message: "Message agent",
+ read_agent: "Read agent reply",
+ open_agent: "Open agent",
+ cancel_agent_message: "Cancel agent message",
+ list_agents: "List agents",
+ list_agent_models: "List agent models",
+};
+
+/** `create_issue` / `createIssue` / `create-issue` → "Create issue". */
+function sentenceCase(raw: string): string {
+ const words = raw
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+ .split(/[\s_\-.]+/)
+ .filter((w) => w !== "")
+ .map((w) => w.toLowerCase());
+ if (words.length === 0) return raw;
+ const text = words.join(" ");
+ return text.charAt(0).toUpperCase() + text.slice(1);
+}
+
+/**
+ * Generic English name for an unknown tool. MCP tools arrive namespaced as
+ * `<serverId>__<toolName>` (backend `mcp/registry.ts`), shown as "Server: Tool name".
+ */
+function humanize(name: string): string {
+ const parts = name.split("__").filter((p) => p !== "");
+ if (parts[0]?.toLowerCase() === "mcp" && parts.length > 1) parts.shift();
+ if (parts.length >= 2) {
+ const server = sentenceCase(parts[0] ?? "");
+ return `${server}: ${sentenceCase(parts.slice(1).join(" "))}`;
+ }
+ return sentenceCase(name);
+}
+
+/** The English display name for a tool (built-in table, else humanized). */
+export function toolLabel(name: string): string {
+ return Object.hasOwn(TOOL_LABELS, name) ? (TOOL_LABELS[name] ?? name) : humanize(name);
+}
+
+/** True for input that carries no arguments at all (absent, `null`, `{}`, `[]`, blank string). */
+function isEmptyInput(input: unknown): boolean {
+ if (input === undefined || input === null) return true;
+ if (typeof input === "string") return input.trim() === "";
+ if (Array.isArray(input)) return input.length === 0;
+ return typeof input === "object" && Object.keys(input).length === 0;
+}
+
+/** Fallback summary: scalar args as `key: value` (never raw JSON braces). */
+function scalarPairs(args: Args): string {
+ return Object.entries(args)
+ .filter(
+ ([, v]) => ["string", "number", "boolean"].includes(typeof v) && String(v).trim() !== "",
+ )
+ .map(([k, v]) => `${k}: ${String(v)}`)
+ .join(" · ");
+}
+
/** Optional built-in polish; all unknown/MCP names use the same generic path. */
const summaries: Readonly<Record<string, CallSummary>> = {
run_shell: (args) => arg(args, "command"),
@@ -163,6 +239,7 @@ function detail(name: string, input: unknown, args: Args): ToolCardView["callDet
newText: typeof args.newString === "string" ? args.newString : "",
};
}
+ if (isEmptyInput(input)) return { kind: "none" };
const entries = Object.entries(args);
return {
kind: "fields",
@@ -249,7 +326,16 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView {
const callSummary =
entry.call === null
? "Call in earlier history"
- : oneLine(builtInSummary || generic || serialize(input));
+ : isEmptyInput(input)
+ ? ""
+ : oneLine(
+ builtInSummary ||
+ generic ||
+ scalarPairs(args) ||
+ // Non-object input (e.g. a bare string); objects with only nested
+ // values stay unsummarized rather than dumping JSON braces.
+ (Object.keys(args).length === 0 ? serialize(input) : ""),
+ );
const callBadges: ToolCardView["callBadges"][number][] = [];
if (entry.toolName === "edit_file" && entry.call !== null && !entry.result?.isError) {
if (typeof args.oldString === "string" && typeof args.newString === "string") {
@@ -265,6 +351,7 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView {
result === null ? null : responseSummary(entry.toolName, result.content, result.isError);
return {
name: entry.toolName,
+ label: toolLabel(entry.toolName),
status: result === null ? "pending" : result.isError ? "error" : "ok",
callSummary,
callBadges,
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index c3a76d4..6a00e49 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -108,7 +108,7 @@ describe("ChatView", () => {
const { container } = render(ChatView, { props: { chunks } });
await openAllCollapses(container);
- expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("Read file")).toBeInTheDocument();
const pre = screen.getByText((content, element) => {
return element?.tagName === "PRE" && content.includes("/tmp/test.txt");
});
@@ -134,7 +134,7 @@ describe("ChatView", () => {
const { container } = render(ChatView, { props: { chunks } });
await openAllCollapses(container);
- expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("Read file")).toBeInTheDocument();
expect(screen.getByText("file contents here")).toBeInTheDocument();
});
@@ -262,8 +262,8 @@ describe("ChatView", () => {
// Both call names + the available result are shown; the result is absorbed
// (no standalone tool-result card).
- expect(screen.getByText("read_file")).toBeInTheDocument();
- expect(screen.getByText("list_dir")).toBeInTheDocument();
+ expect(screen.getByText("Read file")).toBeInTheDocument();
+ expect(screen.getByText("List dir")).toBeInTheDocument();
expect(screen.getByText("contents-of-a")).toBeInTheDocument();
});
@@ -781,7 +781,7 @@ describe("ChatView", () => {
const { container } = render(ChatView, { props: { chunks } });
await openAllCollapses(container);
- expect(screen.getAllByText("consult_vision").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Ask vision model").length).toBeGreaterThan(0);
expect(screen.getByText("a red square on a white background")).toBeInTheDocument();
});
diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts
index 9ac4986..4b28b48 100644
--- a/src/features/chat/ui/ChatView.test.ts
+++ b/src/features/chat/ui/ChatView.test.ts
@@ -135,8 +135,8 @@ describe("ChatView — duplicate toolCallIds", () => {
expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2);
// Both unique tool names shown; each result rendered once (deduped).
- expect(screen.getByText("read_file")).toBeInTheDocument();
- expect(screen.getByText("list_dir")).toBeInTheDocument();
+ expect(screen.getByText("Read file")).toBeInTheDocument();
+ expect(screen.getByText("List dir")).toBeInTheDocument();
expect(screen.getByText("contents-of-a")).toBeInTheDocument();
expect(screen.getByText("contents-of-b")).toBeInTheDocument();
});
@@ -159,10 +159,11 @@ describe("ChatView — lazy collapse bodies", () => {
},
];
const { container } = render(ChatView, { props: { chunks } });
- expect(screen.getByText("run_shell")).toBeInTheDocument();
+ // English display name; the raw wire name stays available as the tooltip.
+ expect(screen.getByText("Run command")).toHaveAttribute("title", "run_shell");
expect(screen.queryByText("HUGE-OUTPUT")).toBeNull();
- const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
await userEvent.click(box);
expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument();
@@ -211,7 +212,7 @@ describe("ChatView — tool cards", () => {
expect(screen.getByText("bun run test")).toBeInTheDocument();
expect(screen.getByText("→ 2 lines")).toBeInTheDocument();
expect(screen.getByLabelText("Succeeded")).toHaveTextContent("✓");
- const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
expect(box).not.toBeChecked();
expect(container.querySelector("pre")).toBeNull();
await userEvent.click(box);
@@ -221,19 +222,19 @@ describe("ChatView — tool cards", () => {
});
it("transitions pending → result → sealed without losing open state", async () => {
const { rerender } = render(ChatView, { props: { chunks: shellChunks(null) } });
- expect(screen.getByLabelText("Running")).toHaveClass("loading-spinner");
+ expect(screen.getByLabelText("Running")).toHaveClass("loading-dots", "loading-sm");
expect(screen.getByText("→ running…")).toHaveClass("tool-result", "text-base-content/60");
- const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
await userEvent.click(box);
expect(screen.getByText("running…")).toBeInTheDocument();
await rerender({ chunks: shellChunks("done") });
expect(screen.queryByLabelText("Running")).toBeNull();
expect(screen.queryByText("→ running…")).toBeNull();
expect(screen.getByLabelText("Succeeded")).toBeInTheDocument();
- expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box);
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box);
expect(box).toBeChecked();
await rerender({ chunks: shellChunks("done", false, true) });
- expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box);
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box);
expect(box).toBeChecked();
expect(screen.getByText("done")).toBeInTheDocument();
});
@@ -241,7 +242,7 @@ describe("ChatView — tool cards", () => {
const { container } = render(ChatView, {
props: { chunks: shellChunks("Permission denied", true) },
});
- const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
expect(box).not.toBeChecked();
expect(screen.getByLabelText("Failed")).toHaveClass("text-error");
expect(screen.getByLabelText("Failed")).toHaveTextContent("✗");
@@ -257,7 +258,7 @@ describe("ChatView — tool cards", () => {
expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1);
expect(screen.getByText("Call in earlier history")).toHaveClass("earlier-history");
expect(screen.getByLabelText("Succeeded")).toHaveClass("opacity-60");
- const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
await userEvent.click(box);
expect(screen.getByRole("note")).toHaveTextContent(
"This call is earlier in the conversation. Show earlier messages to see it.",
@@ -266,14 +267,14 @@ describe("ChatView — tool cards", () => {
expect(screen.queryByRole("heading", { name: "Call" })).toBeNull();
expect(screen.getByText("contents")).toBeInTheDocument();
await rerender({ chunks });
- expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box);
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box);
expect(box).toBeChecked();
expect(screen.getByText("$ bun run test")).toBeInTheDocument();
});
it("folds 13 output lines as 6 + toggle + 6, expands and collapses without closing the card", async () => {
const output = Array.from({ length: 13 }, (_, i) => `output-${i + 1}`).join("\n");
const { rerender } = render(ChatView, { props: { chunks: shellChunks(output) } });
- const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
await userEvent.click(box);
const response = screen.getByRole("region", { name: "Response" });
expect(response).toHaveTextContent("output-1");
@@ -315,7 +316,7 @@ describe("ChatView — tool cards", () => {
it("shows 12 output lines without an inner toggle", async () => {
const output = Array.from({ length: 12 }, (_, i) => `output-${i + 1}`).join("\n");
render(ChatView, { props: { chunks: shellChunks(output) } });
- await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" }));
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Run command" }));
expect(screen.queryByRole("button", { name: /more lines|fewer lines/ })).toBeNull();
expect(screen.getByRole("region", { name: "Response" })).toHaveTextContent("output-12");
});
@@ -349,7 +350,7 @@ describe("ChatView — tool cards", () => {
expect(screen.getByText("+2")).toHaveClass("text-success");
expect(screen.getByText("−1")).toHaveClass("text-error");
expect(screen.getByText("→ 1 replacement · diagnostics")).toBeInTheDocument();
- await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call edit_file" }));
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Edit file" }));
// Unified diff: one tinted row per line, each with its own −/+ gutter marker.
const diff = screen.getByRole("group", { name: "Edit diff" });
const removed = diff.querySelectorAll(".edit-diff-del");
@@ -383,7 +384,7 @@ it("renders compact, aligned generic fields with legible headings and inline sca
];
const { container } = render(ChatView, { props: { chunks } });
await userEvent.click(
- screen.getByRole("checkbox", { name: "Toggle tool call mcp__github__create_issue" }),
+ screen.getByRole("checkbox", { name: "Toggle tool call Github: Create issue" }),
);
for (const name of ["Call", "Response"]) {
expect(screen.getByRole("heading", { name })).toHaveClass("font-semibold", "text-base-content");
@@ -427,7 +428,7 @@ it("removes a pending edit's diffstat when it fails, without hiding the attempte
expect(screen.getByText("+1")).toBeInTheDocument();
expect(screen.getByText("−1")).toBeInTheDocument();
expect(screen.getByText("→ running…")).toBeInTheDocument();
- await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call edit_file" }));
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Edit file" }));
await rerender({ chunks: [call, result] });
expect(screen.queryByText("+1")).toBeNull();
expect(screen.queryByText("−1")).toBeNull();
@@ -438,3 +439,34 @@ it("removes a pending edit's diffstat when it fails, without hiding the attempte
expect(screen.getByText("bar")).toBeInTheDocument();
expect(screen.getByText("Error: oldString not found")).toBeInTheDocument();
});
+
+it("shows an argument-less call without `{}` — empty header summary, 'No arguments' in the body", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: { type: "tool-call", toolCallId: "m", toolName: "list_agent_models", input: {} },
+ },
+ {
+ seq: 2,
+ role: "tool",
+ provisional: false,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "m",
+ toolName: "list_agent_models",
+ content: "Available named agents (3)",
+ isError: false,
+ },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ expect(screen.getByText("List agent models")).toBeInTheDocument();
+ expect(container).not.toHaveTextContent("{}");
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: "Toggle tool call List agent models" }),
+ );
+ expect(screen.getByText("No arguments")).toBeInTheDocument();
+ expect(container).not.toHaveTextContent("{}");
+});
diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte
index 75c562d..c3da723 100644
--- a/src/features/chat/ui/ToolCard.svelte
+++ b/src/features/chat/ui/ToolCard.svelte
@@ -9,16 +9,18 @@
</script>
<div class="tool-card">
- <LazyCollapse label={`Toggle tool call ${card.name}`} titleClass="pe-4">
+ <LazyCollapse label={`Toggle tool call ${card.label}`} titleClass="pe-4">
{#snippet title()}
<div class="tool-header" class:has-earlier-call={card.callDetail.kind === "unavailable"}>
{#if card.status === "pending"}
- <span class="loading loading-spinner loading-xs shrink-0" aria-label="Running"></span>
+ <!-- Same in-progress animation as the streaming thinking block. All three
+ status icons share a fixed w-5 slot so the row never shifts on settle. -->
+ <span class="loading loading-dots loading-sm w-5 shrink-0" aria-label="Running"></span>
{:else if card.status === "error"}
- <span class="shrink-0 text-error" aria-label="Failed">✗</span>
+ <span class="w-5 shrink-0 text-center text-error" aria-label="Failed">✗</span>
{:else}
<span
- class="shrink-0 text-success"
+ class="w-5 shrink-0 text-center text-success"
class:opacity-60={card.callDetail.kind === "unavailable"}
aria-label="Succeeded">✓</span
>
@@ -38,9 +40,9 @@
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
- <span class="tool-name truncate" title={card.name}>{card.name}</span>
+ <span class="tool-name truncate" title={card.name}>{card.label}</span>
<span
- class="tool-summary truncate text-base-content/90"
+ class="tool-summary truncate font-normal text-base-content/60"
class:earlier-history={card.callDetail.kind === "unavailable"}
title={card.callDetail.kind === "unavailable" ? card.callDetail.text : card.callSummary}
>
@@ -86,7 +88,9 @@
Call
</h3>
{/if}
- {#if card.callDetail.kind === "command"}
+ {#if card.callDetail.kind === "none"}
+ <p class="italic text-base-content/60">No arguments</p>
+ {:else if card.callDetail.kind === "command"}
<pre class="overflow-x-auto">$ {card.callDetail.text}</pre>
{:else if card.callDetail.kind === "edit"}
<div class="mb-2 break-all font-mono">{card.callDetail.path}</div>
@@ -172,9 +176,11 @@
width: 100%;
min-width: 0;
}
+ /* English names are short phrases: keep them whole and let the call
+ summary absorb the squeeze. The cap only bites on long MCP names. */
.tool-name {
- flex: 0 1 auto;
- max-width: 30%;
+ flex: 0 0 auto;
+ max-width: 60%;
}
.tool-summary {
flex: 1;
@@ -257,9 +263,6 @@
.tool-badges {
display: none;
}
- .tool-name {
- max-width: 32%;
- }
.has-earlier-call .tool-result {
display: none;
}