Skip to content

Commit 32712c6

Browse files
🤖 feat: Programmatic Tool Calling (PTC) via QuickJS sandbox (#1212)
## Overview Implements **Programmatic Tool Calling (PTC)** - a `code_execution` tool that enables AI models to orchestrate multi-tool workflows via JavaScript code in a sandboxed QuickJS environment. Instead of N inference round-trips for N tool calls, the model writes code that executes all tools in a single round-trip. **Gated behind experiment flags** (disabled by default): - `PROGRAMMATIC_TOOL_CALLING` - Adds `code_execution` alongside existing tools - `PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE` - Replaces all tools not available within `code_execution` with just `code_execution` ## Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ │ code_execution tool │ ├─────────────────────────────────────────────────────────────────┤ │ Static Analysis → TypeScript Validation → QuickJS Runtime │ │ (syntax, globals) (type checking) (sandboxed) │ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────┴─────────────┐ │ Tool Bridge │ │ mux.bash(), mux.file_read │ │ mux.file_edit_*(), etc. │ └───────────────────────────┘ ``` ### Key Components | Component | File | Purpose | |-----------|------|---------| | `IJSRuntime` | `ptc/runtime.ts` | Abstract interface for JS runtimes | | `QuickJSRuntime` | `ptc/quickjsRuntime.ts` | QuickJS-emscripten implementation with Asyncify | | `ToolBridge` | `ptc/toolBridge.ts` | Exposes Mux tools under `mux.*` namespace | | `staticAnalysis` | `ptc/staticAnalysis.ts` | Pre-execution validation (syntax, forbidden patterns) | | `typeGenerator` | `ptc/typeGenerator.ts` | Generates `.d.ts` from Zod schemas | | `code_execution` | `tools/code_execution.ts` | Entry point tool definition | ### Streaming Flow Nested tool calls stream to the UI in real-time: ``` streamText() calls code_execution.execute() ↓ JS code runs: mux.file_read({...}) → emit tool-call-start {parentToolCallId: "abc123"} → file_read executes → emit tool-call-end {parentToolCallId: "abc123", result: ...} ↓ JS code runs: mux.bash({...}) → emit tool-call-start {parentToolCallId: "abc123"} → bash executes → emit tool-call-end {parentToolCallId: "abc123", result: ...} ↓ code_execution returns final result ``` The `StreamingMessageAggregator` handles `parentToolCallId` to nest calls within the parent tool part. ## UI Components - **CodeExecutionToolCall** - Main container with fieldset layout, collapsible code/console sections - **NestedToolRenderer** - Routes nested calls to specialized tool components (BashToolCall, FileReadToolCall, etc.) - **ConsoleOutput** - Displays console.log/warn/error output with appropriate styling ## Security & Resource Limits | Resource | Limit | |----------|-------| | Memory | 64MB | | Timeout | 5 minutes | | Sandbox | QuickJS WASM (no fs/net access except via tools) | **Excluded from bridge:** `code_execution` (prevents recursion), `ask_user_question`, `propose_plan`, `todo_*`, `status_set` (UI-specific), provider-native tools (no `execute` function) ## Test Coverage 136 tests across: - QuickJS runtime (49 tests) - marshaling, async functions, abort, limits - Static analysis (33 tests) - syntax, forbidden patterns, unavailable globals - Type generation (14 tests) - Zod → `.d.ts` conversion, caching - Type validation (13 tests) - TypeScript error detection - code_execution tool (20 tests) - end-to-end execution - StreamingMessageAggregator (5 nested call tests) - parent/child handling ## Known Limitations 1. **Sequential execution** - `Promise.all()` runs sequentially due to Asyncify single-stack limitation 2. **Console output not streamed** - Appears only after code completes --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent a15e968 commit 32712c6

38 files changed

+6027
-102
lines changed

bun.lock

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"@ai-sdk/openai": "^2.0.72",
1414
"@ai-sdk/xai": "^2.0.36",
1515
"@aws-sdk/credential-providers": "^3.940.0",
16+
"@jitl/quickjs-wasmfile-release-asyncify": "^0.31.0",
1617
"@lydell/node-pty": "1.1.0",
1718
"@mozilla/readability": "^0.6.0",
1819
"@openrouter/ai-sdk-provider": "^1.2.5",
@@ -49,6 +50,7 @@
4950
"express": "^5.1.0",
5051
"ghostty-web": "^0.3.0-next.13.g3dd4aef",
5152
"jsdom": "^27.2.0",
53+
"json-schema-to-typescript": "^15.0.4",
5254
"jsonc-parser": "^3.3.1",
5355
"lru-cache": "^11.2.2",
5456
"lucide-react": "^0.553.0",
@@ -59,13 +61,16 @@
5961
"openai": "^6.9.1",
6062
"parse-duration": "^2.1.4",
6163
"posthog-node": "^5.17.0",
64+
"quickjs-emscripten": "^0.31.0",
65+
"quickjs-emscripten-core": "^0.31.0",
6266
"rehype-harden": "^1.1.5",
6367
"rehype-sanitize": "^6.0.0",
6468
"shescape": "^2.1.6",
6569
"source-map-support": "^0.5.21",
6670
"streamdown": "1.6.10",
6771
"trpc-cli": "^0.12.1",
6872
"turndown": "^7.2.2",
73+
"typescript": "^5.1.3",
6974
"undici": "^7.16.0",
7075
"write-file-atomic": "^6.0.0",
7176
"ws": "^8.18.3",
@@ -153,7 +158,6 @@
153158
"tailwindcss": "^4.1.15",
154159
"ts-jest": "^29.4.4",
155160
"tsc-alias": "^1.8.16",
156-
"typescript": "^5.1.3",
157161
"typescript-eslint": "^8.45.0",
158162
"vite": "^7.1.11",
159163
"vite-plugin-svgr": "^4.5.0",
@@ -196,6 +200,8 @@
196200

197201
"@antfu/install-pkg": ["@antfu/[email protected]", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
198202

203+
"@apidevtools/json-schema-ref-parser": ["@apidevtools/[email protected]", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="],
204+
199205
"@asamuzakjp/css-color": ["@asamuzakjp/[email protected]", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "lru-cache": "^11.2.2" } }, "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w=="],
200206

201207
"@asamuzakjp/dom-selector": ["@asamuzakjp/[email protected]", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.2" } }, "sha512-Eks6dY8zau4m4wNRQjRVaKQRTalNcPcBvU1ZQ35w5kKRk1gUeNCkVLsRiATurjASTp3TKM4H10wsI50nx3NZdw=="],
@@ -734,6 +740,16 @@
734740

735741
"@jest/types": ["@jest/[email protected]", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="],
736742

743+
"@jitl/quickjs-ffi-types": ["@jitl/[email protected]", "", {}, "sha512-1yrgvXlmXH2oNj3eFTrkwacGJbmM0crwipA3ohCrjv52gBeDaD7PsTvFYinlAnqU8iPME3LGP437yk05a2oejw=="],
744+
745+
"@jitl/quickjs-wasmfile-debug-asyncify": ["@jitl/[email protected]", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.31.0" } }, "sha512-YkdzQdr1uaftFhgEnTRjTTZHk2SFZdpWO7XhOmRVbi6CEVsH9g5oNF8Ta1q3OuSJHRwwT8YsuR1YzEiEIJEk6w=="],
746+
747+
"@jitl/quickjs-wasmfile-debug-sync": ["@jitl/[email protected]", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.31.0" } }, "sha512-8XvloaaWBONqcHXYs5tWOjdhQVxzULilIfB2hvZfS6S+fI4m2+lFiwQy7xeP8ExHmiZ7D8gZGChNkdLgjGfknw=="],
748+
749+
"@jitl/quickjs-wasmfile-release-asyncify": ["@jitl/[email protected]", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.31.0" } }, "sha512-uz0BbQYTxNsFkvkurd7vk2dOg57ElTBLCuvNtRl4rgrtbC++NIndD5qv2+AXb6yXDD3Uy1O2PCwmoaH0eXgEOg=="],
750+
751+
"@jitl/quickjs-wasmfile-release-sync": ["@jitl/[email protected]", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.31.0" } }, "sha512-hYduecOByj9AsAfsJhZh5nA6exokmuFC8cls39+lYmTCGY51bgjJJJwReEu7Ff7vBWaQCL6TeDdVlnp2WYz0jw=="],
752+
737753
"@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/[email protected]", "", { "dependencies": { "glob": "^10.0.0", "magic-string": "^0.30.0", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw=="],
738754

739755
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -746,6 +762,8 @@
746762

747763
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
748764

765+
"@jsdevtools/ono": ["@jsdevtools/[email protected]", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
766+
749767
"@lydell/node-pty": ["@lydell/[email protected]", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", "@lydell/node-pty-linux-arm64": "1.1.0", "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0" } }, "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw=="],
750768

751769
"@lydell/node-pty-darwin-arm64": ["@lydell/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w=="],
@@ -1356,6 +1374,8 @@
13561374

13571375
"@types/linkify-it": ["@types/[email protected]", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
13581376

1377+
"@types/lodash": ["@types/[email protected]", "", {}, "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ=="],
1378+
13591379
"@types/markdown-it": ["@types/[email protected]", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
13601380

13611381
"@types/mdast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
@@ -2562,6 +2582,8 @@
25622582

25632583
"json-schema": ["[email protected]", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
25642584

2585+
"json-schema-to-typescript": ["[email protected]", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.5.5", "@types/json-schema": "^7.0.15", "@types/lodash": "^4.17.7", "is-glob": "^4.0.3", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "minimist": "^1.2.8", "prettier": "^3.2.5", "tinyglobby": "^0.2.9" }, "bin": { "json2ts": "dist/src/cli.js" } }, "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ=="],
2586+
25652587
"json-schema-traverse": ["[email protected]", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
25662588

25672589
"json-schema-typed": ["[email protected]", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
@@ -3082,6 +3104,10 @@
30823104

30833105
"quick-lru": ["[email protected]", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
30843106

3107+
"quickjs-emscripten": ["[email protected]", "", { "dependencies": { "@jitl/quickjs-wasmfile-debug-asyncify": "0.31.0", "@jitl/quickjs-wasmfile-debug-sync": "0.31.0", "@jitl/quickjs-wasmfile-release-asyncify": "0.31.0", "@jitl/quickjs-wasmfile-release-sync": "0.31.0", "quickjs-emscripten-core": "0.31.0" } }, "sha512-K7Yt78aRPLjPcqv3fIuLW1jW3pvwO21B9pmFOolsjM/57ZhdVXBr51GqJpalgBlkPu9foAvhEAuuQPnvIGvLvQ=="],
3108+
3109+
"quickjs-emscripten-core": ["[email protected]", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.31.0" } }, "sha512-oQz8p0SiKDBc1TC7ZBK2fr0GoSHZKA0jZIeXxsnCyCs4y32FStzCW4d1h6E1sE0uHDMbGITbk2zhNaytaoJwXQ=="],
3110+
30853111
"radash": ["[email protected]", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="],
30863112

30873113
"range-parser": ["[email protected]", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@ai-sdk/openai": "^2.0.72",
5454
"@ai-sdk/xai": "^2.0.36",
5555
"@aws-sdk/credential-providers": "^3.940.0",
56+
"@jitl/quickjs-wasmfile-release-asyncify": "^0.31.0",
5657
"@lydell/node-pty": "1.1.0",
5758
"@mozilla/readability": "^0.6.0",
5859
"@openrouter/ai-sdk-provider": "^1.2.5",
@@ -89,6 +90,7 @@
8990
"express": "^5.1.0",
9091
"ghostty-web": "^0.3.0-next.13.g3dd4aef",
9192
"jsdom": "^27.2.0",
93+
"json-schema-to-typescript": "^15.0.4",
9294
"jsonc-parser": "^3.3.1",
9395
"lru-cache": "^11.2.2",
9496
"lucide-react": "^0.553.0",
@@ -99,6 +101,9 @@
99101
"openai": "^6.9.1",
100102
"parse-duration": "^2.1.4",
101103
"posthog-node": "^5.17.0",
104+
"quickjs-emscripten": "^0.31.0",
105+
"typescript": "^5.1.3",
106+
"quickjs-emscripten-core": "^0.31.0",
102107
"rehype-harden": "^1.1.5",
103108
"rehype-sanitize": "^6.0.0",
104109
"shescape": "^2.1.6",
@@ -193,7 +198,6 @@
193198
"tailwindcss": "^4.1.15",
194199
"ts-jest": "^29.4.4",
195200
"tsc-alias": "^1.8.16",
196-
"typescript": "^5.1.3",
197201
"typescript-eslint": "^8.45.0",
198202
"vite": "^7.1.11",
199203
"vite-plugin-svgr": "^4.5.0",

src/browser/components/Messages/ToolMessage.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { WebFetchToolCall } from "../tools/WebFetchToolCall";
1313
import { BashBackgroundListToolCall } from "../tools/BashBackgroundListToolCall";
1414
import { BashBackgroundTerminateToolCall } from "../tools/BashBackgroundTerminateToolCall";
1515
import { BashOutputToolCall } from "../tools/BashOutputToolCall";
16+
import { CodeExecutionToolCall } from "../tools/CodeExecutionToolCall";
1617
import type {
1718
BashToolArgs,
1819
BashToolResult,
@@ -136,6 +137,15 @@ function isBashOutputTool(toolName: string, args: unknown): args is BashOutputTo
136137
return TOOL_DEFINITIONS.bash_output.schema.safeParse(args).success;
137138
}
138139

140+
interface CodeExecutionToolArgs {
141+
code: string;
142+
}
143+
144+
function isCodeExecutionTool(toolName: string, args: unknown): args is CodeExecutionToolArgs {
145+
if (toolName !== "code_execution") return false;
146+
return TOOL_DEFINITIONS.code_execution.schema.safeParse(args).success;
147+
}
148+
139149
export const ToolMessage: React.FC<ToolMessageProps> = ({
140150
message,
141151
className,
@@ -329,6 +339,19 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
329339
);
330340
}
331341

342+
if (isCodeExecutionTool(message.toolName, message.args)) {
343+
return (
344+
<div className={className}>
345+
<CodeExecutionToolCall
346+
args={message.args}
347+
result={message.result as Parameters<typeof CodeExecutionToolCall>[0]["result"]}
348+
status={message.status}
349+
nestedCalls={message.nestedCalls}
350+
/>
351+
</div>
352+
);
353+
}
354+
332355
// Fallback to generic tool call
333356
return (
334357
<div className={className}>
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import React, { useState, useMemo } from "react";
2+
import { CodeIcon, TerminalIcon, CheckCircleIcon, XCircleIcon } from "lucide-react";
3+
import { DetailContent } from "./shared/ToolPrimitives";
4+
import { getStatusDisplay, type ToolStatus } from "./shared/toolUtils";
5+
import { HighlightedCode } from "./shared/HighlightedCode";
6+
import { ConsoleOutputDisplay } from "./shared/ConsoleOutput";
7+
import { NestedToolsContainer } from "./shared/NestedToolsContainer";
8+
import type { CodeExecutionResult, NestedToolCall } from "./shared/codeExecutionTypes";
9+
import { cn } from "@/common/lib/utils";
10+
11+
interface CodeExecutionToolCallProps {
12+
args: { code: string };
13+
result?: CodeExecutionResult;
14+
status?: ToolStatus;
15+
/** Nested tool calls from streaming (takes precedence over result.toolCalls) */
16+
nestedCalls?: NestedToolCall[];
17+
}
18+
19+
// Threshold for auto-collapsing long results (characters)
20+
const LONG_RESULT_THRESHOLD = 200;
21+
22+
export const CodeExecutionToolCall: React.FC<CodeExecutionToolCallProps> = ({
23+
args,
24+
result,
25+
status = "pending",
26+
nestedCalls,
27+
}) => {
28+
const [codeExpanded, setCodeExpanded] = useState(false);
29+
const [consoleExpanded, setConsoleExpanded] = useState(false);
30+
31+
// Format result for display
32+
const formattedResult = useMemo(() => {
33+
if (!result?.success || result.result === undefined) return null;
34+
return typeof result.result === "string"
35+
? result.result
36+
: JSON.stringify(result.result, null, 2);
37+
}, [result]);
38+
39+
// Auto-expand result if it's short
40+
const isLongResult = formattedResult ? formattedResult.length > LONG_RESULT_THRESHOLD : false;
41+
const [resultExpanded, setResultExpanded] = useState(!isLongResult);
42+
43+
// Use streaming nested calls if available, otherwise fall back to result
44+
const toolCalls = nestedCalls ?? [];
45+
const consoleOutput = result?.consoleOutput ?? [];
46+
const hasToolCalls = toolCalls.length > 0;
47+
const isComplete = status === "completed" || status === "failed";
48+
49+
return (
50+
<fieldset className="border-foreground/20 flex flex-col gap-3 rounded-lg border border-dashed px-3 pt-2 pb-3">
51+
{/* Legend title with status - sits on the border */}
52+
<legend className="flex items-center gap-2 px-2">
53+
<span className="text-foreground text-sm font-medium">Code Execution</span>
54+
<span className="text-muted text-xs">{getStatusDisplay(status)}</span>
55+
</legend>
56+
57+
{/* Code - collapsible toggle */}
58+
<div>
59+
<button
60+
type="button"
61+
onClick={() => setCodeExpanded(!codeExpanded)}
62+
className="text-muted hover:text-foreground flex items-center gap-1.5 text-xs transition-colors"
63+
>
64+
<span
65+
className={cn(
66+
"text-[10px] transition-transform duration-150",
67+
codeExpanded && "rotate-90"
68+
)}
69+
>
70+
71+
</span>
72+
<CodeIcon className="h-3 w-3" />
73+
<span>Show code</span>
74+
</button>
75+
{codeExpanded && (
76+
<div className="border-foreground/10 bg-code-bg mt-2 rounded border p-2">
77+
<HighlightedCode language="javascript" code={args.code} />
78+
</div>
79+
)}
80+
</div>
81+
82+
{/* Console Output - collapsible toggle */}
83+
<div>
84+
<button
85+
type="button"
86+
onClick={() => setConsoleExpanded(!consoleExpanded)}
87+
className="text-muted hover:text-foreground flex items-center gap-1.5 text-xs transition-colors"
88+
>
89+
<span
90+
className={cn(
91+
"text-[10px] transition-transform duration-150",
92+
consoleExpanded && "rotate-90"
93+
)}
94+
>
95+
96+
</span>
97+
<TerminalIcon className="h-3 w-3" />
98+
<span>Console output</span>
99+
{consoleOutput.length > 0 && <span className="text-muted">({consoleOutput.length})</span>}
100+
</button>
101+
{consoleExpanded && (
102+
<div className="border-foreground/10 bg-code-bg mt-2 rounded border p-2">
103+
{consoleOutput.length > 0 ? (
104+
<ConsoleOutputDisplay output={consoleOutput} />
105+
) : (
106+
<span className="text-muted text-xs italic">No output</span>
107+
)}
108+
</div>
109+
)}
110+
</div>
111+
112+
{/* Nested tool calls - stream in the middle */}
113+
{hasToolCalls && <NestedToolsContainer calls={toolCalls} />}
114+
115+
{/* Result/Error - shown when complete */}
116+
{isComplete && result && (
117+
<div>
118+
<button
119+
type="button"
120+
onClick={() => setResultExpanded(!resultExpanded)}
121+
className={cn(
122+
"flex items-center gap-1.5 text-xs transition-colors",
123+
result.success
124+
? "text-green-400 hover:text-green-300"
125+
: "text-red-400 hover:text-red-300"
126+
)}
127+
>
128+
<span
129+
className={cn(
130+
"text-[10px] transition-transform duration-150",
131+
resultExpanded && "rotate-90"
132+
)}
133+
>
134+
135+
</span>
136+
{result.success ? (
137+
<CheckCircleIcon className="h-3 w-3" />
138+
) : (
139+
<XCircleIcon className="h-3 w-3" />
140+
)}
141+
<span>{result.success ? "Result" : "Error"}</span>
142+
</button>
143+
{resultExpanded &&
144+
(result.success ? (
145+
formattedResult ? (
146+
<DetailContent className="mt-2 p-2">{formattedResult}</DetailContent>
147+
) : (
148+
<div className="text-muted mt-2 text-xs italic">(no return value)</div>
149+
)
150+
) : (
151+
<DetailContent className="mt-2 border border-red-500/30 bg-red-500/10 p-2 text-red-400">
152+
{result.error}
153+
</DetailContent>
154+
))}
155+
</div>
156+
)}
157+
</fieldset>
158+
);
159+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React from "react";
2+
import type { ConsoleRecord } from "./codeExecutionTypes";
3+
4+
interface ConsoleOutputDisplayProps {
5+
output: ConsoleRecord[];
6+
}
7+
8+
// Use CSS variables from globals.css
9+
const levelStyles: Record<string, React.CSSProperties> = {
10+
log: { color: "var(--color-muted-foreground)" },
11+
warn: { color: "var(--color-warning, #f59e0b)" },
12+
error: { color: "var(--color-error, #ef4444)" },
13+
};
14+
15+
export const ConsoleOutputDisplay: React.FC<ConsoleOutputDisplayProps> = ({ output }) => {
16+
return (
17+
<div className="space-y-0.5 font-mono text-[11px]">
18+
{output.map((record, i) => (
19+
<div key={i} className="flex gap-2" style={levelStyles[record.level]}>
20+
<span className="opacity-60">[{record.level}]</span>
21+
<span>
22+
{record.args.map((arg, j) => {
23+
// Handle all types to avoid Object.toString() issues
24+
let display: string;
25+
if (arg === null) {
26+
display = "null";
27+
} else if (arg === undefined) {
28+
display = "undefined";
29+
} else if (typeof arg === "string") {
30+
display = arg;
31+
} else if (typeof arg === "number" || typeof arg === "boolean") {
32+
display = String(arg);
33+
} else {
34+
// objects, arrays, symbols, functions - JSON.stringify handles them all
35+
display = JSON.stringify(arg);
36+
}
37+
return (
38+
<span key={j}>
39+
{display}
40+
{j < record.args.length - 1 ? " " : ""}
41+
</span>
42+
);
43+
})}
44+
</span>
45+
</div>
46+
))}
47+
</div>
48+
);
49+
};

0 commit comments

Comments
 (0)