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
7 HttpApiBuilder,7 HttpApiBuilder,
8 HttpApiSwagger,8 HttpApiSwagger,
9 HttpMiddleware,9 HttpMiddleware,
10 HttpRouter,
10 HttpServer,11 HttpServer,
11} from "@effect/platform";12} from "@effect/platform";
12import { Effect, Layer, Scope } from "effect";13import { Effect, Layer, Scope } from "effect";
@@ -76,6 +77,7 @@ const SharedServicesMemoized = Effect.runSync( · 63 unmodified lines
76const publicApiHandler = HttpApiBuilder.toWebHandler(77const publicApiHandler = HttpApiBuilder.toWebHandler(
77 PublicCloudApiLive.pipe(78 PublicCloudApiLive.pipe(
78 Layer.provideMerge(SharedServicesMemoized),79 Layer.provideMerge(SharedServicesMemoized),
80 Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })),
79 ),81 ),
80 { middleware: HttpMiddleware.logger },82 { middleware: HttpMiddleware.logger },
81);83);
@@ -184,6 +186,7 @@ const createProtectedHandler = ( · 102 unmodified lines
184 Layer.provideMerge(ProtectedCloudApiLive),186 Layer.provideMerge(ProtectedCloudApiLive),
185 Layer.provideMerge(requestServices),187 Layer.provideMerge(requestServices),
186 Layer.provideMerge(SharedServicesMemoized),188 Layer.provideMerge(SharedServicesMemoized),
189 Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })),
187 ),190 ),
188 { middleware: HttpMiddleware.logger },191 { middleware: HttpMiddleware.logger },
189 );192 );
apps/local/src/routes/__root.tsx+3−0
@@ -17,6 +17,9 @@ export const Route = createRootRoute({ · 16 unmodified lines
17 { 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" },17 { 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" },
18 { rel: "stylesheet", href: appCss },18 { rel: "stylesheet", href: appCss },
19 ],19 ],
20 scripts: import.meta.env.DEV
21 ? [{ src: "https://ui.sh/ui-picker.js" }]
22 : [],
20 }),23 }),
21 component: RootComponent,24 component: RootComponent,
22 shellComponent: RootDocument,25 shellComponent: RootDocument,
apps/local/src/server/main.ts+2−0
@@ -2,6 +2,7 @@ import { · 1 unmodified line
2 HttpApiBuilder,2 HttpApiBuilder,
3 HttpApiSwagger,3 HttpApiSwagger,
4 HttpMiddleware,4 HttpMiddleware,
5 HttpRouter,
5 HttpServer,6 HttpServer,
6} from "@effect/platform";7} from "@effect/platform";
7import { Context, Effect, Layer, ManagedRuntime } from "effect";8import { Context, Effect, Layer, ManagedRuntime } from "effect";
@@ -76,6 +77,7 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 68 unmodified lines
76 Layer.provideMerge(Layer.succeed(ExecutorService, executor)),77 Layer.provideMerge(Layer.succeed(ExecutorService, executor)),
77 Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)),78 Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)),
78 Layer.provideMerge(HttpServer.layerContext),79 Layer.provideMerge(HttpServer.layerContext),
80 Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })),
79 ),81 ),
80 { middleware: HttpMiddleware.logger },82 { middleware: HttpMiddleware.logger },
81 );83 );
packages/core/api/src/tools/api.ts+2−0
@@ -33,6 +33,8 @@ const ToolSchemaResponse = Schema.Struct({ · 32 unmodified lines
33 typeScriptDefinitions: Schema.optional(33 typeScriptDefinitions: Schema.optional(
34 Schema.Record({ key: Schema.String, value: Schema.String }),34 Schema.Record({ key: Schema.String, value: Schema.String }),
35 ),35 ),
36 inputSchema: Schema.optional(Schema.Unknown),
37 outputSchema: Schema.optional(Schema.Unknown),
36});38});
37 39
38// ---------------------------------------------------------------------------40// ---------------------------------------------------------------------------
packages/core/sdk/src/in-memory/tool-registry.ts+9−1
@@ -9,7 +9,7 @@ import type { · 8 unmodified lines
9 InvokeOptions,9 InvokeOptions,
10 RuntimeToolHandler,10 RuntimeToolHandler,
11} from "../tools";11} from "../tools";
12import { normalizeRefs } from "../schema-refs";12import { normalizeRefs, reattachDefs } from "../schema-refs";
13import { buildToolTypeScriptPreview } from "../schema-types";13import { buildToolTypeScriptPreview } from "../schema-types";
14 14
15export const makeInMemoryToolRegistry = () => {15export const makeInMemoryToolRegistry = () => {
@@ -68,11 +68,19 @@ export const makeInMemoryToolRegistry = () => { · 52 unmodified lines
68 inputSchema: t.inputSchema,68 inputSchema: t.inputSchema,
69 outputSchema: t.outputSchema,69 outputSchema: t.outputSchema,
70 defs,70 defs,
71 options: {
72 maxLength: Infinity,
73 maxProperties: Infinity,
74 maxCompositeMembers: Infinity,
75 maxRefDepth: 20,
76 },
71 });77 });
72 78
73 return {79 return {
74 id: t.id,80 id: t.id,
75 ...typeScriptPreview,81 ...typeScriptPreview,
82 inputSchema: t.inputSchema ? reattachDefs(t.inputSchema, defs) : undefined,
83 outputSchema: t.outputSchema ? reattachDefs(t.outputSchema, defs) : undefined,
76 };84 };
77 }),85 }),
78 ),86 ),
packages/core/sdk/src/tools.ts+2−0
@@ -36,6 +36,8 @@ export class ToolSchema extends Schema.Class<ToolSchema>("ToolSchema")({ · 35 unmodified lines
36 typeScriptDefinitions: Schema.optional(36 typeScriptDefinitions: Schema.optional(
37 Schema.Record({ key: Schema.String, value: Schema.String }),37 Schema.Record({ key: Schema.String, value: Schema.String }),
38 ),38 ),
39 inputSchema: Schema.optional(Schema.Unknown),
40 outputSchema: Schema.optional(Schema.Unknown),
39}) {}41}) {}
40 42
41export class ToolInvocationResult extends Schema.Class<ToolInvocationResult>(43export 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
12 InvokeOptions,12 InvokeOptions,
13 RuntimeToolHandler,13 RuntimeToolHandler,
14} from "@executor/sdk";14} from "@executor/sdk";
15import { buildToolTypeScriptPreview } from "@executor/sdk";15import { buildToolTypeScriptPreview, reattachDefs } from "@executor/sdk";
16 16
17// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
18// Serialization — leverage ToolRegistration Schema.Class directly18// Serialization — leverage ToolRegistration Schema.Class directly
@@ -101,11 +101,19 @@ export const makeKvToolRegistry = ( · 82 unmodified lines
101 inputSchema: t.inputSchema,101 inputSchema: t.inputSchema,
102 outputSchema: t.outputSchema,102 outputSchema: t.outputSchema,
103 defs,103 defs,
104 options: {
105 maxLength: Infinity,
106 maxProperties: Infinity,
107 maxCompositeMembers: Infinity,
108 maxRefDepth: 20,
109 },
104 });110 });
105 111
106 return {112 return {
107 id: t.id,113 id: t.id,
108 ...typeScriptPreview,114 ...typeScriptPreview,
115 inputSchema: t.inputSchema ? reattachDefs(t.inputSchema, defs) : undefined,
116 outputSchema: t.outputSchema ? reattachDefs(t.outputSchema, defs) : undefined,
109 };117 };
110 }),118 }),
111 119
packages/react/src/components/expandable-code-block.tsx+380−0
@@ -0,0 +1,380 @@
1import { useCallback, useEffect, useMemo, useState, startTransition } from "react";
2import { getHighlighter, THEME } from "../lib/shiki";
3import { cn } from "../lib/utils";
4import type { ThemedToken } from "shiki/core";
5
6// ---------------------------------------------------------------------------
7// Types
8// ---------------------------------------------------------------------------
9
10type 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
20const 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
67const 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
74const 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
84function 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 */
121const 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
166type RenderToken =
167 | { kind: "text"; content: string; color?: string }
168 | { kind: "ref"; name: string; color?: string };
169
170const 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
212function 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
275export 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 @@
1import { useState, useCallback } from "react";
2import { ChevronRight } from "lucide-react";
3
4// ---------------------------------------------------------------------------
5// JSON Schema types (subset we render)
6// ---------------------------------------------------------------------------
7
8type 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
32const 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
38const 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 */
45const 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
61const 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
134const 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
159const isExpandable = (schema: JsonSchema, root: JsonSchema): boolean =>
160 getChildCount(schema, root) > 0;
161
162// ---------------------------------------------------------------------------
163// Merge allOf
164// ---------------------------------------------------------------------------
165
166const 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
187const typeClasses = "font-mono text-[0.6875rem] leading-5 text-muted-foreground/50";
188
189// ---------------------------------------------------------------------------
190// PropertyRow
191// ---------------------------------------------------------------------------
192
193function 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
309function 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
421export 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
2import { useAtomValue, Result } from "@effect-atom/atom-react";2import { useAtomValue, Result } from "@effect-atom/atom-react";
3import { toolSchemaAtom } from "../api/atoms";3import { toolSchemaAtom } from "../api/atoms";
4import { ScopeId, ToolId } from "@executor/sdk";4import { ScopeId, ToolId } from "@executor/sdk";
5import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./accordion";
6import { Badge } from "./badge";
7import { CodeBlock } from "./code-block";
8import { Markdown } from "./markdown";5import { Markdown } from "./markdown";
6import { SchemaExplorer } from "./schema-explorer";
7import { ExpandableCodeBlock } from "./expandable-code-block";
8import { Copy, Check, ChevronRight } from "lucide-react";
9 9
10// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
11// Copy button11// Copy button
@@ -27,48 +27,32 @@ function CopyButton(props: { text: string; label?: string }) { · 15 unmodified lines
27 title={props.label ?? "Copy"}27 title={props.label ?? "Copy"}
28 >28 >
29 {copied ? (29 {copied ? (
30 <svg viewBox="0 0 16 16" className="size-3.5">30 <Check className="size-3.5 shrink-0" />
31 <path d="M3 8l3 3 7-7" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
32 </svg>
33 ) : (31 ) : (
34 <svg viewBox="0 0 16 16" className="size-3.5">32 <Copy className="size-3.5 shrink-0" />
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>
38 )}33 )}
39 </button>34 </button>
40 );35 );
41}36}
42 37
43// ---------------------------------------------------------------------------38// ---------------------------------------------------------------------------
44// Build type declarations from tool TypeScript previews39// Helpers
45// ---------------------------------------------------------------------------40// ---------------------------------------------------------------------------
46 41
47const buildCallSignature = (42const friendlyName = (name: string): string => {
48 toolName: string,43 const leaf = name.split(".").pop() ?? name;
49 inputTypeScript?: string,44 return leaf
50 outputTypeScript?: string,45 .replace(/([a-z])([A-Z])/g, "$1 $2")
51): string => {46 .replace(/[_.-]/g, " ")
52 const inputType = inputTypeScript ?? "void";47 .replace(/\b\w/g, (c) => c.toUpperCase());
53 const outputType = outputTypeScript ?? "void";
54
55 const leaf = toolName.split(".").pop() ?? toolName;
56 return `declare function ${leaf}(input: ${inputType}): ${outputType}`;
57};48};
58 49
59const buildPrimaryContract = (input: {50const breadcrumbParts = (name: string): string[] =>
60 toolName: string;51 name.split(".").map((p) =>
61 inputTypeScript?: string;52 p.replace(/([a-z])([A-Z])/g, "$1 $2")
62 outputTypeScript?: string;53 .replace(/[_-]/g, " ")
63}): string => {54 .replace(/\b\w/g, (c) => c.toUpperCase()),
64 const sections = [55 );
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};
72 56
73// ---------------------------------------------------------------------------57// ---------------------------------------------------------------------------
74// ToolDetail58// ToolDetail
@@ -83,162 +67,166 @@ export function ToolDetail(props: { · 8 unmodified lines
83 const toolContract = useAtomValue(67 const toolContract = useAtomValue(
84 toolSchemaAtom(props.scopeId, props.toolId as ToolId),68 toolSchemaAtom(props.scopeId, props.toolId as ToolId),
85 );69 );
70 const [tab, setTab] = useState<"schema" | "typescript">("schema");
86 71
87 const types = useMemo(() => {72 const data = useMemo(() => {
88 if (!Result.isSuccess(toolContract)) return null;73 if (!Result.isSuccess(toolContract)) return null;
89 const v = toolContract.value;74 const v = toolContract.value;
90 const definitions = Object.entries(v.typeScriptDefinitions ?? {}).map(([name, body]) => ({75 const definitions = Object.entries(v.typeScriptDefinitions ?? {}).map(([name, body]) => ({
91 name,76 name,
92 code: `type ${name} = ${body}`,77 code: body,
93 }));78 }));
94 79
95 return {80 return {
96 inputTypeScript: v.inputTypeScript,81 inputSchema: v.inputSchema,
97 outputTypeScript: v.outputTypeScript,82 outputSchema: v.outputSchema,
98 contract: buildPrimaryContract({83 inputTypeScript: v.inputTypeScript
99 toolName: props.toolName,84 ? `type Input = ${v.inputTypeScript}`
100 inputTypeScript: v.inputTypeScript,85 : null,
101 outputTypeScript: v.outputTypeScript,86 outputTypeScript: v.outputTypeScript
102 }),87 ? `type Output = ${v.outputTypeScript}`
88 : null,
103 definitions,89 definitions,
104 };90 };
105 }, [toolContract, props.toolName]);91 }, [toolContract, props.toolName]);
106 92
107 const namespace = props.toolName.split(".").slice(0, -1).join(".") || "global";93 const crumbs = breadcrumbParts(props.toolName);
94 const displayName = friendlyName(props.toolName);
108 95
109 return (96 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%)]">97 <div className="flex h-full flex-col overflow-hidden">
111 <div className="shrink-0 border-b border-border/70 bg-background/90 backdrop-blur-xl">98 {/* Header + tabs */}
112 <div className="px-5 py-4">99 <div className="shrink-0 border-b border-border/40">
113 <div className="flex items-start gap-4">100 <div className="px-5 pt-4 pb-0">
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)]">101 {crumbs.length > 1 && (
115 <div className="absolute inset-0 rounded-2xl bg-[linear-gradient(135deg,rgba(255,255,255,0.14),transparent_55%)]" />102 <div className="flex items-center gap-1 text-sm text-muted-foreground/40">
116 <svg viewBox="0 0 16 16" className="relative size-4">103 {crumbs.slice(0, -1).map((part, i) => (
117 <path104 <span key={i} className="flex items-center gap-1">
118 d="M4 2h8l1 3H3l1-3zM3 6h10v8H3V6z"105 {i > 0 && <ChevronRight className="size-3 shrink-0" />}
119 fill="none"106 <span>{part}</span>
120 stroke="currentColor"107 </span>
121 strokeWidth="1.2"108 ))}
122 />
123 </svg>
124 </div>109 </div>
125 <div className="min-w-0 flex-1">110 )}
126 <div className="flex flex-wrap items-center gap-2">111 <div className="mt-1 flex 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">112 <h3 className="text-base font-semibold text-foreground truncate">
128 {namespace}113 {displayName}
129 </Badge>114 </h3>
130 <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.18em] text-muted-foreground/80">115 <CopyButton text={props.toolId} label="Copy tool ID" />
131 {types?.definitions.length ?? 0} shared type{(types?.definitions.length ?? 0) === 1 ? "" : "s"}116 </div>
132 </Badge>117 {props.toolDescription && (
133 </div>118 <div className="mt-1.5 max-w-lg text-sm text-muted-foreground/70 line-clamp-2">
134 <div className="mt-2 flex items-start gap-2">119 <Markdown>{props.toolDescription}</Markdown>
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 )}
145 </div>120 </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>
146 </div>153 </div>
147 </div>154 </div>
148 </div>155 </div>
149 156
157 {/* Content */}
150 <div className="flex-1 overflow-y-auto">158 <div className="flex-1 overflow-y-auto">
151 {Result.match(toolContract, {159 {Result.match(toolContract, {
152 onInitial: () => (160 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>
154 ),162 ),
155 onFailure: () => (163 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>
157 ),165 ),
158 onSuccess: () => (166 onSuccess: () =>
159 <div className="space-y-4 px-5 py-4">167 tab === "schema" ? (
160 <div className="rounded-2xl border border-border/70 bg-card/70 shadow-[0_12px_40px_rgba(0,0,0,0.04)] overflow-hidden">168 <div className="px-5 py-5 space-y-6">
161 <div className="flex items-center justify-between border-b border-border/70 bg-muted/30 px-4 py-3">169 <section>
162 <div>170 <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40">
163 <p className="text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground/75">171 Parameters
164 Primary contract172 </h4>
165 </p>173 <div className="mt-2.5">
166 <p className="mt-1 text-xs text-muted-foreground/80">174 {data?.inputSchema ? (
167 One compact view for the call signature, input, and output.175 <SchemaExplorer schema={data.inputSchema} />
168 </p>176 ) : (
177 <p className="py-2 text-sm text-muted-foreground/40">None</p>
178 )}
169 </div>179 </div>
170 <div className="hidden items-center gap-2 sm:flex">180 </section>
171 <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.16em]">181
172 {types?.inputTypeScript ? "input" : "no input"}182 <section>
173 </Badge>183 <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40">
174 <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.16em]">184 Response
175 {types?.outputTypeScript ? "output" : "no output"}185 </h4>
176 </Badge>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 )}
177 </div>192 </div>
178 </div>193 </section>
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>
188 </div>194 </div>
189 195 ) : (
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)]">196 <div className="px-5 py-5 space-y-6">
191 <div className="flex items-center justify-between gap-3">197 <section>
192 <div>198 <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40">
193 <p className="text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground/75">199 Input
194 Shared definitions200 </h4>
195 </p>201 <div className="mt-2.5">
196 <p className="mt-1 text-xs text-muted-foreground/80">202 {data?.inputTypeScript ? (
197 Referenced types stay tucked away until you need them.203 <ExpandableCodeBlock
198 </p>204 code={data.inputTypeScript}
205 definitions={data.definitions}
206 />
207 ) : (
208 <p className="py-2 text-sm text-muted-foreground/40">void</p>
209 )}
199 </div>210 </div>
200 <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.16em]">211 </section>
201 {types?.definitions.length ?? 0}212
202 </Badge>213 <section>
203 </div>214 <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40">
204 215 Output
205 {types && types.definitions.length > 0 ? (216 </h4>
206 <Accordion type="multiple" className="mt-3 divide-y divide-border/60">217 <div className="mt-2.5">
207 {types.definitions.map((definition) => (218 {data?.outputTypeScript ? (
208 <AccordionItem key={definition.name} value={definition.name} className="border-none">219 <ExpandableCodeBlock
209 <AccordionTrigger className="py-3 hover:no-underline">220 code={data.outputTypeScript}
210 <div className="flex min-w-0 items-center gap-3">221 definitions={data.definitions}
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">222 />
212 TS223 ) : (
213 </div>224 <p className="py-2 text-sm text-muted-foreground/40">void</p>
214 <div className="min-w-0">225 )}
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.
237 </div>226 </div>
238 )}227 </section>
239 </div>228 </div>
240 </div>229 ),
241 ),
242 })}230 })}
243 </div>231 </div>
244 </div>232 </div>
@@ -257,12 +245,8 @@ export function ToolDetailEmpty(props: { hasTools: boolean }) { · 12 unmodified lines
257 {props.hasTools ? "Select a tool" : "No tools available"}245 {props.hasTools ? "Select a tool" : "No tools available"}
258 </p>246 </p>
259 {props.hasTools && (247 {props.hasTools && (
260 <p className="mt-1 text-xs text-muted-foreground">248 <p className="mt-1.5 text-sm text-muted-foreground/50">
261 Choose from the list or press{" "}249 Choose from the list to see what it does.
262 <kbd className="rounded border border-border bg-muted px-1 py-px text-[10px]">
263 /
264 </kbd>{" "}
265 to search.
266 </p>250 </p>
267 )}251 )}
268 </div>252 </div>