Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 7ff7eca

Browse files
authored
feat(app): rich display of regular tools (#1069)
1 parent 13bbe86 commit 7ff7eca

5 files changed

Lines changed: 111 additions & 59 deletions

File tree

‎apps/twig/src/renderer/features/sessions/components/session-update/McpToolView.tsx‎

Lines changed: 4 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,52 +2,29 @@ import { Plugs } from "@phosphor-icons/react";
22
import { Box, Flex } from "@radix-ui/themes";
33
import { useState } from "react";
44
import {
5+
compactInput,
56
ExpandableIcon,
67
ExpandedContentBox,
8+
formatInput,
79
getContentText,
810
StatusIndicators,
11+
stripCodeFences,
912
ToolTitle,
1013
type ToolViewProps,
1114
useToolCallStatus,
1215
} from "./toolCallUtils";
1316

14-
const INPUT_PREVIEW_MAX_LENGTH = 60;
15-
1617
function parseMcpName(mcpToolName: string): {
1718
serverName: string;
1819
toolName: string;
1920
} {
2021
const parts = mcpToolName.split("__");
21-
const serverName = parts[1] ?? "";
2222
return {
23-
serverName: serverName.toLowerCase() === "posthog" ? "PostHog" : serverName,
23+
serverName: parts[1] ?? "",
2424
toolName: parts.slice(2).join("__"),
2525
};
2626
}
2727

28-
function compactInput(rawInput: unknown): string | undefined {
29-
if (!rawInput || typeof rawInput !== "object") return undefined;
30-
try {
31-
const json = JSON.stringify(rawInput);
32-
if (json === "{}") return undefined;
33-
if (json.length <= INPUT_PREVIEW_MAX_LENGTH) return json;
34-
return `${json.slice(0, INPUT_PREVIEW_MAX_LENGTH)}...`;
35-
} catch {
36-
return undefined;
37-
}
38-
}
39-
40-
function formatInput(rawInput: unknown): string | undefined {
41-
if (!rawInput || typeof rawInput !== "object") return undefined;
42-
try {
43-
const json = JSON.stringify(rawInput, null, 2);
44-
if (json === "{}") return undefined;
45-
return json;
46-
} catch {
47-
return undefined;
48-
}
49-
}
50-
5128
interface McpToolViewProps extends ToolViewProps {
5229
mcpToolName: string;
5330
}
@@ -122,7 +99,3 @@ export function McpToolView({
12299
</Box>
123100
);
124101
}
125-
126-
function stripCodeFences(text: string): string {
127-
return text.replace(/^```\w*\n?/, "").replace(/\n?```\s*$/, "");
128-
}

‎apps/twig/src/renderer/features/sessions/components/session-update/ToolCallBlock.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export function ToolCallBlock({
9191
case "question":
9292
return <QuestionToolView {...props} />;
9393
default:
94-
return <ToolCallView {...props} />;
94+
return <ToolCallView {...props} agentToolName={toolName} />;
9595
}
9696
})();
9797

