History

Merge pull request #102 from RhysSullivan/worktree-tools-view-redesign

Redesign tools detail view with interactive schema explorer

Rhys Sullivan committed 7 months ago

Commit 7edf1d72 parents 2c75d0e 0f70f7810 files · +1001 −166Browse files

Unified · Split

10 files
  1. apps/cloud/src/api.ts +3 −0
  2. apps/local/src/routes/__root.tsx +3 −0
  3. apps/local/src/server/main.ts +2 −0
  4. packages/core/api/src/tools/api.ts +2 −0
  5. packages/core/sdk/src/in-memory/tool-registry.ts +9 −1
  6. packages/core/sdk/src/tools.ts +2 −0
  7. packages/core/storage-file/src/tool-registry.ts +9 −1
  8. packages/react/src/components/expandable-code-block.tsx +380 −0
  9. packages/react/src/components/schema-explorer.tsx +443 −0
  10. packages/react/src/components/tool-detail.tsx +148 −164
apps/cloud/src/api.ts+3−0
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 HttpApiBuilder,
88 HttpApiSwagger,
99 HttpMiddleware,
10+ HttpRouter,
1011 HttpServer,
1112 } from "@effect/platform";
1213 import { Effect, Layer, Scope } from "effect";
@@ -76,6 +77,7 @@ const SharedServicesMemoized = Effect.runSync( · 63 unmodified lines
7677 const publicApiHandler = HttpApiBuilder.toWebHandler(
7778 PublicCloudApiLive.pipe(
7879 Layer.provideMerge(SharedServicesMemoized),
80+ Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })),
7981 ),
8082 { middleware: HttpMiddleware.logger },
8183 );
@@ -184,6 +186,7 @@ const createProtectedHandler = ( · 102 unmodified lines
184186 Layer.provideMerge(ProtectedCloudApiLive),
185187 Layer.provideMerge(requestServices),
186188 Layer.provideMerge(SharedServicesMemoized),
189+ Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })),
187190 ),
188191 { middleware: HttpMiddleware.logger },
189192 );
apps/local/src/routes/__root.tsx+3−0
@@ -17,6 +17,9 @@ export const Route = createRootRoute({ · 16 unmodified lines
1717 { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=Instrument+Serif&family=JetBrains+Mono:wght@400;500&display=swap" },
1818 { rel: "stylesheet", href: appCss },
1919 ],
20+ scripts: import.meta.env.DEV
21+ ? [{ src: "https://ui.sh/ui-picker.js" }]
22+ : [],
2023 }),
2124 component: RootComponent,
2225 shellComponent: RootDocument,
apps/local/src/server/main.ts+2−0
@@ -2,6 +2,7 @@ import { · 1 unmodified line
22 HttpApiBuilder,
33 HttpApiSwagger,
44 HttpMiddleware,
5+ HttpRouter,
56 HttpServer,
67 } from "@effect/platform";
78 import { Context, Effect, Layer, ManagedRuntime } from "effect";
@@ -76,6 +77,7 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 68 unmodified lines
7677 Layer.provideMerge(Layer.succeed(ExecutorService, executor)),
7778 Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)),
7879 Layer.provideMerge(HttpServer.layerContext),
80+ Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })),
7981 ),
8082 { middleware: HttpMiddleware.logger },
8183 );
packages/core/api/src/tools/api.ts+2−0
@@ -33,6 +33,8 @@ const ToolSchemaResponse = Schema.Struct({ · 32 unmodified lines
3333 typeScriptDefinitions: Schema.optional(
3434 Schema.Record({ key: Schema.String, value: Schema.String }),
3535 ),
36+ inputSchema: Schema.optional(Schema.Unknown),
37+ outputSchema: Schema.optional(Schema.Unknown),
3638 });
3739
3840 // ---------------------------------------------------------------------------
packages/core/sdk/src/in-memory/tool-registry.ts+9−1
@@ -9,7 +9,7 @@ import type { · 8 unmodified lines
99 InvokeOptions,
1010 RuntimeToolHandler,
1111 } from "../tools";
12−import { normalizeRefs } from "../schema-refs";
12+import { normalizeRefs, reattachDefs } from "../schema-refs";
1313 import { buildToolTypeScriptPreview } from "../schema-types";
1414
1515 export const makeInMemoryToolRegistry = () => {
@@ -68,11 +68,19 @@ export const makeInMemoryToolRegistry = () => { · 52 unmodified lines
6868 inputSchema: t.inputSchema,
6969 outputSchema: t.outputSchema,
7070 defs,
71+ options: {
72+ maxLength: Infinity,
73+ maxProperties: Infinity,
74+ maxCompositeMembers: Infinity,
75+ maxRefDepth: 20,
76+ },
7177 });
7278
7379 return {
7480 id: t.id,
7581 ...typeScriptPreview,
82+ inputSchema: t.inputSchema ? reattachDefs(t.inputSchema, defs) : undefined,
83+ outputSchema: t.outputSchema ? reattachDefs(t.outputSchema, defs) : undefined,
7684 };
7785 }),
7886 ),
packages/core/sdk/src/tools.ts+2−0
@@ -36,6 +36,8 @@ export class ToolSchema extends Schema.Class<ToolSchema>("ToolSchema")({ · 35 unmodified lines
3636 typeScriptDefinitions: Schema.optional(
3737 Schema.Record({ key: Schema.String, value: Schema.String }),
3838 ),
39+ inputSchema: Schema.optional(Schema.Unknown),
40+ outputSchema: Schema.optional(Schema.Unknown),
3941 }) {}
4042
4143 export class ToolInvocationResult extends Schema.Class<ToolInvocationResult>(
packages/core/storage-file/src/tool-registry.ts+9−1
@@ -12,7 +12,7 @@ import type { · 11 unmodified lines
1212 InvokeOptions,
1313 RuntimeToolHandler,
1414 } from "@executor/sdk";
15−import { buildToolTypeScriptPreview } from "@executor/sdk";
15+import { buildToolTypeScriptPreview, reattachDefs } from "@executor/sdk";
1616
1717 // ---------------------------------------------------------------------------
1818 // Serialization — leverage ToolRegistration Schema.Class directly
@@ -101,11 +101,19 @@ export const makeKvToolRegistry = ( · 82 unmodified lines
101101 inputSchema: t.inputSchema,
102102 outputSchema: t.outputSchema,
103103 defs,
104+ options: {
105+ maxLength: Infinity,
106+ maxProperties: Infinity,
107+ maxCompositeMembers: Infinity,
108+ maxRefDepth: 20,
109+ },
104110 });
105111
106112 return {
107113 id: t.id,
108114 ...typeScriptPreview,
115+ inputSchema: t.inputSchema ? reattachDefs(t.inputSchema, defs) : undefined,
116+ outputSchema: t.outputSchema ? reattachDefs(t.outputSchema, defs) : undefined,
109117 };
110118 }),
111119
packages/react/src/components/expandable-code-block.tsx+380−0
@@ -0,0 +1,380 @@
1+import { useCallback, useEffect, useMemo, useState, startTransition } from "react";
2+import { getHighlighter, THEME } from "../lib/shiki";
3+import { cn } from "../lib/utils";
4+import type { ThemedToken } from "shiki/core";
5+
6+// ---------------------------------------------------------------------------
7+// Types
8+// ---------------------------------------------------------------------------
9+
10+type Definition = {
11+ readonly name: string;
12+ /** The full type body string, e.g. `{ login: string; id: number }` */
13+ readonly code: string;
14+};
15+
16+// ---------------------------------------------------------------------------
17+// Simple TypeScript formatter — expands single-line types into readable form
18+// ---------------------------------------------------------------------------
19+
20+const formatTypeScript = (code: string): string => {
21+ let result = "";
22+ let indent = 0;
23+ let i = 0;
24+ const len = code.length;
25+
26+ // Track brace depth so we only break `|` and `&` at the top level of a union
27+ let braceDepth = 0;
28+
29+ while (i < len) {
30+ const ch = code[i]!;
31+
32+ if (ch === "{") {
33+ braceDepth++;
34+ indent++;
35+ result += "{\n" + " ".repeat(indent);
36+ i++;
37+ while (i < len && code[i] === " ") i++;
38+ } else if (ch === "}") {
39+ braceDepth = Math.max(0, braceDepth - 1);
40+ indent = Math.max(0, indent - 1);
41+ result += "\n" + " ".repeat(indent) + "}";
42+ i++;
43+ } else if (ch === ";" && i + 1 < len && code[i + 1] === " ") {
44+ result += ";\n" + " ".repeat(indent);
45+ i += 2;
46+ } else if (ch === ";" && indent > 0) {
47+ result += ";\n" + " ".repeat(indent);
48+ i++;
49+ while (i < len && code[i] === " ") i++;
50+ } else if (ch === "|" && i > 0 && code[i - 1] === " " && i + 1 < len && code[i + 1] === " ") {
51+ // Union operator ` | ` — break onto new line
52+ result += "\n" + " ".repeat(indent) + "| ";
53+ i += 2; // skip the operator and trailing space
54+ } else {
55+ result += ch;
56+ i++;
57+ }
58+ }
59+
60+ return result;
61+};
62+
63+// ---------------------------------------------------------------------------
64+// Copy icons
65+// ---------------------------------------------------------------------------
66+
67+const CopyIcon = () => (
68+ <svg viewBox="0 0 16 16" className="size-3">
69+ <rect x="5" y="5" width="8" height="8" rx="1" fill="none" stroke="currentColor" strokeWidth="1.2" />
70+ <path d="M3 11V3h8" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
71+ </svg>
72+);
73+
74+const CheckIcon = () => (
75+ <svg viewBox="0 0 16 16" className="size-3">
76+ <path d="M3 8l3 3 7-7" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
77+ </svg>
78+);
79+
80+// ---------------------------------------------------------------------------
81+// Shiki tokenization hook — non-blocking
82+// ---------------------------------------------------------------------------
83+
84+function useTokens(code: string): ThemedToken[][] | null {
85+ const [tokens, setTokens] = useState<ThemedToken[][] | null>(null);
86+
87+ useEffect(() => {
88+ let cancelled = false;
89+ getHighlighter().then((highlighter) => {
90+ if (cancelled) return;
91+ const result = highlighter.codeToTokens(code, {
92+ lang: "typescript",
93+ theme: THEME,
94+ });
95+ if (!cancelled) {
96+ startTransition(() => setTokens(result.tokens));
97+ }
98+ });
99+ return () => { cancelled = true; };
100+ }, [code]);
101+
102+ return tokens;
103+}
104+
105+// ---------------------------------------------------------------------------
106+// Inline-expand logic
107+//
108+// When a user clicks a ref name like `NonEmptyTrimmedString`, we replace
109+// that name with the resolved body inline. So `firstName: NonEmptyTrimmedString`
110+// becomes `firstName: string`.
111+//
112+// We rebuild the full code string with expansions applied, then re-tokenize.
113+// ---------------------------------------------------------------------------
114+
115+/**
116+ * Given a code string, a set of ref names to expand, and their definitions,
117+ * produce a new code string with each expanded ref replaced by its body.
118+ * Applies recursively — if an expanded body itself contains expanded refs,
119+ * those get replaced too. Tracks ancestors to prevent infinite loops.
120+ */
121+const applyExpansions = (
122+ code: string,
123+ expanded: ReadonlySet<string>,
124+ definitions: ReadonlyMap<string, string>,
125+ ancestors: ReadonlySet<string>,
126+): string => {
127+ if (expanded.size === 0) return code;
128+
129+ let result = "";
130+ let i = 0;
131+
132+ // Sort expanded names longest-first to match greedily
133+ const sortedNames = [...expanded].filter((n) => !ancestors.has(n)).sort((a, b) => b.length - a.length);
134+
135+ while (i < code.length) {
136+ let matched = false;
137+
138+ for (const name of sortedNames) {
139+ if (code.startsWith(name, i)) {
140+ const body = definitions.get(name);
141+ if (body) {
142+ // Recursively expand the body, adding this name to ancestors
143+ const childAncestors = new Set(ancestors);
144+ childAncestors.add(name);
145+ result += applyExpansions(body, expanded, definitions, childAncestors);
146+ i += name.length;
147+ matched = true;
148+ break;
149+ }
150+ }
151+ }
152+
153+ if (!matched) {
154+ result += code[i];
155+ i++;
156+ }
157+ }
158+
159+ return result;
160+};
161+
162+// ---------------------------------------------------------------------------
163+// Split tokens to find ref names that can be clicked
164+// ---------------------------------------------------------------------------
165+
166+type RenderToken =
167+ | { kind: "text"; content: string; color?: string }
168+ | { kind: "ref"; name: string; color?: string };
169+
170+const splitToken = (
171+ token: ThemedToken,
172+ clickableNames: ReadonlySet<string>,
173+): RenderToken[] => {
174+ if (clickableNames.size === 0) {
175+ return [{ kind: "text", content: token.content, color: token.color }];
176+ }
177+
178+ const text = token.content;
179+ const results: RenderToken[] = [];
180+ let remaining = text;
181+
182+ while (remaining.length > 0) {
183+ let earliest = -1;
184+ let matchedName = "";
185+ for (const name of clickableNames) {
186+ const idx = remaining.indexOf(name);
187+ if (idx !== -1 && (earliest === -1 || idx < earliest || (idx === earliest && name.length > matchedName.length))) {
188+ earliest = idx;
189+ matchedName = name;
190+ }
191+ }
192+
193+ if (earliest === -1) {
194+ results.push({ kind: "text", content: remaining, color: token.color });
195+ break;
196+ }
197+
198+ if (earliest > 0) {
199+ results.push({ kind: "text", content: remaining.slice(0, earliest), color: token.color });
200+ }
201+ results.push({ kind: "ref", name: matchedName, color: token.color });
202+ remaining = remaining.slice(earliest + matchedName.length);
203+ }
204+
205+ return results;
206+};
207+
208+// ---------------------------------------------------------------------------
209+// HighlightedCode — renders tokenized code with clickable unexpanded refs
210+// ---------------------------------------------------------------------------
211+
212+function HighlightedCode(props: {
213+ tokens: ThemedToken[][];
214+ /** Ref names that are NOT yet expanded and can be clicked */
215+ clickableNames: ReadonlySet<string>;
216+ onToggle: (name: string) => void;
217+ expanded: ReadonlySet<string>;
218+}) {
219+ const { tokens, clickableNames, onToggle, expanded } = props;
220+
221+ return (
222+ <>
223+ {tokens.map((line, li) => (
224+ <span key={li}>
225+ {li > 0 && "\n"}
226+ {line.map((token, ti) => {
227+ const parts = splitToken(token, clickableNames);
228+ return parts.map((part, pi) => {
229+ if (part.kind === "text") {
230+ return (
231+ <span key={`${ti}-${pi}`} style={part.color ? { color: part.color } : undefined}>
232+ {part.content}
233+ </span>
234+ );
235+ }
236+
237+ const isExpanded = expanded.has(part.name);
238+
239+ return (
240+ <span
241+ key={`${ti}-${pi}`}
242+ role="button"
243+ tabIndex={0}
244+ onClick={(e) => { e.stopPropagation(); onToggle(part.name); }}
245+ onKeyDown={(e) => {
246+ if (e.key === "Enter" || e.key === " ") {
247+ e.preventDefault();
248+ onToggle(part.name);
249+ }
250+ }}
251+ className={cn(
252+ "cursor-pointer underline underline-offset-2 hover:opacity-80",
253+ isExpanded
254+ ? "decoration-current/50"
255+ : "decoration-current/30",
256+ )}
257+ style={part.color ? { color: part.color } : undefined}
258+ title={isExpanded ? `Collapse ${part.name}` : `Expand ${part.name}`}
259+ >
260+ {part.name}
261+ </span>
262+ );
263+ });
264+ })}
265+ </span>
266+ ))}
267+ </>
268+ );
269+}
270+
271+// ---------------------------------------------------------------------------
272+// ExpandableCodeBlock — main export
273+// ---------------------------------------------------------------------------
274+
275+export function ExpandableCodeBlock(props: {
276+ code: string;
277+ definitions?: readonly Definition[];
278+ className?: string;
279+}) {
280+ const { code, definitions = [], className } = props;
281+ // Auto-expand trivial aliases (primitives, simple unions, string literals)
282+ const trivialNames = useMemo(() => {
283+ const trivial = new Set<string>();
284+ const trivialPattern = /^(?:string|number|boolean|null|undefined|void|never|unknown|any|true|false|(?:"[^"]*"(?:\s*\|\s*"[^"]*")*))$/;
285+ for (const d of definitions) {
286+ const body = d.code.trim();
287+ if (trivialPattern.test(body)) {
288+ trivial.add(d.name);
289+ }
290+ }
291+ return trivial;
292+ }, [definitions]);
293+
294+ const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
295+ const [copied, setCopied] = useState(false);
296+
297+ const definitionMap = useMemo(
298+ () => new Map(definitions.map((d) => [d.name, d.code])),
299+ [definitions],
300+ );
301+
302+ const definitionNames = useMemo(
303+ () => new Set(definitions.map((d) => d.name)),
304+ [definitions],
305+ );
306+
307+ // Names that can be clicked — exclude trivial aliases (already inlined)
308+ const clickableNames = useMemo(() => {
309+ const names = new Set(definitionNames);
310+ for (const name of trivialNames) names.delete(name);
311+ return names;
312+ }, [definitionNames, trivialNames]);
313+
314+ const emptyAncestors = useMemo(() => new Set<string>(), []);
315+
316+ // Merge user-expanded + trivial auto-expanded
317+ const allExpanded = useMemo(() => {
318+ const merged = new Set(expanded);
319+ for (const name of trivialNames) merged.add(name);
320+ return merged;
321+ }, [expanded, trivialNames]);
322+
323+ // Build the display code: start with raw code, apply expansions, then format
324+ const displayCode = useMemo(() => {
325+ const withExpansions = applyExpansions(code, allExpanded, definitionMap, emptyAncestors);
326+ return formatTypeScript(withExpansions);
327+ }, [code, allExpanded, definitionMap, emptyAncestors]);
328+
329+ const tokens = useTokens(displayCode);
330+
331+ const handleToggle = useCallback((name: string) => {
332+ setExpanded((prev) => {
333+ const next = new Set(prev);
334+ if (next.has(name)) {
335+ next.delete(name);
336+ } else {
337+ next.add(name);
338+ }
339+ return next;
340+ });
341+ }, []);
342+
343+ const handleCopy = useCallback(() => {
344+ void navigator.clipboard.writeText(displayCode).then(() => {
345+ setCopied(true);
346+ setTimeout(() => setCopied(false), 1500);
347+ });
348+ }, [displayCode]);
349+
350+ return (
351+ <div className={cn("rounded-lg border border-border/40 bg-card/60 overflow-hidden", className)}>
352+ <div className="group relative">
353+ <button
354+ type="button"
355+ onClick={handleCopy}
356+ className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 p-1.5 text-muted-foreground/40 opacity-0 backdrop-blur-sm hover:text-foreground group-hover:opacity-100 transition-opacity"
357+ title="Copy to clipboard"
358+ >
359+ {copied ? <CheckIcon /> : <CopyIcon />}
360+ </button>
361+
362+ <pre className="overflow-auto p-3 font-mono text-[0.75rem] leading-6 !bg-transparent">
363+ <code>
364+ {tokens
365+ ? (
366+ <HighlightedCode
367+ tokens={tokens}
368+ clickableNames={clickableNames}
369+ onToggle={handleToggle}
370+ expanded={allExpanded}
371+ />
372+ )
373+ : <span className="text-foreground/60">{displayCode}</span>
374+ }
375+ </code>
376+ </pre>
377+ </div>
378+ </div>
379+ );
380+}
packages/react/src/components/schema-explorer.tsx+443−0
@@ -0,0 +1,443 @@
1+import { useState, useCallback } from "react";
2+import { ChevronRight } from "lucide-react";
3+
4+// ---------------------------------------------------------------------------
5+// JSON Schema types (subset we render)
6+// ---------------------------------------------------------------------------
7+
8+type JsonSchema = {
9+ type?: string | string[];
10+ properties?: Record<string, JsonSchema>;
11+ required?: string[];
12+ items?: JsonSchema | JsonSchema[];
13+ additionalProperties?: boolean | JsonSchema;
14+ oneOf?: JsonSchema[];
15+ anyOf?: JsonSchema[];
16+ allOf?: JsonSchema[];
17+ enum?: unknown[];
18+ const?: unknown;
19+ $ref?: string;
20+ $defs?: Record<string, JsonSchema>;
21+ description?: string;
22+ title?: string;
23+ default?: unknown;
24+ nullable?: boolean;
25+ format?: string;
26+};
27+
28+// ---------------------------------------------------------------------------
29+// Ref resolution — lazy, only on expand
30+// ---------------------------------------------------------------------------
31+
32+const resolveRef = (ref: string, root: JsonSchema): JsonSchema | null => {
33+ const name = ref.match(/^#\/\$defs\/(.+)$/)?.[1];
34+ if (!name || !root.$defs) return null;
35+ return root.$defs[name] ?? null;
36+};
37+
38+const getRefName = (ref: string): string | undefined =>
39+ ref.match(/^#\/\$defs\/(.+)$/)?.[1];
40+
41+/**
42+ * Fully resolve a schema, following $ref and unwrapping single-variant
43+ * oneOf/anyOf so we can inspect the concrete shape.
44+ */
45+const deepResolve = (schema: JsonSchema, root: JsonSchema): JsonSchema => {
46+ let s = schema;
47+ if (s.$ref) {
48+ const resolved = resolveRef(s.$ref, root);
49+ if (resolved) s = resolved;
50+ }
51+ // Unwrap single-variant unions
52+ if (s.oneOf?.length === 1) s = deepResolve(s.oneOf[0]!, root);
53+ if (s.anyOf?.length === 1) s = deepResolve(s.anyOf[0]!, root);
54+ return s;
55+};
56+
57+// ---------------------------------------------------------------------------
58+// Type label — human readable, shows ref names
59+// ---------------------------------------------------------------------------
60+
61+const getTypeLabel = (schema: JsonSchema, root: JsonSchema): string => {
62+ if (schema.$ref) {
63+ return getRefName(schema.$ref) ?? "ref";
64+ }
65+
66+ if (schema.const !== undefined) return JSON.stringify(schema.const);
67+
68+ if (schema.enum) {
69+ if (schema.enum.length <= 3) {
70+ return schema.enum.map((v) => JSON.stringify(v)).join(" | ");
71+ }
72+ return `enum (${schema.enum.length})`;
73+ }
74+
75+ if (schema.oneOf) {
76+ if (schema.oneOf.length === 1) return getTypeLabel(schema.oneOf[0]!, root);
77+ const labels = schema.oneOf.slice(0, 3).map((s) => getTypeLabel(s, root));
78+ if (schema.oneOf.length > 3) labels.push("…");
79+ return labels.join(" | ");
80+ }
81+
82+ if (schema.anyOf) {
83+ if (schema.anyOf.length === 1) return getTypeLabel(schema.anyOf[0]!, root);
84+ const labels = schema.anyOf.slice(0, 3).map((s) => getTypeLabel(s, root));
85+ if (schema.anyOf.length > 3) labels.push("…");
86+ return labels.join(" | ");
87+ }
88+
89+ if (schema.allOf) {
90+ // If any allOf member is a named ref, show that
91+ for (const s of schema.allOf) {
92+ if (s.$ref) {
93+ const name = getRefName(s.$ref);
94+ if (name) return name;
95+ }
96+ }
97+ return "object";
98+ }
99+
100+ const types = Array.isArray(schema.type)
101+ ? schema.type
102+ : schema.type
103+ ? [schema.type]
104+ : [];
105+
106+ if (types.includes("array")) {
107+ if (schema.items && !Array.isArray(schema.items)) {
108+ return `${getTypeLabel(schema.items, root)}[]`;
109+ }
110+ return "array";
111+ }
112+
113+ if (types.includes("object")) {
114+ if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
115+ return `Record<string, ${getTypeLabel(schema.additionalProperties, root)}>`;
116+ }
117+ return "object";
118+ }
119+
120+ if (types.length === 1) {
121+ const t = types[0]!;
122+ if (schema.format) return `${t}<${schema.format}>`;
123+ return t;
124+ }
125+ if (types.length > 1) return types.join(" | ");
126+
127+ return "any";
128+};
129+
130+// ---------------------------------------------------------------------------
131+// Expandability check — conservative, resolves deeply
132+// ---------------------------------------------------------------------------
133+
134+const getChildCount = (schema: JsonSchema, root: JsonSchema): number => {
135+ const s = deepResolve(schema, root);
136+
137+ if (s.properties) return Object.keys(s.properties).length;
138+
139+ if (s.items && !Array.isArray(s.items)) {
140+ const itemResolved = deepResolve(s.items, root);
141+ if (itemResolved.properties) return Object.keys(itemResolved.properties).length;
142+ return 0;
143+ }
144+
145+ if (s.allOf) {
146+ const merged = mergeAllOf(s.allOf, root);
147+ if (merged.properties) return Object.keys(merged.properties).length;
148+ return 0;
149+ }
150+
151+ if (s.oneOf && s.oneOf.length > 1) return s.oneOf.length;
152+ if (s.anyOf && s.anyOf.length > 1) return s.anyOf.length;
153+
154+ if (s.additionalProperties && typeof s.additionalProperties === "object") return 1;
155+
156+ return 0;
157+};
158+
159+const isExpandable = (schema: JsonSchema, root: JsonSchema): boolean =>
160+ getChildCount(schema, root) > 0;
161+
162+// ---------------------------------------------------------------------------
163+// Merge allOf
164+// ---------------------------------------------------------------------------
165+
166+const mergeAllOf = (schemas: JsonSchema[], root: JsonSchema): JsonSchema => {
167+ const merged: JsonSchema = { type: "object", properties: {}, required: [] };
168+ for (const s of schemas) {
169+ const resolved = deepResolve(s, root);
170+ if (resolved.properties) {
171+ merged.properties = { ...merged.properties, ...resolved.properties };
172+ }
173+ if (resolved.required) {
174+ merged.required = [...(merged.required ?? []), ...resolved.required];
175+ }
176+ if (resolved.description && !merged.description) {
177+ merged.description = resolved.description;
178+ }
179+ }
180+ return merged;
181+};
182+
183+// ---------------------------------------------------------------------------
184+// Type label styling — plain text, no colored pills
185+// ---------------------------------------------------------------------------
186+
187+const typeClasses = "font-mono text-[0.6875rem] leading-5 text-muted-foreground/50";
188+
189+// ---------------------------------------------------------------------------
190+// PropertyRow
191+// ---------------------------------------------------------------------------
192+
193+function PropertyRow(props: {
194+ name: string;
195+ schema: JsonSchema;
196+ root: JsonSchema;
197+ required: boolean;
198+ depth: number;
199+ isLast?: boolean;
200+ /** Hide the required/optional badge entirely (e.g. for union variants) */
201+ hideRequiredBadge?: boolean;
202+}) {
203+ const { name, schema, root, required, depth, hideRequiredBadge } = props;
204+ const [open, setOpen] = useState(false);
205+ const [resolved, setResolved] = useState<JsonSchema | null>(null);
206+
207+ const expandable = isExpandable(schema, root);
208+ const typeLabel = getTypeLabel(schema, root);
209+ const description = schema.description
210+ ?? (schema.$ref ? (resolveRef(schema.$ref, root)?.description) : undefined);
211+
212+ const handleToggle = useCallback(() => {
213+ if (!open && !resolved && schema.$ref) {
214+ setResolved(resolveRef(schema.$ref, root));
215+ }
216+ setOpen((v) => !v);
217+ }, [open, resolved, schema, root]);
218+
219+ const childSchema = schema.$ref
220+ ? resolved ?? resolveRef(schema.$ref, root) ?? schema
221+ : schema;
222+
223+ return (
224+ <div>
225+ <div
226+ role={expandable ? "button" : undefined}
227+ tabIndex={expandable ? 0 : undefined}
228+ onClick={expandable ? handleToggle : undefined}
229+ onKeyDown={
230+ expandable
231+ ? (e) => {
232+ if (e.key === "Enter" || e.key === " ") {
233+ e.preventDefault();
234+ handleToggle();
235+ }
236+ }
237+ : undefined
238+ }
239+ className={[
240+ "flex items-start gap-2 py-2.5 px-3",
241+ expandable
242+ ? "cursor-pointer hover:bg-accent/30 transition-colors"
243+ : "",
244+ ].join(" ")}
245+ style={depth > 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}
246+ >
247+ {/* Chevron or dot */}
248+ <div className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
249+ {expandable ? (
250+ <ChevronRight
251+ className="size-3 shrink-0 text-muted-foreground/30 transition-transform duration-150"
252+ style={open ? { transform: "rotate(90deg)" } : undefined}
253+ />
254+ ) : (
255+ <span className="size-1 rounded-full bg-muted-foreground/15" />
256+ )}
257+ </div>
258+
259+ {/* Content */}
260+ <div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">
261+ <p className="font-medium text-foreground text-sm">{name}</p>
262+ <p className={typeClasses}>{typeLabel}</p>
263+ {!hideRequiredBadge && (
264+ required ? (
265+ <p className="text-[0.6875rem] leading-5 text-orange-600/60 dark:text-orange-400/60">
266+ required
267+ </p>
268+ ) : (
269+ <p className="text-[0.6875rem] leading-5 text-muted-foreground/25">
270+ optional
271+ </p>
272+ )
273+ )}
274+ {schema.default !== undefined && (
275+ <p className="text-[0.6875rem] leading-5 text-muted-foreground/30">
276+ = {JSON.stringify(schema.default)}
277+ </p>
278+ )}
279+ </div>
280+ </div>
281+
282+ {/* Description — below the row */}
283+ {description && (
284+ <p
285+ className="px-3 pb-2 text-[0.8125rem] leading-5 text-muted-foreground/50"
286+ style={{ paddingLeft: `${(depth * 16) + 32}px` }}
287+ >
288+ {description}
289+ </p>
290+ )}
291+
292+ {/* Children — rendered lazily on expand */}
293+ {open && expandable && (
294+ <div
295+ className="border-l border-border/30"
296+ style={{ marginLeft: `${depth * 16 + 20}px` }}
297+ >
298+ <PropertyChildren schema={childSchema} root={root} depth={depth + 1} />
299+ </div>
300+ )}
301+ </div>
302+ );
303+}
304+
305+// ---------------------------------------------------------------------------
306+// PropertyChildren — renders sub-properties
307+// ---------------------------------------------------------------------------
308+
309+function PropertyChildren(props: {
310+ schema: JsonSchema;
311+ root: JsonSchema;
312+ depth: number;
313+}) {
314+ const { schema: rawSchema, root, depth } = props;
315+
316+ if (depth > 6) {
317+ return (
318+ <p className="px-3 py-2 text-[0.8125rem] text-muted-foreground/30">
319+ Nested too deep to display.
320+ </p>
321+ );
322+ }
323+
324+ const schema = deepResolve(rawSchema, root);
325+ const required = new Set(schema.required ?? []);
326+
327+ // Object properties
328+ if (schema.properties && Object.keys(schema.properties).length > 0) {
329+ const entries = Object.entries(schema.properties);
330+ entries.sort(([a], [b]) => {
331+ const ar = required.has(a);
332+ const br = required.has(b);
333+ if (ar !== br) return ar ? -1 : 1;
334+ return a.localeCompare(b);
335+ });
336+ return (
337+ <div className="divide-y divide-border/20">
338+ {entries.map(([key, value], i) => (
339+ <PropertyRow
340+ key={key}
341+ name={key}
342+ schema={value}
343+ root={root}
344+ required={required.has(key)}
345+ depth={depth}
346+ isLast={i === entries.length - 1}
347+ />
348+ ))}
349+ </div>
350+ );
351+ }
352+
353+ // Array items
354+ if (schema.items && !Array.isArray(schema.items)) {
355+ const itemSchema = deepResolve(schema.items, root);
356+ if (itemSchema.properties && Object.keys(itemSchema.properties).length > 0) {
357+ return <PropertyChildren schema={itemSchema} root={root} depth={depth} />;
358+ }
359+ return (
360+ <PropertyRow name="items" schema={schema.items} root={root} required depth={depth} />
361+ );
362+ }
363+
364+ // allOf
365+ if (schema.allOf) {
366+ const merged = mergeAllOf(schema.allOf, root);
367+ if (merged.properties && Object.keys(merged.properties).length > 0) {
368+ return <PropertyChildren schema={merged} root={root} depth={depth} />;
369+ }
370+ }
371+
372+ // oneOf / anyOf (only if multiple) — these are choices, not required fields
373+ const variants = schema.oneOf ?? schema.anyOf;
374+ if (variants && variants.length > 1) {
375+ const label = schema.oneOf ? "One of" : "Any of";
376+ return (
377+ <div>
378+ <p
379+ className="px-3 py-2 text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/30"
380+ style={depth > 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}
381+ >
382+ {label}
383+ </p>
384+ <div className="divide-y divide-border/20">
385+ {variants.map((variant, i) => (
386+ <PropertyRow
387+ key={i}
388+ name={variant.title ?? `option ${i + 1}`}
389+ schema={variant}
390+ root={root}
391+ required={false}
392+ depth={depth}
393+ hideRequiredBadge
394+ />
395+ ))}
396+ </div>
397+ </div>
398+ );
399+ }
400+
401+ // Record / additionalProperties
402+ if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
403+ return (
404+ <PropertyRow
405+ name="[key]"
406+ schema={schema.additionalProperties}
407+ root={root}
408+ required
409+ depth={depth}
410+ />
411+ );
412+ }
413+
414+ return null;
415+}
416+
417+// ---------------------------------------------------------------------------
418+// SchemaExplorer — main export
419+// ---------------------------------------------------------------------------
420+
421+export function SchemaExplorer(props: { schema: unknown }) {
422+ const schema = props.schema as JsonSchema | undefined;
423+ if (!schema) return null;
424+
425+ const hasContent = isExpandable(schema, schema);
426+
427+ if (!hasContent) {
428+ const typeLabel = getTypeLabel(schema, schema);
429+ return (
430+ <div className="rounded-lg border border-border/40 px-4 py-3">
431+ <p className="text-sm text-muted-foreground/50">
432+ <span className={typeClasses}>{typeLabel}</span>
433+ </p>
434+ </div>
435+ );
436+ }
437+
438+ return (
439+ <div className="rounded-lg border border-border/40 overflow-hidden divide-y divide-border/20">
440+ <PropertyChildren schema={schema} root={schema} depth={0} />
441+ </div>
442+ );
443+}
packages/react/src/components/tool-detail.tsx+148−164
@@ -2,10 +2,10 @@ import { useMemo, useState } from "react"; · 1 unmodified line
22 import { useAtomValue, Result } from "@effect-atom/atom-react";
33 import { toolSchemaAtom } from "../api/atoms";
44 import { ScopeId, ToolId } from "@executor/sdk";
5−import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./accordion";
6−import { Badge } from "./badge";
7−import { CodeBlock } from "./code-block";
85 import { Markdown } from "./markdown";
6+import { SchemaExplorer } from "./schema-explorer";
7+import { ExpandableCodeBlock } from "./expandable-code-block";
8+import { Copy, Check, ChevronRight } from "lucide-react";
99
1010 // ---------------------------------------------------------------------------
1111 // Copy button
@@ -27,48 +27,32 @@ function CopyButton(props: { text: string; label?: string }) { · 15 unmodified lines
2727 title={props.label ?? "Copy"}
2828 >
2929 {copied ? (
30− <svg viewBox="0 0 16 16" className="size-3.5">
31− <path d="M3 8l3 3 7-7" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
32− </svg>
30+ <Check className="size-3.5 shrink-0" />
3331 ) : (
34− <svg viewBox="0 0 16 16" className="size-3.5">
35− <rect x="5" y="5" width="8" height="8" rx="1" fill="none" stroke="currentColor" strokeWidth="1.2" />
36− <path d="M3 11V3h8" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
37− </svg>
32+ <Copy className="size-3.5 shrink-0" />
3833 )}
3934 </button>
4035 );
4136 }
4237
4338 // ---------------------------------------------------------------------------
44−// Build type declarations from tool TypeScript previews
39+// Helpers
4540 // ---------------------------------------------------------------------------
4641
47−const buildCallSignature = (
48− toolName: string,
49− inputTypeScript?: string,
50− outputTypeScript?: string,
51−): string => {
52− const inputType = inputTypeScript ?? "void";
53− const outputType = outputTypeScript ?? "void";
54−
55− const leaf = toolName.split(".").pop() ?? toolName;
56− return `declare function ${leaf}(input: ${inputType}): ${outputType}`;
42+const friendlyName = (name: string): string => {
43+ const leaf = name.split(".").pop() ?? name;
44+ return leaf
45+ .replace(/([a-z])([A-Z])/g, "$1 $2")
46+ .replace(/[_.-]/g, " ")
47+ .replace(/\b\w/g, (c) => c.toUpperCase());
5748 };
5849
59−const buildPrimaryContract = (input: {
60− toolName: string;
61− inputTypeScript?: string;
62− outputTypeScript?: string;
63−}): string => {
64− const sections = [
65− buildCallSignature(input.toolName, input.inputTypeScript, input.outputTypeScript),
66− input.inputTypeScript ? `type Input = ${input.inputTypeScript}` : "type Input = void",
67− input.outputTypeScript ? `type Output = ${input.outputTypeScript}` : "type Output = void",
68− ];
69−
70− return sections.join("\n\n");
71−};
50+const breadcrumbParts = (name: string): string[] =>
51+ name.split(".").map((p) =>
52+ p.replace(/([a-z])([A-Z])/g, "$1 $2")
53+ .replace(/[_-]/g, " ")
54+ .replace(/\b\w/g, (c) => c.toUpperCase()),
55+ );
7256
7357 // ---------------------------------------------------------------------------
7458 // ToolDetail
@@ -83,162 +67,166 @@ export function ToolDetail(props: { · 8 unmodified lines
8367 const toolContract = useAtomValue(
8468 toolSchemaAtom(props.scopeId, props.toolId as ToolId),
8569 );
70+ const [tab, setTab] = useState<"schema" | "typescript">("schema");
8671
87− const types = useMemo(() => {
72+ const data = useMemo(() => {
8873 if (!Result.isSuccess(toolContract)) return null;
8974 const v = toolContract.value;
9075 const definitions = Object.entries(v.typeScriptDefinitions ?? {}).map(([name, body]) => ({
9176 name,
92− code: `type ${name} = ${body}`,
77+ code: body,
9378 }));
9479
9580 return {
96− inputTypeScript: v.inputTypeScript,
97− outputTypeScript: v.outputTypeScript,
98− contract: buildPrimaryContract({
99− toolName: props.toolName,
100− inputTypeScript: v.inputTypeScript,
101− outputTypeScript: v.outputTypeScript,
102− }),
81+ inputSchema: v.inputSchema,
82+ outputSchema: v.outputSchema,
83+ inputTypeScript: v.inputTypeScript
84+ ? `type Input = ${v.inputTypeScript}`
85+ : null,
86+ outputTypeScript: v.outputTypeScript
87+ ? `type Output = ${v.outputTypeScript}`
88+ : null,
10389 definitions,
10490 };
10591 }, [toolContract, props.toolName]);
10692
107− const namespace = props.toolName.split(".").slice(0, -1).join(".") || "global";
93+ const crumbs = breadcrumbParts(props.toolName);
94+ const displayName = friendlyName(props.toolName);
10895
10996 return (
110− <div className="flex h-full flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(120,119,198,0.08),transparent_32%),linear-gradient(to_bottom,rgba(255,255,255,0.02),transparent_24%)]">
111− <div className="shrink-0 border-b border-border/70 bg-background/90 backdrop-blur-xl">
112− <div className="px-5 py-4">
113− <div className="flex items-start gap-4">
114− <div className="relative mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary shadow-[0_10px_30px_rgba(120,119,198,0.12)]">
115− <div className="absolute inset-0 rounded-2xl bg-[linear-gradient(135deg,rgba(255,255,255,0.14),transparent_55%)]" />
116− <svg viewBox="0 0 16 16" className="relative size-4">
117− <path
118− d="M4 2h8l1 3H3l1-3zM3 6h10v8H3V6z"
119− fill="none"
120− stroke="currentColor"
121− strokeWidth="1.2"
122− />
123− </svg>
97+ <div className="flex h-full flex-col overflow-hidden">
98+ {/* Header + tabs */}
99+ <div className="shrink-0 border-b border-border/40">
100+ <div className="px-5 pt-4 pb-0">
101+ {crumbs.length > 1 && (
102+ <div className="flex items-center gap-1 text-sm text-muted-foreground/40">
103+ {crumbs.slice(0, -1).map((part, i) => (
104+ <span key={i} className="flex items-center gap-1">
105+ {i > 0 && <ChevronRight className="size-3 shrink-0" />}
106+ <span>{part}</span>
107+ </span>
108+ ))}
124109 </div>
125− <div className="min-w-0 flex-1">
126− <div className="flex flex-wrap items-center gap-2">
127− <Badge variant="outline" className="rounded-full border-primary/20 bg-primary/5 px-2.5 py-1 font-mono text-[10px] tracking-[0.18em] text-primary/80 uppercase">
128− {namespace}
129− </Badge>
130− <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.18em] text-muted-foreground/80">
131− {types?.definitions.length ?? 0} shared type{(types?.definitions.length ?? 0) === 1 ? "" : "s"}
132− </Badge>
133− </div>
134− <div className="mt-2 flex items-start gap-2">
135− <h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground font-mono leading-6">
136− {props.toolName}
137− </h3>
138− <CopyButton text={props.toolId} label="Copy tool ID" />
139− </div>
140− {props.toolDescription && (
141− <div className="mt-2 max-w-2xl text-sm text-muted-foreground/90">
142− <Markdown>{props.toolDescription}</Markdown>
143− </div>
144− )}
110+ )}
111+ <div className="mt-1 flex items-center gap-2">
112+ <h3 className="text-base font-semibold text-foreground truncate">
113+ {displayName}
114+ </h3>
115+ <CopyButton text={props.toolId} label="Copy tool ID" />
116+ </div>
117+ {props.toolDescription && (
118+ <div className="mt-1.5 max-w-lg text-sm text-muted-foreground/70 line-clamp-2">
119+ <Markdown>{props.toolDescription}</Markdown>
145120 </div>
121+ )}
122+
123+ {/* Tabs */}
124+ <div className="mt-3 flex gap-4" role="tablist">
125+ <button
126+ type="button"
127+ role="tab"
128+ aria-selected={tab === "schema"}
129+ onClick={() => setTab("schema")}
130+ className={[
131+ "border-b-2 pb-2.5 text-sm font-medium transition-colors",
132+ tab === "schema"
133+ ? "border-primary text-foreground"
134+ : "border-transparent text-muted-foreground/50 hover:text-muted-foreground",
135+ ].join(" ")}
136+ >
137+ Schema
138+ </button>
139+ <button
140+ type="button"
141+ role="tab"
142+ aria-selected={tab === "typescript"}
143+ onClick={() => setTab("typescript")}
144+ className={[
145+ "border-b-2 pb-2.5 text-sm font-medium transition-colors",
146+ tab === "typescript"
147+ ? "border-primary text-foreground"
148+ : "border-transparent text-muted-foreground/50 hover:text-muted-foreground",
149+ ].join(" ")}
150+ >
151+ TypeScript
152+ </button>
146153 </div>
147154 </div>
148155 </div>
149156
157+ {/* Content */}
150158 <div className="flex-1 overflow-y-auto">
151159 {Result.match(toolContract, {
152160 onInitial: () => (
153− <div className="p-5 text-sm text-muted-foreground">Loading tool contract…</div>
161+ <div className="p-5 text-sm text-muted-foreground">Loading…</div>
154162 ),
155163 onFailure: () => (
156− <div className="p-5 text-sm text-destructive">Failed to load tool contract</div>
164+ <div className="p-5 text-sm text-destructive">Something went wrong</div>
157165 ),
158− onSuccess: () => (
159− <div className="space-y-4 px-5 py-4">
160− <div className="rounded-2xl border border-border/70 bg-card/70 shadow-[0_12px_40px_rgba(0,0,0,0.04)] overflow-hidden">
161− <div className="flex items-center justify-between border-b border-border/70 bg-muted/30 px-4 py-3">
162− <div>
163− <p className="text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground/75">
164− Primary contract
165− </p>
166− <p className="mt-1 text-xs text-muted-foreground/80">
167− One compact view for the call signature, input, and output.
168− </p>
166+ onSuccess: () =>
167+ tab === "schema" ? (
168+ <div className="px-5 py-5 space-y-6">
169+ <section>
170+ <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40">
171+ Parameters
172+ </h4>
173+ <div className="mt-2.5">
174+ {data?.inputSchema ? (
175+ <SchemaExplorer schema={data.inputSchema} />
176+ ) : (
177+ <p className="py-2 text-sm text-muted-foreground/40">None</p>
178+ )}
169179 </div>
170− <div className="hidden items-center gap-2 sm:flex">
171− <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.16em]">
172− {types?.inputTypeScript ? "input" : "no input"}
173− </Badge>
174− <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.16em]">
175− {types?.outputTypeScript ? "output" : "no output"}
176− </Badge>
180+ </section>
181+
182+ <section>
183+ <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40">
184+ Response
185+ </h4>
186+ <div className="mt-2.5">
187+ {data?.outputSchema ? (
188+ <SchemaExplorer schema={data.outputSchema} />
189+ ) : (
190+ <p className="py-2 text-sm text-muted-foreground/40">None</p>
191+ )}
177192 </div>
178− </div>
179− <div className="p-4">
180− {types?.contract && (
181− <CodeBlock
182− code={types.contract}
183− lang="typescript"
184− className="rounded-xl border-border/60 bg-background/70"
185− />
186− )}
187− </div>
193+ </section>
188194 </div>
189−
190− <div className="rounded-2xl border border-border/70 bg-card/50 px-4 py-3 shadow-[0_8px_30px_rgba(0,0,0,0.03)]">
191− <div className="flex items-center justify-between gap-3">
192− <div>
193− <p className="text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground/75">
194− Shared definitions
195− </p>
196− <p className="mt-1 text-xs text-muted-foreground/80">
197− Referenced types stay tucked away until you need them.
198− </p>
195+ ) : (
196+ <div className="px-5 py-5 space-y-6">
197+ <section>
198+ <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40">
199+ Input
200+ </h4>
201+ <div className="mt-2.5">
202+ {data?.inputTypeScript ? (
203+ <ExpandableCodeBlock
204+ code={data.inputTypeScript}
205+ definitions={data.definitions}
206+ />
207+ ) : (
208+ <p className="py-2 text-sm text-muted-foreground/40">void</p>
209+ )}
199210 </div>
200− <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.16em]">
201− {types?.definitions.length ?? 0}
202− </Badge>
203− </div>
204−
205− {types && types.definitions.length > 0 ? (
206− <Accordion type="multiple" className="mt-3 divide-y divide-border/60">
207− {types.definitions.map((definition) => (
208− <AccordionItem key={definition.name} value={definition.name} className="border-none">
209− <AccordionTrigger className="py-3 hover:no-underline">
210− <div className="flex min-w-0 items-center gap-3">
211− <div className="flex size-8 shrink-0 items-center justify-center rounded-xl border border-border/70 bg-background/80 text-[11px] font-medium text-muted-foreground">
212− TS
213− </div>
214− <div className="min-w-0">
215− <div className="truncate font-mono text-sm text-foreground">
216− {definition.name}
217− </div>
218− <div className="text-xs text-muted-foreground/75">
219− Referenced by the primary contract
220− </div>
221− </div>
222− </div>
223− </AccordionTrigger>
224− <AccordionContent className="pb-1">
225− <CodeBlock
226− code={definition.code}
227− lang="typescript"
228− className="rounded-xl border-border/60 bg-background/70"
229− />
230− </AccordionContent>
231− </AccordionItem>
232− ))}
233− </Accordion>
234− ) : (
235− <div className="mt-3 rounded-xl border border-dashed border-border/70 bg-background/40 px-4 py-5 text-center text-[13px] text-muted-foreground/65">
236− This contract is self-contained — no extra referenced types.
211+ </section>
212+
213+ <section>
214+ <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40">
215+ Output
216+ </h4>
217+ <div className="mt-2.5">
218+ {data?.outputTypeScript ? (
219+ <ExpandableCodeBlock
220+ code={data.outputTypeScript}
221+ definitions={data.definitions}
222+ />
223+ ) : (
224+ <p className="py-2 text-sm text-muted-foreground/40">void</p>
225+ )}
237226 </div>
238− )}
227+ </section>
239228 </div>
240− </div>
241− ),
229+ ),
242230 })}
243231 </div>
244232 </div>
@@ -257,12 +245,8 @@ export function ToolDetailEmpty(props: { hasTools: boolean }) { · 12 unmodified lines
257245 {props.hasTools ? "Select a tool" : "No tools available"}
258246 </p>
259247 {props.hasTools && (
260− <p className="mt-1 text-xs text-muted-foreground">
261− Choose from the list or press{" "}
262− <kbd className="rounded border border-border bg-muted px-1 py-px text-[10px]">
263− /
264− </kbd>{" "}
265− to search.
248+ <p className="mt-1.5 text-sm text-muted-foreground/50">
249+ Choose from the list to see what it does.
266250 </p>
267251 )}
268252 </div>