Skip to content

Commit d2eac5b

Browse files
committed
feat: ExecuteToolView
1 parent bd8721a commit d2eac5b

File tree

3 files changed

+143
-3
lines changed

3 files changed

+143
-3
lines changed

apps/array/src/renderer/features/sessions/components/ConversationView.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,11 @@ function extractUserContent(params: unknown): string {
222222
return textBlock?.text ?? "";
223223
}
224224

225+
function mergeToolCallUpdate(existing: ToolCall, update: SessionUpdate) {
226+
const { sessionUpdate: _, ...rest } = update;
227+
Object.assign(existing, rest);
228+
}
229+
225230
function processSessionUpdate(turn: Turn, update: SessionUpdate) {
226231
switch (update.sessionUpdate) {
227232
case "user_message_chunk":
@@ -252,9 +257,7 @@ function processSessionUpdate(turn: Turn, update: SessionUpdate) {
252257
case "tool_call_update": {
253258
const existing = turn.toolCalls.get(update.toolCallId);
254259
if (existing) {
255-
// Merge update but preserve sessionUpdate as "tool_call" for rendering
256-
const { sessionUpdate: _, ...rest } = update;
257-
Object.assign(existing, rest);
260+
mergeToolCallUpdate(existing, update);
258261
}
259262
break;
260263
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { DotsCircleSpinner } from "@components/DotsCircleSpinner";
2+
import type { ToolCall } from "@features/sessions/types";
3+
import { ArrowsInSimpleIcon, ArrowsOutSimpleIcon, Copy } from "@phosphor-icons/react";
4+
import { Box, Flex, IconButton, Text } from "@radix-ui/themes";
5+
import { useState } from "react";
6+
7+
interface ExecuteToolViewProps {
8+
toolCall: ToolCall;
9+
turnCancelled?: boolean;
10+
}
11+
12+
interface ExecuteRawInput {
13+
command?: string;
14+
description?: string;
15+
}
16+
17+
const COLLAPSED_LINE_COUNT = 3;
18+
19+
function getOutputFromContent(
20+
content: ToolCall["content"],
21+
): string | undefined {
22+
if (!content?.length) return undefined;
23+
const first = content[0];
24+
if (first.type === "content" && first.content.type === "text") {
25+
return first.content.text;
26+
}
27+
return undefined;
28+
}
29+
30+
export function ExecuteToolView({
31+
toolCall,
32+
turnCancelled,
33+
}: ExecuteToolViewProps) {
34+
const [isExpanded, setIsExpanded] = useState(false);
35+
const { status, rawInput, content } = toolCall;
36+
const executeInput = rawInput as ExecuteRawInput | undefined;
37+
38+
const command = executeInput?.command ?? "";
39+
const description = executeInput?.description;
40+
41+
const isIncomplete = status === "pending" || status === "in_progress";
42+
const isLoading = isIncomplete && !turnCancelled;
43+
44+
const output = getOutputFromContent(content) ?? "";
45+
const hasOutput = output.trim().length > 0;
46+
const outputLines = output.split("\n");
47+
const isCollapsible = outputLines.length > COLLAPSED_LINE_COUNT;
48+
const hiddenLineCount = outputLines.length - COLLAPSED_LINE_COUNT;
49+
const displayedOutput = isExpanded
50+
? output
51+
: outputLines.slice(0, COLLAPSED_LINE_COUNT).join("\n");
52+
53+
const handleCopy = () => {
54+
navigator.clipboard.writeText(command);
55+
};
56+
57+
return (
58+
<Box className="my-2 max-w-4xl overflow-hidden rounded-lg border border-gray-6 bg-gray-1">
59+
{/* Header */}
60+
<Flex align="center" justify="between" className="px-3 py-2">
61+
<Flex align="center" gap="2">
62+
{isLoading && (
63+
<DotsCircleSpinner size={12} className="text-gray-10" />
64+
)}
65+
{description && (
66+
<Text size="1" className="text-gray-10">
67+
{description}
68+
</Text>
69+
)}
70+
</Flex>
71+
<Flex align="center" gap="2">
72+
{hasOutput && (
73+
<IconButton
74+
size="1"
75+
variant="ghost"
76+
color="gray"
77+
onClick={handleCopy}
78+
>
79+
<Copy size={12} />
80+
</IconButton>
81+
)}
82+
{isCollapsible && (
83+
<IconButton
84+
size="1"
85+
variant="ghost"
86+
color="gray"
87+
onClick={() => setIsExpanded(!isExpanded)}
88+
>
89+
{isExpanded ? (
90+
<ArrowsInSimpleIcon size={12} />
91+
) : (
92+
<ArrowsOutSimpleIcon size={12} />
93+
)}
94+
</IconButton>
95+
)}
96+
</Flex>
97+
</Flex>
98+
99+
{/* Command line */}
100+
<Box className="px-3 py-2">
101+
<Text asChild size="1" className="font-mono">
102+
<pre className="m-0 whitespace-pre-wrap break-all">
103+
<span className="text-accent-11">$</span>{" "}
104+
<span className="text-accent-11">{command}</span>
105+
</pre>
106+
</Text>
107+
</Box>
108+
109+
{/* Output */}
110+
{hasOutput && (
111+
<Box className="border-gray-6 border-t px-3 py-2">
112+
<Text asChild size="1" className="font-mono text-gray-11">
113+
<pre className="m-0 whitespace-pre-wrap break-all">
114+
{displayedOutput}
115+
</pre>
116+
</Text>
117+
{/* Expand button at bottom */}
118+
{isCollapsible && !isExpanded && (
119+
<button
120+
type="button"
121+
onClick={() => setIsExpanded(true)}
122+
className="mt-1 flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-gray-10 hover:text-gray-12"
123+
>
124+
<Text size="1">+{hiddenLineCount} more lines</Text>
125+
</button>
126+
)}
127+
</Box>
128+
)}
129+
</Box>
130+
);
131+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ToolCall } from "@features/sessions/types";
2+
import { ExecuteToolView } from "./ExecuteToolView";
23
import { ToolCallView } from "./ToolCallView";
34

45
interface ToolCallBlockProps {
@@ -7,5 +8,10 @@ interface ToolCallBlockProps {
78
}
89

910
export function ToolCallBlock({ toolCall, turnCancelled }: ToolCallBlockProps) {
11+
if (toolCall.kind === "execute") {
12+
return (
13+
<ExecuteToolView toolCall={toolCall} turnCancelled={turnCancelled} />
14+
);
15+
}
1016
return <ToolCallView toolCall={toolCall} turnCancelled={turnCancelled} />;
1117
}

0 commit comments

Comments
 (0)