‎apps/twig/src/renderer/features/sessions/components/session-update/ToolCallView.tsx‎

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ArrowsLeftRight,
55
Brain,
66
ChatCircle,
7+
Command,
78
FileText,
89
Globe,
910
type Icon,
@@ -17,11 +18,14 @@ import { Box, Flex } from "@radix-ui/themes";
1718
import { compactHomePath } from "@utils/path";
1819
import { useState } from "react";
1920
import {
21+
compactInput,
2022
ExpandableIcon,
2123
ExpandedContentBox,
24+
formatInput,
2225
getContentText,
2326
getFilename,
2427
StatusIndicators,
28+
stripCodeFences,
2529
ToolTitle,
2630
type ToolViewProps,
2731
useToolCallStatus,
@@ -41,20 +45,33 @@ const kindIcons: Record<TwigToolKind, Icon> = {
4145
other: Wrench,
4246
};
4347

48+
const toolNameIcons: Record<string, Icon> = {
49+
ToolSearch: MagnifyingGlass,
50+
Skill: Command,
51+
};
52+
53+
interface ToolCallViewProps extends ToolViewProps {
54+
agentToolName?: string;
55+
}
56+
4457
export function ToolCallView({
4558
toolCall,
4659
turnCancelled,
4760
turnComplete,
61+
agentToolName,
4862
expanded = false,
49-
}: ToolViewProps) {
63+
}: ToolCallViewProps) {
5064
const [isExpanded, setIsExpanded] = useState(expanded);
51-
const { title, kind, status, locations, content } = toolCall;
52-
const { isLoading, isFailed, wasCancelled } = useToolCallStatus(
65+
const { title, kind, status, locations, content, rawInput } = toolCall;
66+
const { isLoading, isFailed, wasCancelled, isComplete } = useToolCallStatus(
5367
status,
5468
turnCancelled,
5569
turnComplete,
5670
);
57-
const KindIcon = (kind && kindIcons[kind]) || Wrench;
71+
const KindIcon =
72+
(agentToolName && toolNameIcons[agentToolName]) ||
73+
(kind && kindIcons[kind]) ||
74+
Wrench;
5875

5976
const filePath = kind === "read" && locations?.[0]?.path;
6077
const displayText = filePath
@@ -63,9 +80,12 @@ export function ToolCallView({
6380
? compactHomePath(title)
6481
: undefined;
6582

83+
const inputPreview = compactInput(rawInput);
84+
const fullInput = formatInput(rawInput);
85+
6686
const output = stripCodeFences(getContentText(content) ?? "");
6787
const hasOutput = output.trim().length > 0;
68-
const isExpandable = hasOutput;
88+
const isExpandable = !!fullInput || hasOutput;
6989

7090
const handleClick = () => {
7191
if (isExpandable) {
@@ -87,19 +107,25 @@ export function ToolCallView({
87107
isExpanded={isExpanded}
88108
/>
89109
</Box>
90-
<Flex align="center" gap="2" wrap="wrap">
110+
<Flex align="center" gap="1" wrap="wrap" className="min-w-0">
91111
<ToolTitle>{displayText}</ToolTitle>
112+
{inputPreview && (
113+
<ToolTitle>
114+
<span className="font-mono text-accent-11">{inputPreview}</span>
115+
</ToolTitle>
116+
)}
92117
<StatusIndicators isFailed={isFailed} wasCancelled={wasCancelled} />
93118
</Flex>
94119
</Flex>
95120

96-
{isExpanded && hasOutput && (
97-
<ExpandedContentBox>{output}</ExpandedContentBox>
121+
{isExpanded && (
122+
<>
123+
{fullInput && <ExpandedContentBox>{fullInput}</ExpandedContentBox>}
124+
{isComplete && hasOutput && (
125+
<ExpandedContentBox>{output}</ExpandedContentBox>
126+
)}
127+
</>
98128
)}
99129
</Box>
100130
);
101131
}
102-
103-
function stripCodeFences(text: string): string {
104-
return text.replace(/^```\w*\n?/, "").replace(/\n?```\s*$/, "");
105-
}

‎apps/twig/src/renderer/features/sessions/components/session-update/toolCallUtils.tsx‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,35 @@ export function getLineCount(content: ToolCall["content"]): number | null {
8989
return text ? text.split("\n").length : null;
9090
}
9191

92+
const INPUT_PREVIEW_MAX_LENGTH = 60;
93+
94+
export function compactInput(rawInput: unknown): string | undefined {
95+
if (!rawInput || typeof rawInput !== "object") return undefined;
96+
try {
97+
const json = JSON.stringify(rawInput);
98+
if (json === "{}") return undefined;
99+
if (json.length <= INPUT_PREVIEW_MAX_LENGTH) return json;
100+
return `${json.slice(0, INPUT_PREVIEW_MAX_LENGTH)}...`;
101+
} catch {
102+
return undefined;
103+
}
104+
}
105+
106+
export function formatInput(rawInput: unknown): string | undefined {
107+
if (!rawInput || typeof rawInput !== "object") return undefined;
108+
try {
109+
const json = JSON.stringify(rawInput, null, 2);
110+
if (json === "{}") return undefined;
111+
return json;
112+
} catch {
113+
return undefined;
114+
}
115+
}
116+
117+
export function stripCodeFences(text: string): string {
118+
return text.replace(/^```\w*\n?/, "").replace(/\n?```\s*$/, "");
119+
}
120+
92121
export function truncateText(
93122
text: string,
94123
maxLength: number,

‎packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts‎

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -609,32 +609,56 @@ export function toolUpdateFromToolResult(
609609
}
610610
}
611611

612+
function itemToText(item: unknown): string | null {
613+
if (!item || typeof item !== "object") return null;
614+
const obj = item as Record<string, unknown>;
615+
// Standard text block
616+
if (obj.type === "text" && typeof obj.text === "string") {
617+
return obj.text;
618+
}
619+
// Any other structured object — serialize it
620+
try {
621+
return JSON.stringify(obj, null, 2);
622+
} catch {
623+
return null;
624+
}
625+
}
626+
612627
function toAcpContentUpdate(
613628
content: unknown,
614629
isError: boolean = false,
615630
): Pick<ToolCallUpdate, "content"> {
616631
if (Array.isArray(content) && content.length > 0) {
617-
return {
618-
content: content.map((item) => {
619-
const itemObj = item as { type?: string; text?: string };
620-
if (isError && itemObj.type === "text") {
621-
return {
622-
type: "content" as const,
623-
content: text(`\`\`\`\n${itemObj.text ?? ""}\n\`\`\``),
624-
};
625-
}
626-
return {
627-
type: "content" as const,
628-
content: item as { type: "text"; text: string },
629-
};
630-
}),
631-
};
632+
const texts: string[] = [];
633+
for (const item of content) {
634+
const t = itemToText(item);
635+
if (t) texts.push(t);
636+
}
637+
if (texts.length > 0) {
638+
const combined = texts.join("\n");
639+
return {
640+
content: toolContent()
641+
.text(isError ? `\`\`\`\n${combined}\n\`\`\`` : combined)
642+
.build(),
643+
};
644+
}
632645
} else if (typeof content === "string" && content.length > 0) {
633646
return {
634647
content: toolContent()
635648
.text(isError ? `\`\`\`\n${content}\n\`\`\`` : content)
636649
.build(),
637650
};
651+
} else if (content && typeof content === "object") {
652+
try {
653+
const json = JSON.stringify(content, null, 2);
654+
if (json && json !== "{}") {
655+
return {
656+
content: toolContent().text(json).build(),
657+
};
658+
}
659+
} catch {
660+
// ignore serialization errors
661+
}
638662
}
639663
return {};
640664
}

0 commit comments

Comments
 (0)