From c60dc7c9839a9d9057815911dfff8e998fb54f7b Mon Sep 17 00:00:00 2001 From: Joe Shull <42043763+joeshull@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:29:11 -0600 Subject: [PATCH 1/2] feat: unwrap xd:// writes and add specable tool cards (#120) --- .../src/components/CollapsedToolGroup.tsx | 9 +- .../client/src/components/ToolBurstGroup.tsx | 14 +- .../client/src/components/ToolCallStep.tsx | 27 +- .../tool-renderers/AstToolRenderer.tsx | 39 +++ .../tool-renderers/BrowserToolRenderer.tsx | 43 ++++ .../tool-renderers/EvalToolRenderer.tsx | 60 +++++ .../tool-renderers/GithubToolRenderer.tsx | 36 +++ .../tool-renderers/GoalToolRenderer.tsx | 39 +++ .../tool-renderers/HubToolRenderer.tsx | 38 +++ .../tool-renderers/ImageToolRenderer.tsx | 33 +++ .../tool-renderers/KnowledgeToolRenderer.tsx | 33 +++ .../tool-renderers/LspToolRenderer.tsx | 40 +++ .../ResolveRejectToolRenderer.tsx | 32 +++ .../tool-renderers/SearchToolRenderer.tsx | 29 +++ .../tool-renderers/TaskToolRenderer.tsx | 37 +++ .../tool-renderers/ThinkToolRenderer.tsx | 27 ++ .../tool-renderers/TodoToolRenderer.tsx | 57 +++++ .../__tests__/SpecableToolRenderers.test.tsx | 238 ++++++++++++++++++ .../src/components/tool-renderers/index.ts | 16 +- .../src/components/tool-renderers/registry.ts | 51 +++- .../src/lib/__tests__/unwrap-xd.test.ts | 91 +++++++ packages/client/src/lib/tool-summary.ts | 55 ++++ packages/client/src/lib/unwrap-xd.ts | 68 +++++ 23 files changed, 1083 insertions(+), 29 deletions(-) create mode 100644 packages/client/src/components/tool-renderers/AstToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/BrowserToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/EvalToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/GithubToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/GoalToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/HubToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/ImageToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/KnowledgeToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/LspToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/ResolveRejectToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/SearchToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/TaskToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/ThinkToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/TodoToolRenderer.tsx create mode 100644 packages/client/src/components/tool-renderers/__tests__/SpecableToolRenderers.test.tsx create mode 100644 packages/client/src/lib/__tests__/unwrap-xd.test.ts create mode 100644 packages/client/src/lib/unwrap-xd.ts diff --git a/packages/client/src/components/CollapsedToolGroup.tsx b/packages/client/src/components/CollapsedToolGroup.tsx index 2b2b42a04..4236164fd 100644 --- a/packages/client/src/components/CollapsedToolGroup.tsx +++ b/packages/client/src/components/CollapsedToolGroup.tsx @@ -12,6 +12,7 @@ import { useDisplayPrefs } from "../hooks/useDisplayPrefs.js"; import { useMobile } from "../hooks/useMobile.js"; import type { ToolCallGroup } from "../lib/group-tool-calls.js"; import { getSummary } from "../lib/tool-summary.js"; +import { unwrapXdToolCall } from "../lib/unwrap-xd.js"; import { MarkdownContent } from "./MarkdownContent.js"; import { ToolCallStep } from "./ToolCallStep.js"; import type { ToolContext } from "./tool-renderers/index.js"; @@ -33,21 +34,21 @@ export function CollapsedToolGroup({ group, toolContext }: Props) { return key === null || prefs.toolCalls[key]; }); if (visibleMessages.length === 0) return null; - const lastMsg = group.messages[group.messages.length - 1]; - const firstArgs = group.messages[0]?.args; + const firstMsg = group.messages[0]; + const { effectiveToolName, effectiveArgs } = unwrapXdToolCall(group.toolName, firstMsg?.args, firstMsg?.toolDetails); return (
+ ); +} diff --git a/packages/client/src/components/tool-renderers/BrowserToolRenderer.tsx b/packages/client/src/components/tool-renderers/BrowserToolRenderer.tsx new file mode 100644 index 000000000..1e6e29a4b --- /dev/null +++ b/packages/client/src/components/tool-renderers/BrowserToolRenderer.tsx @@ -0,0 +1,43 @@ +import { ToolResultImages } from "./ToolResultImages.js"; +import type { ToolRendererProps } from "./types.js"; + +export function BrowserToolRenderer({ args, status, result, images }: ToolRendererProps) { + const action = (args?.action as string) || "browse"; + const url = (args?.url as string) || (args?.target as string); + const selector = args?.selector as string | undefined; + const text = args?.text as string | undefined; + + return ( +
+
+ + {action} + + {url && {url}} +
+ + {(selector || text) && ( +
+ {selector &&
Selector: {selector}
} + {text &&
Text: {text}
} +
+ )} + + {images && images.length > 0 && ( +
+ +
+ )} + + {status === "running" && !result && (!images || images.length === 0) && ( +
Browser active…
+ )} + + {result && ( +
+          {result}
+        
+ )} +
+ ); +} diff --git a/packages/client/src/components/tool-renderers/EvalToolRenderer.tsx b/packages/client/src/components/tool-renderers/EvalToolRenderer.tsx new file mode 100644 index 000000000..d0631e74c --- /dev/null +++ b/packages/client/src/components/tool-renderers/EvalToolRenderer.tsx @@ -0,0 +1,60 @@ +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { getSyntaxTheme } from "../../lib/syntax-theme.js"; +import { useThemeContext } from "../ThemeProvider.js"; +import type { ToolRendererProps } from "./types.js"; + +export function EvalToolRenderer({ args, status, result }: ToolRendererProps) { + let syntaxStyle; + try { + const { resolved: theme, themeName } = useThemeContext(); + syntaxStyle = getSyntaxTheme(theme, themeName); + } catch { + syntaxStyle = undefined; + } + + const language = (args?.language as string) || "py"; + const code = (args?.code as string) || (args?.raw as string); + const title = args?.title as string | undefined; + + return ( +
+
+ + {language} + + {title && {title}} +
+ + {code && ( +
+ {syntaxStyle ? ( + + {code} + + ) : ( +
{code}
+ )} +
+ )} + + {status === "running" && !result && ( +
Executing code…
+ )} + + {result && ( +
+
Output
+
+            {result}
+          
+
+ )} +
+ ); +} diff --git a/packages/client/src/components/tool-renderers/GithubToolRenderer.tsx b/packages/client/src/components/tool-renderers/GithubToolRenderer.tsx new file mode 100644 index 000000000..c2f650c44 --- /dev/null +++ b/packages/client/src/components/tool-renderers/GithubToolRenderer.tsx @@ -0,0 +1,36 @@ +import type { ToolRendererProps } from "./types.js"; + +export function GithubToolRenderer({ args, status, result }: ToolRendererProps) { + const op = (args?.op ?? args?.action ?? args?.command) as string | undefined; + const repo = args?.repo as string | undefined; + const issueOrPr = (args?.issue ?? args?.pr ?? args?.number) as string | number | undefined; + const title = (args?.title ?? args?.subject) as string | undefined; + + return ( +
+
+ + github {op ?? ""} + + {repo && {repo}} + {issueOrPr && #{issueOrPr}} +
+ + {title && ( +
+ {title} +
+ )} + + {status === "running" && !result && ( +
GitHub request in progress…
+ )} + + {result && ( +
+          {result}
+        
+ )} +
+ ); +} diff --git a/packages/client/src/components/tool-renderers/GoalToolRenderer.tsx b/packages/client/src/components/tool-renderers/GoalToolRenderer.tsx new file mode 100644 index 000000000..e12c2a54b --- /dev/null +++ b/packages/client/src/components/tool-renderers/GoalToolRenderer.tsx @@ -0,0 +1,39 @@ +import type { ToolRendererProps } from "./types.js"; + +export function GoalToolRenderer({ args, status, result }: ToolRendererProps) { + const objective = (args?.objective ?? args?.goal ?? args?.title) as string | undefined; + const goalStatus = (args?.status as string) || "active"; + + const statusStyles: Record = { + active: "bg-sky-500/20 text-sky-300", + accomplished: "bg-emerald-500/20 text-emerald-300", + abandoned: "bg-rose-500/20 text-rose-300", + }; + + return ( +
+
+ + {goalStatus} + + Goal +
+ + {objective && ( +
+ {objective} +
+ )} + + {status === "running" && !result && ( +
Evaluating goal…
+ )} + + {result && ( +
+          {result}
+        
+ )} +
+ ); +} diff --git a/packages/client/src/components/tool-renderers/HubToolRenderer.tsx b/packages/client/src/components/tool-renderers/HubToolRenderer.tsx new file mode 100644 index 000000000..ea92b34ad --- /dev/null +++ b/packages/client/src/components/tool-renderers/HubToolRenderer.tsx @@ -0,0 +1,38 @@ +import type { ToolRendererProps } from "./types.js"; + +export function HubToolRenderer({ args, status, result }: ToolRendererProps) { + const op = (args?.op as string) || "hub"; + const target = (args?.to as string) || (args?.from as string) || (args?.name as string) || (args?.id as string); + const message = (args?.message as string) || (args?.text as string); + + return ( +
+
+ + {op} + + {target && ( + + target: {target} + + )} +
+ + {message && ( +
+ {message} +
+ )} + + {status === "running" && !result && ( +
Hub operating…
+ )} + + {result && ( +
+          {result}
+        
+ )} +
+ ); +} diff --git a/packages/client/src/components/tool-renderers/ImageToolRenderer.tsx b/packages/client/src/components/tool-renderers/ImageToolRenderer.tsx new file mode 100644 index 000000000..7fab6926b --- /dev/null +++ b/packages/client/src/components/tool-renderers/ImageToolRenderer.tsx @@ -0,0 +1,33 @@ +import { ToolResultImages } from "./ToolResultImages.js"; +import type { ToolRendererProps } from "./types.js"; + +export function ImageToolRenderer({ toolName, args, status, result, images }: ToolRendererProps) { + const promptOrPath = (args?.prompt ?? args?.path ?? args?.url) as string | undefined; + + return ( +
+
+ + {toolName} + + {promptOrPath && "{promptOrPath}"} +
+ + {images && images.length > 0 && ( +
+ +
+ )} + + {status === "running" && !result && (!images || images.length === 0) && ( +
Processing image…
+ )} + + {result && ( +
+ {result} +
+ )} +
+ ); +} diff --git a/packages/client/src/components/tool-renderers/KnowledgeToolRenderer.tsx b/packages/client/src/components/tool-renderers/KnowledgeToolRenderer.tsx new file mode 100644 index 000000000..1a0954d57 --- /dev/null +++ b/packages/client/src/components/tool-renderers/KnowledgeToolRenderer.tsx @@ -0,0 +1,33 @@ +import type { ToolRendererProps } from "./types.js"; + +export function KnowledgeToolRenderer({ toolName, args, status, result }: ToolRendererProps) { + const keyOrQuery = (args?.key ?? args?.query ?? args?.topic ?? args?.subject) as string | undefined; + const value = (args?.value ?? args?.content ?? args?.memory) as string | undefined; + + return ( +
+
+ + {toolName} + + {keyOrQuery && {keyOrQuery}} +
+ + {value && ( +
+ {value} +
+ )} + + {status === "running" && !result && !value && ( +
Accessing knowledge base…
+ )} + + {result && ( +
+          {result}
+        
+ )} +
+ ); +} diff --git a/packages/client/src/components/tool-renderers/LspToolRenderer.tsx b/packages/client/src/components/tool-renderers/LspToolRenderer.tsx new file mode 100644 index 000000000..9bc3b6c0c --- /dev/null +++ b/packages/client/src/components/tool-renderers/LspToolRenderer.tsx @@ -0,0 +1,40 @@ +import { OpenFileButton } from "./OpenFileButton.js"; +import type { ToolRendererProps } from "./types.js"; + +export function LspToolRenderer({ args, status, result, context }: ToolRendererProps) { + const command = (args?.command ?? args?.action ?? args?.method) as string | undefined; + const filePath = (args?.path ?? args?.uri ?? args?.filePath) as string | undefined; + const symbol = (args?.symbol ?? args?.query) as string | undefined; + + return ( +
+
+ + LSP {command ?? ""} + + {filePath && ( +
+ {filePath} + +
+ )} +
+ + {symbol && ( +
+ symbol: {symbol} +
+ )} + + {status === "running" && !result && ( +
Querying LSP server…
+ )} + + {result && ( +
+          {result}
+        
+ )} +
+ ); +} diff --git a/packages/client/src/components/tool-renderers/ResolveRejectToolRenderer.tsx b/packages/client/src/components/tool-renderers/ResolveRejectToolRenderer.tsx new file mode 100644 index 000000000..008d7a9e3 --- /dev/null +++ b/packages/client/src/components/tool-renderers/ResolveRejectToolRenderer.tsx @@ -0,0 +1,32 @@ +import { mdiAlertCircle, mdiCheckCircle } from "@mdi/js"; +import { Icon } from "@mdi/react"; +import type { ToolRendererProps } from "./types.js"; + +export function ResolveRejectToolRenderer({ toolName, args, result }: ToolRendererProps) { + const isResolve = toolName === "resolve"; + const reason = (args?.reason ?? args?.label ?? args?.action ?? args?.summary) as string | undefined; + + return ( +
+ +
+
+ {isResolve ? "Task Resolved" : "Task Rejected"} +
+ {reason &&
{reason}
} + {result &&
{result}
} +
+
+ ); +} diff --git a/packages/client/src/components/tool-renderers/SearchToolRenderer.tsx b/packages/client/src/components/tool-renderers/SearchToolRenderer.tsx new file mode 100644 index 000000000..9c52ec923 --- /dev/null +++ b/packages/client/src/components/tool-renderers/SearchToolRenderer.tsx @@ -0,0 +1,29 @@ +import { LinkifiedText } from "./LinkifiedText.js"; +import type { ToolRendererProps } from "./types.js"; + +export function SearchToolRenderer({ toolName, args, status, result }: ToolRendererProps) { + const query = (args?.query ?? args?.pattern ?? args?.glob ?? args?.search) as string | undefined; + const path = (args?.path ?? args?.domain ?? args?.dir) as string | undefined; + + return ( +
+
+ + {toolName} + + {query && "{query}"} + {path && in {path}} +
+ + {status === "running" && !result && ( +
Searching…
+ )} + + {result && ( +
+ +
+ )} +
+ ); +} diff --git a/packages/client/src/components/tool-renderers/TaskToolRenderer.tsx b/packages/client/src/components/tool-renderers/TaskToolRenderer.tsx new file mode 100644 index 000000000..c2e2f7805 --- /dev/null +++ b/packages/client/src/components/tool-renderers/TaskToolRenderer.tsx @@ -0,0 +1,37 @@ +import type { ToolRendererProps } from "./types.js"; + +export function TaskToolRenderer({ args, status, result }: ToolRendererProps) { + const name = args?.name as string | undefined; + const agent = (args?.agent as string) || "task"; + const taskDesc = (args?.task as string) || (args?.description as string); + + return ( +
+
+ + {agent} + + {name && {name}} +
+ + {taskDesc && ( +
+ {taskDesc} +
+ )} + + {status === "running" && !result && ( +
Task running…
+ )} + + {result && ( +
+
Result
+
+            {result}
+          
+
+ )} +
+ ); +} diff --git a/packages/client/src/components/tool-renderers/ThinkToolRenderer.tsx b/packages/client/src/components/tool-renderers/ThinkToolRenderer.tsx new file mode 100644 index 000000000..ae8c32cdd --- /dev/null +++ b/packages/client/src/components/tool-renderers/ThinkToolRenderer.tsx @@ -0,0 +1,27 @@ +import type { ToolRendererProps } from "./types.js"; + +export function ThinkToolRenderer({ args, status, result }: ToolRendererProps) { + const thought = (args?.thought ?? args?.reasoning ?? args?.text) as string | undefined; + + return ( +
+
+ Reasoning / Thought +
+ + {thought && ( +
+ {thought} +
+ )} + + {status === "running" && !thought && !result && ( +
Thinking…
+ )} + + {result && ( +
{result}
+ )} +
+ ); +} diff --git a/packages/client/src/components/tool-renderers/TodoToolRenderer.tsx b/packages/client/src/components/tool-renderers/TodoToolRenderer.tsx new file mode 100644 index 000000000..a273332d8 --- /dev/null +++ b/packages/client/src/components/tool-renderers/TodoToolRenderer.tsx @@ -0,0 +1,57 @@ +import { mdiCheckboxBlankOutline, mdiCheckboxMarkedOutline } from "@mdi/js"; +import { Icon } from "@mdi/react"; +import type { ToolRendererProps } from "./types.js"; + +interface TodoItem { + id?: string; + text?: string; + title?: string; + completed?: boolean; + status?: string; +} + +export function TodoToolRenderer({ args, status, result }: ToolRendererProps) { + const action = (args?.action as string) || "todo"; + const items = (Array.isArray(args?.todos) ? args.todos : Array.isArray(args?.items) ? args.items : []) as TodoItem[]; + const singleTitle = (args?.title ?? args?.text ?? args?.item) as string | undefined; + + return ( +
+
+ + {action} + + {singleTitle && {singleTitle}} +
+ + {items.length > 0 && ( +
+ {items.map((item, idx) => { + const isDone = item.completed || item.status === "completed" || item.status === "done"; + const label = item.text ?? item.title ?? item.id ?? `Item ${idx + 1}`; + return ( +
+ + {label} +
+ ); + })} +
+ )} + + {status === "running" && !result && items.length === 0 && ( +
Updating todo list…
+ )} + + {result && ( +
+          {result}
+        
+ )} +
+ ); +} diff --git a/packages/client/src/components/tool-renderers/__tests__/SpecableToolRenderers.test.tsx b/packages/client/src/components/tool-renderers/__tests__/SpecableToolRenderers.test.tsx new file mode 100644 index 000000000..bf51ce34d --- /dev/null +++ b/packages/client/src/components/tool-renderers/__tests__/SpecableToolRenderers.test.tsx @@ -0,0 +1,238 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AstToolRenderer } from "../AstToolRenderer.js"; +import { BrowserToolRenderer } from "../BrowserToolRenderer.js"; +import { EditToolRenderer } from "../EditToolRenderer.js"; +import { EvalToolRenderer } from "../EvalToolRenderer.js"; +import { GithubToolRenderer } from "../GithubToolRenderer.js"; +import { GoalToolRenderer } from "../GoalToolRenderer.js"; +import { HubToolRenderer } from "../HubToolRenderer.js"; +import { ImageToolRenderer } from "../ImageToolRenderer.js"; +import { KnowledgeToolRenderer } from "../KnowledgeToolRenderer.js"; +import { LspToolRenderer } from "../LspToolRenderer.js"; +import { ReadToolRenderer } from "../ReadToolRenderer.js"; +import { ResolveRejectToolRenderer } from "../ResolveRejectToolRenderer.js"; +import { getToolRenderer } from "../registry.js"; +import { SearchToolRenderer } from "../SearchToolRenderer.js"; +import { TaskToolRenderer } from "../TaskToolRenderer.js"; +import { ThinkToolRenderer } from "../ThinkToolRenderer.js"; +import { TodoToolRenderer } from "../TodoToolRenderer.js"; +import type { ToolContext } from "../types.js"; + +const ctx: ToolContext = { cwd: "/root" }; + +describe("SpecableToolRenderers", () => { + it("renders resolve banner with green check and reason", () => { + const { container } = render( + , + ); + expect(screen.getByTestId("resolve-banner")).toBeDefined(); + expect(container.textContent).toContain("Task Resolved"); + expect(container.textContent).toContain("All tests passing"); + }); + + it("renders reject banner with alert icon and reason", () => { + const { container } = render( + , + ); + expect(screen.getByTestId("reject-banner")).toBeDefined(); + expect(container.textContent).toContain("Task Rejected"); + expect(container.textContent).toContain("Build failed"); + }); + + it("renders eval card with language badge and code snippet", () => { + const { container } = render( + , + ); + expect(container.textContent?.toLowerCase()).toContain("py"); + expect(container.textContent).toContain("print('hello')"); + expect(container.textContent).toContain("hello"); + }); + + it("renders browser card with action badge and url", () => { + const { container } = render( + , + ); + expect(container.textContent?.toLowerCase()).toContain("navigate"); + expect(container.textContent).toContain("https://example.com"); + }); + + it("renders task card with agent type and description", () => { + const { container } = render( + , + ); + expect(container.textContent?.toLowerCase()).toContain("reviewer"); + expect(container.textContent).toContain("PR Check"); + expect(container.textContent).toContain("Audit code"); + }); + + it("renders hub card with operation badge and target", () => { + const { container } = render( + , + ); + expect(container.textContent?.toLowerCase()).toContain("send"); + expect(container.textContent).toContain("worker1"); + expect(container.textContent).toContain("Ping"); + }); + + it("renders todo card with checkbox list", () => { + const { container } = render( + , + ); + expect(container.textContent?.toLowerCase()).toContain("list"); + expect(container.textContent).toContain("First task"); + expect(container.textContent).toContain("Second task"); + }); + + it("renders goal card with objective and status", () => { + const { container } = render( + , + ); + expect(container.textContent?.toLowerCase()).toContain("accomplished"); + expect(container.textContent).toContain("Finish feature"); + }); + + it("renders think card with reasoning block", () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("Reasoning / Thought"); + expect(container.textContent).toContain("I should analyze the codebase first"); + }); + + it("renders ast card with pattern and path", () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("ast_grep"); + expect(container.textContent).toContain("src/index.ts"); + expect(container.textContent).toContain("function foo($A)"); + }); + + it("renders lsp card with command and file", () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("LSP definition"); + expect(container.textContent).toContain("src/main.ts"); + }); + + it("renders search card for web_search/grep/glob/find", () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("web_search"); + expect(container.textContent).toContain("vitest react"); + }); + + it("renders github card with operation and issue number", () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("github pr_view"); + expect(container.textContent).toContain("ZGEnergy/omp-dashboard"); + expect(container.textContent).toContain("#120"); + }); + + it("renders knowledge card with key and memory text", () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("retain"); + expect(container.textContent).toContain("user_pref"); + expect(container.textContent).toContain("dark mode"); + }); + + it("renders image card with prompt and images", () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("generate_image"); + expect(container.textContent).toContain("A glowing red cube"); + }); + + it("maps registry aliases correctly", () => { + expect(getToolRenderer("fetch")).toBe(ReadToolRenderer); + expect(getToolRenderer("apply_patch")).toBe(EditToolRenderer); + expect(getToolRenderer("resolve")).toBe(ResolveRejectToolRenderer); + expect(getToolRenderer("reject")).toBe(ResolveRejectToolRenderer); + expect(getToolRenderer("eval")).toBe(EvalToolRenderer); + expect(getToolRenderer("browser")).toBe(BrowserToolRenderer); + }); +}); diff --git a/packages/client/src/components/tool-renderers/index.ts b/packages/client/src/components/tool-renderers/index.ts index 5acb229a5..b705b9285 100644 --- a/packages/client/src/components/tool-renderers/index.ts +++ b/packages/client/src/components/tool-renderers/index.ts @@ -1,2 +1,16 @@ +export { AstToolRenderer } from "./AstToolRenderer.js"; +export { BrowserToolRenderer } from "./BrowserToolRenderer.js"; +export { EvalToolRenderer } from "./EvalToolRenderer.js"; +export { GithubToolRenderer } from "./GithubToolRenderer.js"; +export { GoalToolRenderer } from "./GoalToolRenderer.js"; +export { HubToolRenderer } from "./HubToolRenderer.js"; +export { ImageToolRenderer } from "./ImageToolRenderer.js"; +export { KnowledgeToolRenderer } from "./KnowledgeToolRenderer.js"; +export { LspToolRenderer } from "./LspToolRenderer.js"; +export { ResolveRejectToolRenderer } from "./ResolveRejectToolRenderer.js"; export { getToolRenderer, registerToolRenderer } from "./registry.js"; -export type { ToolContext, ToolRendererProps, ToolRenderer } from "./types.js"; +export { SearchToolRenderer } from "./SearchToolRenderer.js"; +export { TaskToolRenderer } from "./TaskToolRenderer.js"; +export { ThinkToolRenderer } from "./ThinkToolRenderer.js"; +export { TodoToolRenderer } from "./TodoToolRenderer.js"; +export type { ToolContext, ToolRenderer, ToolRendererProps } from "./types.js"; diff --git a/packages/client/src/components/tool-renderers/registry.ts b/packages/client/src/components/tool-renderers/registry.ts index b9a516b27..8068675ad 100644 --- a/packages/client/src/components/tool-renderers/registry.ts +++ b/packages/client/src/components/tool-renderers/registry.ts @@ -1,12 +1,26 @@ -import type { ToolRenderer } from "./types.js"; -import { ReadToolRenderer } from "./ReadToolRenderer.js"; -import { EditToolRenderer } from "./EditToolRenderer.js"; -import { WriteToolRenderer } from "./WriteToolRenderer.js"; -import { BashToolRenderer } from "./BashToolRenderer.js"; import { AgentToolRenderer } from "./AgentToolRenderer.js"; -import { GenericToolRenderer } from "./GenericToolRenderer.js"; import { AskUserToolRenderer } from "./AskUserToolRenderer.js"; +import { AstToolRenderer } from "./AstToolRenderer.js"; +import { BashToolRenderer } from "./BashToolRenderer.js"; +import { BrowserToolRenderer } from "./BrowserToolRenderer.js"; import { CtxToolRenderer } from "./CtxToolRenderer.js"; +import { EditToolRenderer } from "./EditToolRenderer.js"; +import { EvalToolRenderer } from "./EvalToolRenderer.js"; +import { GenericToolRenderer } from "./GenericToolRenderer.js"; +import { GithubToolRenderer } from "./GithubToolRenderer.js"; +import { GoalToolRenderer } from "./GoalToolRenderer.js"; +import { HubToolRenderer } from "./HubToolRenderer.js"; +import { ImageToolRenderer } from "./ImageToolRenderer.js"; +import { KnowledgeToolRenderer } from "./KnowledgeToolRenderer.js"; +import { LspToolRenderer } from "./LspToolRenderer.js"; +import { ReadToolRenderer } from "./ReadToolRenderer.js"; +import { ResolveRejectToolRenderer } from "./ResolveRejectToolRenderer.js"; +import { SearchToolRenderer } from "./SearchToolRenderer.js"; +import { TaskToolRenderer } from "./TaskToolRenderer.js"; +import { ThinkToolRenderer } from "./ThinkToolRenderer.js"; +import { TodoToolRenderer } from "./TodoToolRenderer.js"; +import type { ToolRenderer } from "./types.js"; +import { WriteToolRenderer } from "./WriteToolRenderer.js"; const renderers = new Map([ ["read", ReadToolRenderer], @@ -23,6 +37,31 @@ const renderers = new Map([ ["ctx_index", CtxToolRenderer], ["ctx_fetch_and_index", CtxToolRenderer], ["ctx_insight", CtxToolRenderer], + // Specable Tool Card Mappings (#120) + ["apply_patch", EditToolRenderer], + ["fetch", ReadToolRenderer], + ["resolve", ResolveRejectToolRenderer], + ["reject", ResolveRejectToolRenderer], + ["eval", EvalToolRenderer], + ["browser", BrowserToolRenderer], + ["task", TaskToolRenderer], + ["hub", HubToolRenderer], + ["todo", TodoToolRenderer], + ["goal", GoalToolRenderer], + ["think", ThinkToolRenderer], + ["ast_edit", AstToolRenderer], + ["ast_grep", AstToolRenderer], + ["lsp", LspToolRenderer], + ["web_search", SearchToolRenderer], + ["grep", SearchToolRenderer], + ["glob", SearchToolRenderer], + ["find", SearchToolRenderer], + ["github", GithubToolRenderer], + ["retain", KnowledgeToolRenderer], + ["recall", KnowledgeToolRenderer], + ["reflect", KnowledgeToolRenderer], + ["inspect_image", ImageToolRenderer], + ["generate_image", ImageToolRenderer], ]); /** Register a custom renderer for a tool name */ diff --git a/packages/client/src/lib/__tests__/unwrap-xd.test.ts b/packages/client/src/lib/__tests__/unwrap-xd.test.ts new file mode 100644 index 000000000..eb006b011 --- /dev/null +++ b/packages/client/src/lib/__tests__/unwrap-xd.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { unwrapXdToolCall } from "../unwrap-xd.js"; + +describe("unwrapXdToolCall", () => { + it("prioritizes toolDetails.xdev when present", () => { + const result = unwrapXdToolCall( + "write", + { path: "xd://ignored", content: '{"foo": "bar"}' }, + { xdev: { tool: "eval", inner: { code: "console.log(1)" } } }, + ); + expect(result).toEqual({ + effectiveToolName: "eval", + effectiveArgs: { code: "console.log(1)" }, + }); + }); + + it("supports xdev.args fallback when xdev.inner is missing", () => { + const result = unwrapXdToolCall( + "write", + { path: "xd://ignored" }, + { xdev: { tool: "browser", args: { url: "https://example.com" } } }, + ); + expect(result).toEqual({ + effectiveToolName: "browser", + effectiveArgs: { url: "https://example.com" }, + }); + }); + + it("unwraps write tool calls to xd:// with valid JSON content", () => { + const result = unwrapXdToolCall("write", { + path: "xd://eval", + content: '{"language": "py", "code": "print(42)"}', + }); + expect(result).toEqual({ + effectiveToolName: "eval", + effectiveArgs: { language: "py", code: "print(42)" }, + }); + }); + + it("unwraps xd:// path for MCP tools", () => { + const result = unwrapXdToolCall("write", { + path: "xd://mcp__context_mode_context_mode_ctx_execute", + content: '{"command": "ls"}', + }); + expect(result).toEqual({ + effectiveToolName: "ctx_execute", + effectiveArgs: { command: "ls" }, + }); + }); + + it("handles double-underscore MCP paths", () => { + const result = unwrapXdToolCall("write", { + path: "xd://mcp__server__custom_tool", + content: '{"a": 1}', + }); + expect(result).toEqual({ + effectiveToolName: "custom_tool", + effectiveArgs: { a: 1 }, + }); + }); + + it("falls back to raw string content on invalid or truncated JSON", () => { + const result = unwrapXdToolCall("write", { + path: "xd://eval", + content: '{"code": "print(', + }); + expect(result).toEqual({ + effectiveToolName: "eval", + effectiveArgs: { raw: '{"code": "print(' }, + }); + }); + + it("maps alias names (puppeteer -> browser, irc -> hub)", () => { + const res1 = unwrapXdToolCall("puppeteer", { url: "https://a.b" }); + expect(res1.effectiveToolName).toBe("browser"); + + const res2 = unwrapXdToolCall("write", { + path: "xd://irc", + content: '{"op": "send"}', + }); + expect(res2.effectiveToolName).toBe("hub"); + }); + + it("passes through standard non-xd tool calls unchanged", () => { + const result = unwrapXdToolCall("bash", { command: "npm test" }); + expect(result).toEqual({ + effectiveToolName: "bash", + effectiveArgs: { command: "npm test" }, + }); + }); +}); diff --git a/packages/client/src/lib/tool-summary.ts b/packages/client/src/lib/tool-summary.ts index 2eae6497d..f49693a86 100644 --- a/packages/client/src/lib/tool-summary.ts +++ b/packages/client/src/lib/tool-summary.ts @@ -10,14 +10,27 @@ */ import { + mdiAccountGroupOutline, mdiAccountQuestionOutline, + mdiBrain, + mdiCheckCircleOutline, + mdiCloseCircleOutline, mdiCodeBraces, mdiConsoleLine, + mdiDatabasePlusOutline, mdiDatabaseSearchOutline, + mdiDownload, + mdiFileDocumentEditOutline, mdiFileDocumentOutline, mdiFilePlusOutline, + mdiFlagOutline, mdiFolderSearchOutline, mdiFormatListBulleted, + mdiFormatListCheckbox, + mdiGithub, + mdiImageOutline, + mdiImagePlusOutline, + mdiLightbulbOutline, mdiMagnify, mdiPencil, mdiRobotOutline, @@ -52,6 +65,27 @@ export const toolSummaries: Record) => s ctx_index: (args) => `ctx_index ${String(args?.source ?? args?.path ?? "")}`.trim(), ctx_fetch_and_index: (args) => `ctx_fetch_and_index ${String(args?.url ?? args?.source ?? "")}`.trim(), ctx_insight: () => `ctx_insight`, + resolve: (args) => `Resolve ${args?.reason ? String(args.reason) : args?.label ? String(args.label) : ""}`.trim(), + reject: (args) => `Reject ${args?.reason ? String(args.reason) : args?.label ? String(args.label) : ""}`.trim(), + eval: (args) => `eval ${args?.language ? `${args.language} ` : ""}${args?.title ? `(${args.title})` : ""}`.trim(), + browser: (args) => `browser ${args?.action ?? args?.url ?? ""}`.trim(), + task: (args) => `task ${args?.name ?? args?.agent ?? ""}`.trim(), + hub: (args) => `hub ${args?.op ?? args?.to ?? ""}`.trim(), + todo: (args) => `todo ${args?.action ?? ""}`.trim(), + goal: (args) => `goal ${args?.objective ?? args?.goal ?? ""}`.trim(), + think: (args) => `think ${args?.thought ? String(args.thought).slice(0, 40) : ""}`.trim(), + ast_edit: (args) => `ast_edit ${args?.path ?? ""}`.trim(), + ast_grep: (args) => `ast_grep ${args?.pattern ?? args?.path ?? ""}`.trim(), + lsp: (args) => `lsp ${args?.command ?? args?.action ?? ""}`.trim(), + web_search: (args) => `web_search ${args?.query ?? ""}`.trim(), + fetch: (args) => `fetch ${args?.url ?? args?.path ?? ""}`.trim(), + github: (args) => `github ${args?.op ?? args?.action ?? ""}`.trim(), + apply_patch: (args) => `apply_patch ${args?.path ?? ""}`.trim(), + retain: (args) => `retain ${args?.key ?? args?.topic ?? ""}`.trim(), + recall: (args) => `recall ${args?.query ?? args?.key ?? ""}`.trim(), + reflect: (args) => `reflect ${args?.topic ?? ""}`.trim(), + inspect_image: (args) => `inspect_image ${args?.path ?? args?.url ?? ""}`.trim(), + generate_image: (args) => `generate_image ${args?.prompt ? String(args.prompt).slice(0, 30) : ""}`.trim(), }; export function getSummary(toolName: string, args?: Record): string { @@ -89,6 +123,27 @@ export const toolIcons: Record = { ctx_index: mdiDatabaseSearchOutline, ctx_fetch_and_index: mdiWeb, ctx_insight: mdiFormatListBulleted, + resolve: mdiCheckCircleOutline, + reject: mdiCloseCircleOutline, + eval: mdiCodeBraces, + browser: mdiWeb, + task: mdiFormatListCheckbox, + hub: mdiAccountGroupOutline, + todo: mdiFormatListCheckbox, + goal: mdiFlagOutline, + think: mdiBrain, + ast_edit: mdiFileDocumentEditOutline, + ast_grep: mdiMagnify, + lsp: mdiCodeBraces, + web_search: mdiWeb, + fetch: mdiDownload, + github: mdiGithub, + apply_patch: mdiPencil, + retain: mdiDatabasePlusOutline, + recall: mdiDatabaseSearchOutline, + reflect: mdiLightbulbOutline, + inspect_image: mdiImageOutline, + generate_image: mdiImagePlusOutline, }; /** mdi icon path for a tool kind; generic wrench for unknown kinds. */ diff --git a/packages/client/src/lib/unwrap-xd.ts b/packages/client/src/lib/unwrap-xd.ts new file mode 100644 index 000000000..1d031a55d --- /dev/null +++ b/packages/client/src/lib/unwrap-xd.ts @@ -0,0 +1,68 @@ +const TOOL_ALIASES: Record = { + puppeteer: "browser", + irc: "hub", + job: "hub", + await: "hub", + poll: "hub", +}; + +export interface UnwrappedToolCall { + effectiveToolName: string; + effectiveArgs: Record; +} + +function parseXdPath(pathStr: string): string { + const rawPath = pathStr.slice(5).split("?")[0].split("#")[0]; + if (!rawPath.startsWith("mcp__")) return rawPath; + const inner = rawPath.slice(5); + if (inner.includes("__")) return inner.split("__").pop() ?? inner; + if (inner.includes("_ctx_")) return inner.slice(inner.indexOf("_ctx_") + 1); + return inner; +} + +function parseXdContent(args?: Record): Record { + if (!args) return {}; + if (typeof args.content === "string") { + try { + const parsed = JSON.parse(args.content); + return typeof parsed === "object" && parsed !== null ? (parsed as Record) : { raw: args.content }; + } catch { + return { raw: args.content }; + } + } + if (typeof args.content === "object" && args.content !== null) { + return args.content as Record; + } + return args; +} + +function getXdevUnwrapped(toolDetails?: Record, fallbackArgs?: Record): UnwrappedToolCall | null { + const xdev = toolDetails?.xdev; + if (xdev && typeof xdev === "object") { + const obj = xdev as Record; + if (typeof obj.tool === "string" && obj.tool.length > 0) { + const inner = (obj.inner ?? obj.args ?? fallbackArgs ?? {}) as Record; + return { effectiveToolName: TOOL_ALIASES[obj.tool] ?? obj.tool, effectiveArgs: inner }; + } + } + return null; +} + +export function unwrapXdToolCall( + toolName: string, + args?: Record, + toolDetails?: Record, +): UnwrappedToolCall { + const xdevResult = getXdevUnwrapped(toolDetails, args); + if (xdevResult) return xdevResult; + + const lowerName = toolName.toLowerCase(); + const isWrite = lowerName === "write" || lowerName === "write_file"; + if (isWrite && typeof args?.path === "string" && args.path.startsWith("xd://")) { + const extractedTool = parseXdPath(args.path); + const effectiveArgs = parseXdContent(args); + return { effectiveToolName: TOOL_ALIASES[extractedTool] ?? extractedTool, effectiveArgs }; + } + + return { effectiveToolName: TOOL_ALIASES[toolName] ?? toolName, effectiveArgs: args ?? {} }; +} From c704aa51ec89fb8e7af116be9108b86fb60bedc9 Mon Sep 17 00:00:00 2001 From: Joe Shull <42043763+joeshull@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:38:30 -0600 Subject: [PATCH 2/2] fix: resolve CI failure (attempt 1) --- .../src/components/tool-renderers/SearchToolRenderer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/src/components/tool-renderers/SearchToolRenderer.tsx b/packages/client/src/components/tool-renderers/SearchToolRenderer.tsx index 9c52ec923..8433be7de 100644 --- a/packages/client/src/components/tool-renderers/SearchToolRenderer.tsx +++ b/packages/client/src/components/tool-renderers/SearchToolRenderer.tsx @@ -1,7 +1,7 @@ import { LinkifiedText } from "./LinkifiedText.js"; import type { ToolRendererProps } from "./types.js"; -export function SearchToolRenderer({ toolName, args, status, result }: ToolRendererProps) { +export function SearchToolRenderer({ toolName, args, status, result, context }: ToolRendererProps) { const query = (args?.query ?? args?.pattern ?? args?.glob ?? args?.search) as string | undefined; const path = (args?.path ?? args?.domain ?? args?.dir) as string | undefined; @@ -21,7 +21,7 @@ export function SearchToolRenderer({ toolName, args, status, result }: ToolRende {result && (
- +
)}