summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--bun.lock3
-rw-r--r--package.json3
-rw-r--r--src/features/chat/logic/tool-summary.test.ts41
-rw-r--r--src/features/chat/logic/tool-summary.ts57
-rw-r--r--src/features/chat/ui/ChatView.test.ts27
-rw-r--r--src/features/chat/ui/ToolCard.svelte19
-rw-r--r--src/features/chat/ui/tool-icons.ts52
7 files changed, 185 insertions, 17 deletions
diff --git a/bun.lock b/bun.lock
index 7ee95b4..856bb73 100644
--- a/bun.lock
+++ b/bun.lock
@@ -15,6 +15,7 @@
"highlight.js": "^11.11.1",
"marked": "^18.0.4",
"marked-highlight": "^2.2.4",
+ "phosphor-svelte": "^3.1.0",
},
"devDependencies": {
"@biomejs/biome": "^2.4.16",
@@ -471,6 +472,8 @@
"pathval": ["[email protected]", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
+ "phosphor-svelte": ["[email protected]", "", { "dependencies": { "estree-walker": "^3.0.3", "magic-string": "^0.30.13" }, "peerDependencies": { "svelte": "^5.0.0 || ^5.0.0-next.96", "vite": ">=5" }, "optionalPeers": ["vite"] }, "sha512-M+CeN2s7YAFHWVxctLj41SyXsjWxoIoNi/rZ15XIYh9XEpbgJ2mE2hn54tA30l6WSagaBg2bA/OazlZJVDUkpQ=="],
+
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
diff --git a/package.json b/package.json
index dd730d9..de9c93d 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,8 @@
"gsap": "^3.15.0",
"highlight.js": "^11.11.1",
"marked": "^18.0.4",
- "marked-highlight": "^2.2.4"
+ "marked-highlight": "^2.2.4",
+ "phosphor-svelte": "^3.1.0"
},
"overrides": {
"@dispatch/ui-contract": "file:../backend/packages/ui-contract",
diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts
index 31c7002..b6c7e0b 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 { toolLabel, viewToolEntry } from "./tool-summary";
+import { toolIcon, toolLabel, viewToolEntry } from "./tool-summary";
function entry(
name: string,
@@ -61,6 +61,45 @@ describe("tool labels", () => {
});
});
+describe("tool icons", () => {
+ const builtIns = [
+ "run_shell",
+ "read_file",
+ "edit_file",
+ "write_file",
+ "web_search",
+ "lsp",
+ "todo_write",
+ "load_skill",
+ "consult_vision",
+ "read_image",
+ "youtube_transcript",
+ "summon_agent",
+ "send_agent_message",
+ "read_agent",
+ "open_agent",
+ "cancel_agent_message",
+ "list_agents",
+ "list_agent_models",
+ ];
+ it("gives every built-in tool its own distinct icon", () => {
+ const icons = builtIns.map(toolIcon);
+ expect(icons).not.toContain("tool");
+ expect(icons).not.toContain("mcp");
+ expect(new Set(icons).size).toBe(builtIns.length);
+ expect(view("run_shell", {}).icon).toBe("terminal");
+ });
+ it.each([
+ ["github__create_issue", "mcp"],
+ ["mcp__github__create_issue", "mcp"],
+ ["scaffold_echo", "tool"],
+ ["__proto__", "tool"],
+ ["constructor", "tool"],
+ ])("%s → %s", (name, icon) => {
+ expect(toolIcon(name)).toBe(icon);
+ });
+});
+
describe("tool summaries", () => {
it.each([
["run_shell", { command: "bun run test\n --run" }, "bun run test --run"],
diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts
index 75682de..566e40f 100644
--- a/src/features/chat/logic/tool-summary.ts
+++ b/src/features/chat/logic/tool-summary.ts
@@ -5,6 +5,8 @@ export interface ToolCardView {
readonly name: string;
/** English display name, e.g. `run_shell` → "Run command" (see {@link toolLabel}). */
readonly label: string;
+ /** Which icon the card shows — a semantic key; the UI maps it to a Phosphor icon. */
+ readonly icon: ToolIconKey;
readonly status: "pending" | "ok" | "error";
readonly callSummary: string;
readonly callBadges: readonly {
@@ -157,6 +159,60 @@ const TOOL_LABELS: Readonly<Record<string, string>> = {
list_agent_models: "List agent models",
};
+/**
+ * Semantic icon keys (Svelte-free; `ui/tool-icons.ts` maps each to a Phosphor
+ * component). Unknown tools get `mcp` when namespaced `<server>__<tool>`,
+ * else the generic `tool` wrench.
+ */
+export type ToolIconKey =
+ | "terminal"
+ | "file-read"
+ | "file-edit"
+ | "file-write"
+ | "search"
+ | "code"
+ | "todos"
+ | "skill"
+ | "vision"
+ | "image"
+ | "youtube"
+ | "agent-summon"
+ | "agent-send"
+ | "agent-read"
+ | "agent-open"
+ | "agent-cancel"
+ | "agents"
+ | "models"
+ | "mcp"
+ | "tool";
+
+const TOOL_ICONS: Readonly<Record<string, ToolIconKey>> = {
+ run_shell: "terminal",
+ read_file: "file-read",
+ edit_file: "file-edit",
+ write_file: "file-write",
+ web_search: "search",
+ lsp: "code",
+ todo_write: "todos",
+ load_skill: "skill",
+ consult_vision: "vision",
+ read_image: "image",
+ youtube_transcript: "youtube",
+ summon_agent: "agent-summon",
+ send_agent_message: "agent-send",
+ read_agent: "agent-read",
+ open_agent: "agent-open",
+ cancel_agent_message: "agent-cancel",
+ list_agents: "agents",
+ list_agent_models: "models",
+};
+
+/** The icon key for a tool (built-in table, else MCP plug / generic wrench). */
+export function toolIcon(name: string): ToolIconKey {
+ if (Object.hasOwn(TOOL_ICONS, name)) return TOOL_ICONS[name] ?? "tool";
+ return name.split("__").filter((p) => p !== "").length >= 2 ? "mcp" : "tool";
+}
+
/** `create_issue` / `createIssue` / `create-issue` → "Create issue". */
function sentenceCase(raw: string): string {
const words = raw
@@ -352,6 +408,7 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView {
return {
name: entry.toolName,
label: toolLabel(entry.toolName),
+ icon: toolIcon(entry.toolName),
status: result === null ? "pending" : result.isError ? "error" : "ok",
callSummary,
callBadges,
diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts
index 4b28b48..77f832d 100644
--- a/src/features/chat/ui/ChatView.test.ts
+++ b/src/features/chat/ui/ChatView.test.ts
@@ -470,3 +470,30 @@ it("shows an argument-less call without `{}` — empty header summary, 'No argum
expect(screen.getByText("No arguments")).toBeInTheDocument();
expect(container).not.toHaveTextContent("{}");
});
+
+it("renders a per-tool Phosphor icon in the header", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "s",
+ toolName: "run_shell",
+ input: { command: "ls" },
+ },
+ },
+ {
+ seq: 2,
+ role: "assistant",
+ provisional: false,
+ chunk: { type: "tool-call", toolCallId: "r", toolName: "read_file", input: { path: "a.ts" } },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ const icons = Array.from(container.querySelectorAll(".tool-header svg[aria-hidden='true']"));
+ expect(icons).toHaveLength(2);
+ // Different tools → different glyphs.
+ expect(icons[0]?.innerHTML).not.toBe(icons[1]?.innerHTML);
+});
diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte
index c3da723..0a72bb5 100644
--- a/src/features/chat/ui/ToolCard.svelte
+++ b/src/features/chat/ui/ToolCard.svelte
@@ -2,9 +2,11 @@
import type { ToolEntry } from "../index";
import { viewToolEntry } from "../logic/tool-summary";
import LazyCollapse from "./LazyCollapse.svelte";
+ import { TOOL_ICON_COMPONENTS } from "./tool-icons";
let { entry }: { entry: ToolEntry } = $props();
const card = $derived(viewToolEntry(entry));
+ const ToolIcon = $derived(TOOL_ICON_COMPONENTS[card.icon]);
let showAll = $state(false);
</script>
@@ -25,21 +27,8 @@
aria-label="Succeeded">✓</span
>
{/if}
- <svg
- class="h-4 w-4 shrink-0 opacity-60"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- aria-hidden="true"
- >
- <path
- 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>
+ <!-- Per-tool Phosphor icon (key chosen by the pure view-model). -->
+ <ToolIcon size={16} class="shrink-0 opacity-70" aria-hidden="true" />
<span class="tool-name truncate" title={card.name}>{card.label}</span>
<span
class="tool-summary truncate font-normal text-base-content/60"
diff --git a/src/features/chat/ui/tool-icons.ts b/src/features/chat/ui/tool-icons.ts
new file mode 100644
index 0000000..1d68ebe
--- /dev/null
+++ b/src/features/chat/ui/tool-icons.ts
@@ -0,0 +1,52 @@
+// Phosphor icon per tool-icon key (the pure view-model picks the KEY — see
+// `logic/tool-summary.ts` `toolIcon`; this is the only place that knows the
+// concrete components). Deep imports (`phosphor-svelte/lib/<Name>Icon`) on
+// purpose: the package root re-exports ~1,500 components, and Svelte libraries
+// are not prebundled in dev, so a root import would load every one of them.
+
+import ArrowSquareOutIcon from "phosphor-svelte/lib/ArrowSquareOutIcon";
+import BookOpenIcon from "phosphor-svelte/lib/BookOpenIcon";
+import BracketsCurlyIcon from "phosphor-svelte/lib/BracketsCurlyIcon";
+import ChatCircleTextIcon from "phosphor-svelte/lib/ChatCircleTextIcon";
+import CpuIcon from "phosphor-svelte/lib/CpuIcon";
+import EyeIcon from "phosphor-svelte/lib/EyeIcon";
+import FileTextIcon from "phosphor-svelte/lib/FileTextIcon";
+import FloppyDiskIcon from "phosphor-svelte/lib/FloppyDiskIcon";
+import ImageIcon from "phosphor-svelte/lib/ImageIcon";
+import ListChecksIcon from "phosphor-svelte/lib/ListChecksIcon";
+import MagnifyingGlassIcon from "phosphor-svelte/lib/MagnifyingGlassIcon";
+import PaperPlaneTiltIcon from "phosphor-svelte/lib/PaperPlaneTiltIcon";
+import PencilSimpleIcon from "phosphor-svelte/lib/PencilSimpleIcon";
+import ProhibitIcon from "phosphor-svelte/lib/ProhibitIcon";
+import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon";
+import RobotIcon from "phosphor-svelte/lib/RobotIcon";
+import TerminalWindowIcon from "phosphor-svelte/lib/TerminalWindowIcon";
+import UsersThreeIcon from "phosphor-svelte/lib/UsersThreeIcon";
+import WrenchIcon from "phosphor-svelte/lib/WrenchIcon";
+import YoutubeLogoIcon from "phosphor-svelte/lib/YoutubeLogoIcon";
+import type { Component } from "svelte";
+import type { ToolIconKey } from "../logic/tool-summary";
+
+// biome-ignore lint/suspicious/noExplicitAny: Phosphor's IconComponentProps is internal; any icon accepts size/class.
+export const TOOL_ICON_COMPONENTS: Readonly<Record<ToolIconKey, Component<any>>> = {
+ terminal: TerminalWindowIcon,
+ "file-read": FileTextIcon,
+ "file-edit": PencilSimpleIcon,
+ "file-write": FloppyDiskIcon,
+ search: MagnifyingGlassIcon,
+ code: BracketsCurlyIcon,
+ todos: ListChecksIcon,
+ skill: BookOpenIcon,
+ vision: EyeIcon,
+ image: ImageIcon,
+ youtube: YoutubeLogoIcon,
+ "agent-summon": RobotIcon,
+ "agent-send": PaperPlaneTiltIcon,
+ "agent-read": ChatCircleTextIcon,
+ "agent-open": ArrowSquareOutIcon,
+ "agent-cancel": ProhibitIcon,
+ agents: UsersThreeIcon,
+ models: CpuIcon,
+ mcp: PuzzlePieceIcon,
+ tool: WrenchIcon,
+};