@townco/debugger 0.1.12 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@townco/debugger",
3
- "version": "0.1.12",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3.0"
@@ -19,8 +19,8 @@
19
19
  "@radix-ui/react-select": "^2.2.6",
20
20
  "@radix-ui/react-slot": "^1.2.3",
21
21
  "@radix-ui/react-tabs": "^1.1.0",
22
- "@townco/otlp-server": "0.1.12",
23
- "@townco/ui": "0.1.57",
22
+ "@townco/otlp-server": "0.1.20",
23
+ "@townco/ui": "0.1.65",
24
24
  "bun-plugin-tailwind": "^0.1.2",
25
25
  "class-variance-authority": "^0.7.1",
26
26
  "clsx": "^2.1.1",
@@ -30,7 +30,7 @@
30
30
  "tailwind-merge": "^3.3.1"
31
31
  },
32
32
  "devDependencies": {
33
- "@townco/tsconfig": "0.1.54",
33
+ "@townco/tsconfig": "0.1.62",
34
34
  "@types/bun": "latest",
35
35
  "@types/react": "^19",
36
36
  "@types/react-dom": "^19",
@@ -168,6 +168,550 @@ export function SpanDetailsPanel({
168
168
  const logCount = countLogs(span);
169
169
  const tokenCount = countTokens(span);
170
170
 
171
+ const JsonSection = ({ title, data }: { title: string; data: unknown }) => {
172
+ if (!data) return null;
173
+
174
+ try {
175
+ const jsonString = typeof data === "string" ? data : JSON.stringify(data);
176
+ const parsed = JSON.parse(jsonString);
177
+
178
+ return (
179
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
180
+ <div className="p-3 border-b border-border">
181
+ <h3 className="text-[8px] font-bold text-muted-foreground uppercase tracking-wider">
182
+ {title}
183
+ </h3>
184
+ </div>
185
+ <div className="p-3">
186
+ <pre className="text-[11px] font-mono text-foreground leading-[14px] whitespace-pre-wrap break-all">
187
+ {JSON.stringify(parsed, null, 2)}
188
+ </pre>
189
+ </div>
190
+ </div>
191
+ );
192
+ } catch {
193
+ // If parse fails, show raw data
194
+ return (
195
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
196
+ <div className="p-3 border-b border-border">
197
+ <h3 className="text-[8px] font-bold text-muted-foreground uppercase tracking-wider">
198
+ {title}
199
+ </h3>
200
+ </div>
201
+ <div className="p-3">
202
+ <pre className="text-[11px] font-mono text-foreground leading-[14px] whitespace-pre-wrap break-all">
203
+ {String(data)}
204
+ </pre>
205
+ </div>
206
+ </div>
207
+ );
208
+ }
209
+ };
210
+
211
+ const CollapsibleText = ({ text }: { text: string }) => {
212
+ const [expanded, setExpanded] = useState(false);
213
+ const shouldCollapse = text.length > 300;
214
+ const preview = shouldCollapse ? text.slice(0, 300) + "..." : text;
215
+
216
+ if (!shouldCollapse) {
217
+ return (
218
+ <div className="text-[11px] text-foreground whitespace-pre-wrap break-words">
219
+ {text}
220
+ </div>
221
+ );
222
+ }
223
+
224
+ return (
225
+ <div>
226
+ <div className="text-[11px] text-foreground whitespace-pre-wrap break-words">
227
+ {expanded ? text : preview}
228
+ </div>
229
+ <button
230
+ type="button"
231
+ onClick={() => setExpanded(!expanded)}
232
+ className="text-[10px] text-purple-600 hover:text-purple-700 mt-1 font-medium"
233
+ >
234
+ {expanded ? "Show less" : "Show more"}
235
+ </button>
236
+ </div>
237
+ );
238
+ };
239
+
240
+ const CollapsibleToolBlock = ({
241
+ title,
242
+ subtitle,
243
+ content,
244
+ borderColor,
245
+ bgColor,
246
+ badgeColor,
247
+ }: {
248
+ title: string;
249
+ subtitle?: string;
250
+ content: any;
251
+ borderColor: string;
252
+ bgColor: string;
253
+ badgeColor: string;
254
+ }) => {
255
+ const [expanded, setExpanded] = useState(false);
256
+
257
+ // Parse content as JSON if it's a string
258
+ let displayContent = content;
259
+ if (typeof content === "string") {
260
+ try {
261
+ displayContent = JSON.parse(content);
262
+ } catch {
263
+ // Keep as string if parsing fails
264
+ }
265
+ }
266
+
267
+ const contentString =
268
+ typeof displayContent === "string"
269
+ ? displayContent
270
+ : JSON.stringify(displayContent, null, 2);
271
+
272
+ return (
273
+ <div className={`border-l-2 ${borderColor} pl-2 ${bgColor} rounded py-1`}>
274
+ <button
275
+ type="button"
276
+ onClick={() => setExpanded(!expanded)}
277
+ className="flex items-center gap-2 mb-1 w-full hover:opacity-80 transition-opacity"
278
+ >
279
+ <span className={`text-[9px] font-semibold ${badgeColor} uppercase`}>
280
+ {title}
281
+ </span>
282
+ {subtitle && (
283
+ <span className="text-[10px] text-foreground font-mono">
284
+ {subtitle}
285
+ </span>
286
+ )}
287
+ <span className="text-[10px] text-muted-foreground ml-auto">
288
+ {expanded ? "▼" : "▶"}
289
+ </span>
290
+ </button>
291
+ {expanded && (
292
+ <pre className="text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all mt-1">
293
+ {contentString}
294
+ </pre>
295
+ )}
296
+ </div>
297
+ );
298
+ };
299
+
300
+ const ToolCallWithResult = ({
301
+ toolName,
302
+ input,
303
+ result,
304
+ }: {
305
+ toolName: string;
306
+ input: any;
307
+ result?: any;
308
+ }) => {
309
+ const [expanded, setExpanded] = useState(false);
310
+
311
+ const parseContent = (content: any) => {
312
+ if (typeof content === "string") {
313
+ try {
314
+ return JSON.parse(content);
315
+ } catch {
316
+ return content;
317
+ }
318
+ }
319
+ return content;
320
+ };
321
+
322
+ const formatContent = (content: any) => {
323
+ const parsed = parseContent(content);
324
+ return typeof parsed === "string"
325
+ ? parsed
326
+ : JSON.stringify(parsed, null, 2);
327
+ };
328
+
329
+ return (
330
+ <div className="border-l-2 border-blue-500 pl-2 bg-blue-500/5 rounded py-1">
331
+ <button
332
+ type="button"
333
+ onClick={() => setExpanded(!expanded)}
334
+ className="flex items-center gap-2 mb-1 w-full hover:opacity-80 transition-opacity"
335
+ >
336
+ <span className="text-[9px] font-semibold text-blue-600 uppercase">
337
+ Tool Call
338
+ </span>
339
+ <span className="text-[10px] text-foreground font-mono">
340
+ {toolName}
341
+ </span>
342
+ <span className="text-[10px] text-muted-foreground ml-auto">
343
+ {expanded ? "▼" : "▶"}
344
+ </span>
345
+ </button>
346
+ {expanded && (
347
+ <div className="flex flex-col gap-2 mt-1">
348
+ {/* Input section */}
349
+ <div>
350
+ <div className="text-[9px] font-semibold text-purple-600 uppercase mb-1">
351
+ Input
352
+ </div>
353
+ <pre className="text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all bg-background/50 rounded p-2">
354
+ {formatContent(input)}
355
+ </pre>
356
+ </div>
357
+ {/* Result section */}
358
+ {result !== undefined && (
359
+ <div>
360
+ <div className="text-[9px] font-semibold text-green-600 uppercase mb-1">
361
+ Result
362
+ </div>
363
+ <pre className="text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all bg-background/50 rounded p-2">
364
+ {formatContent(result)}
365
+ </pre>
366
+ </div>
367
+ )}
368
+ </div>
369
+ )}
370
+ </div>
371
+ );
372
+ };
373
+
374
+ const MessageSection = ({
375
+ title,
376
+ data,
377
+ }: {
378
+ title: string;
379
+ data: unknown;
380
+ }) => {
381
+ if (!data) return null;
382
+
383
+ try {
384
+ const jsonString = typeof data === "string" ? data : JSON.stringify(data);
385
+ const messages = JSON.parse(jsonString);
386
+
387
+ if (!Array.isArray(messages) || messages.length === 0) return null;
388
+
389
+ const parseToolInput = (input: any): any => {
390
+ if (typeof input === "string") {
391
+ try {
392
+ return JSON.parse(input);
393
+ } catch {
394
+ return input;
395
+ }
396
+ }
397
+ return input;
398
+ };
399
+
400
+ const renderContent = (content: any) => {
401
+ // Handle string content - parse if it looks like JSON array
402
+ if (typeof content === "string") {
403
+ // Try to parse if it looks like a JSON array
404
+ if (content.trim().startsWith("[")) {
405
+ try {
406
+ const parsed = JSON.parse(content);
407
+ if (Array.isArray(parsed)) {
408
+ return renderContent(parsed);
409
+ }
410
+ } catch {
411
+ // Not valid JSON, fall through to text display
412
+ }
413
+ }
414
+
415
+ return <CollapsibleText text={content} />;
416
+ }
417
+
418
+ // Handle array content (can contain text and tool_use blocks)
419
+ if (Array.isArray(content)) {
420
+ // First, group tool_use with their corresponding tool_result
421
+ const processedBlocks: any[] = [];
422
+ const usedResultIndices = new Set<number>();
423
+
424
+ content.forEach((block: any, blockIndex: number) => {
425
+ if (block.type === "text") {
426
+ processedBlocks.push({ type: "text", block, index: blockIndex });
427
+ } else if (block.type === "tool_use") {
428
+ // Find the corresponding tool_result
429
+ const resultIndex = content.findIndex(
430
+ (b: any, idx: number) =>
431
+ b.type === "tool_result" &&
432
+ b.tool_use_id === block.id &&
433
+ !usedResultIndices.has(idx),
434
+ );
435
+
436
+ if (resultIndex !== -1) {
437
+ usedResultIndices.add(resultIndex);
438
+ processedBlocks.push({
439
+ type: "tool_call_with_result",
440
+ toolUse: block,
441
+ toolResult: content[resultIndex],
442
+ index: blockIndex,
443
+ });
444
+ } else {
445
+ // No result found, show just the tool_use
446
+ processedBlocks.push({
447
+ type: "tool_use",
448
+ block,
449
+ index: blockIndex,
450
+ });
451
+ }
452
+ } else if (
453
+ block.type === "tool_result" &&
454
+ !usedResultIndices.has(blockIndex)
455
+ ) {
456
+ // Orphaned tool_result (no matching tool_use)
457
+ processedBlocks.push({
458
+ type: "tool_result",
459
+ block,
460
+ index: blockIndex,
461
+ });
462
+ }
463
+ });
464
+
465
+ return (
466
+ <div className="flex flex-col gap-2">
467
+ {processedBlocks.map((item: any) => {
468
+ if (item.type === "text") {
469
+ return (
470
+ <CollapsibleText key={item.index} text={item.block.text} />
471
+ );
472
+ }
473
+
474
+ if (item.type === "tool_call_with_result") {
475
+ const parsedInput = parseToolInput(item.toolUse.input);
476
+ return (
477
+ <ToolCallWithResult
478
+ key={item.index}
479
+ toolName={item.toolUse.name}
480
+ input={parsedInput}
481
+ result={item.toolResult.content}
482
+ />
483
+ );
484
+ }
485
+
486
+ if (item.type === "tool_use") {
487
+ const parsedInput = parseToolInput(item.block.input);
488
+ return (
489
+ <CollapsibleToolBlock
490
+ key={item.index}
491
+ title="Tool Use"
492
+ subtitle={item.block.name}
493
+ content={parsedInput}
494
+ borderColor="border-purple-500"
495
+ bgColor="bg-purple-500/5"
496
+ badgeColor="text-purple-600"
497
+ />
498
+ );
499
+ }
500
+
501
+ if (item.type === "tool_result") {
502
+ return (
503
+ <CollapsibleToolBlock
504
+ key={item.index}
505
+ title="Tool Result"
506
+ subtitle=""
507
+ content={item.block.content}
508
+ borderColor="border-green-500"
509
+ bgColor="bg-green-500/5"
510
+ badgeColor="text-green-600"
511
+ />
512
+ );
513
+ }
514
+
515
+ // Unknown block type - show as JSON
516
+ return (
517
+ <pre
518
+ key={item.index}
519
+ className="text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all"
520
+ >
521
+ {JSON.stringify(item.block, null, 2)}
522
+ </pre>
523
+ );
524
+ })}
525
+ </div>
526
+ );
527
+ }
528
+
529
+ // Fallback for other content types
530
+ return (
531
+ <pre className="text-[10px] font-mono text-muted-foreground whitespace-pre-wrap break-all">
532
+ {JSON.stringify(content, null, 2)}
533
+ </pre>
534
+ );
535
+ };
536
+
537
+ return (
538
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
539
+ <div className="p-3 border-b border-border">
540
+ <h3 className="text-[8px] font-bold text-muted-foreground uppercase tracking-wider">
541
+ {title}
542
+ </h3>
543
+ </div>
544
+ <div className="p-3 flex flex-col gap-3">
545
+ {(() => {
546
+ // Group tool_use (from AI messages) with tool results (from tool messages)
547
+ const processedMessages: any[] = [];
548
+ const usedToolMessageIndices = new Set<number>();
549
+
550
+ messages.forEach((message: any, msgIndex: number) => {
551
+ const role = message.role || "unknown";
552
+
553
+ // Handle AI messages - check for tool_use blocks
554
+ if (role === "ai") {
555
+ const content = message.content;
556
+ let parsedContent = content;
557
+
558
+ if (
559
+ typeof content === "string" &&
560
+ content.trim().startsWith("[")
561
+ ) {
562
+ try {
563
+ parsedContent = JSON.parse(content);
564
+ } catch {}
565
+ }
566
+
567
+ if (Array.isArray(parsedContent)) {
568
+ // Extract tool_use blocks
569
+ const toolUses: any[] = [];
570
+ const nonToolBlocks: any[] = [];
571
+
572
+ for (const block of parsedContent) {
573
+ if (block.type === "tool_use") {
574
+ toolUses.push(block);
575
+ } else {
576
+ nonToolBlocks.push(block);
577
+ }
578
+ }
579
+
580
+ // Add non-tool content if any
581
+ if (nonToolBlocks.length > 0) {
582
+ processedMessages.push({
583
+ type: "content",
584
+ role,
585
+ content: nonToolBlocks,
586
+ index: msgIndex,
587
+ });
588
+ }
589
+
590
+ // For each tool_use, find the matching tool message
591
+ for (const toolUse of toolUses) {
592
+ // Find next unused tool message
593
+ let foundToolMessage = false;
594
+ for (let i = msgIndex + 1; i < messages.length; i++) {
595
+ if (
596
+ messages[i].role === "tool" &&
597
+ !usedToolMessageIndices.has(i)
598
+ ) {
599
+ usedToolMessageIndices.add(i);
600
+ processedMessages.push({
601
+ type: "tool_call_with_result",
602
+ toolUse: toolUse,
603
+ toolResult: messages[i].content,
604
+ index: msgIndex,
605
+ });
606
+ foundToolMessage = true;
607
+ break;
608
+ }
609
+ }
610
+
611
+ // If no tool message found, show just the tool_use
612
+ if (!foundToolMessage) {
613
+ processedMessages.push({
614
+ type: "tool_use_only",
615
+ toolUse: toolUse,
616
+ index: msgIndex,
617
+ });
618
+ }
619
+ }
620
+ } else {
621
+ // Non-array content, render normally
622
+ processedMessages.push({
623
+ type: "content",
624
+ role,
625
+ content: parsedContent,
626
+ index: msgIndex,
627
+ });
628
+ }
629
+ } else if (role === "tool") {
630
+ // Skip if already matched, otherwise show as orphan result
631
+ if (!usedToolMessageIndices.has(msgIndex)) {
632
+ processedMessages.push({
633
+ type: "tool_result_only",
634
+ content: message.content,
635
+ index: msgIndex,
636
+ });
637
+ }
638
+ } else {
639
+ // Other roles (system, human, user, etc.)
640
+ processedMessages.push({
641
+ type: "content",
642
+ role,
643
+ content: message.content,
644
+ index: msgIndex,
645
+ });
646
+ }
647
+ });
648
+
649
+ // Render processed messages
650
+ return processedMessages.map((item, idx) => {
651
+ if (item.type === "tool_call_with_result") {
652
+ return (
653
+ <ToolCallWithResult
654
+ key={`${item.index}-${idx}`}
655
+ toolName={item.toolUse.name}
656
+ input={parseToolInput(item.toolUse.input)}
657
+ result={item.toolResult}
658
+ />
659
+ );
660
+ }
661
+
662
+ if (item.type === "tool_use_only") {
663
+ return (
664
+ <CollapsibleToolBlock
665
+ key={`${item.index}-${idx}`}
666
+ title="Tool Use"
667
+ subtitle={item.toolUse.name}
668
+ content={parseToolInput(item.toolUse.input)}
669
+ borderColor="border-purple-500"
670
+ bgColor="bg-purple-500/5"
671
+ badgeColor="text-purple-600"
672
+ />
673
+ );
674
+ }
675
+
676
+ if (item.type === "tool_result_only") {
677
+ return (
678
+ <CollapsibleToolBlock
679
+ key={`${item.index}-${idx}`}
680
+ title="Tool Result"
681
+ subtitle=""
682
+ content={item.content}
683
+ borderColor="border-green-500"
684
+ bgColor="bg-green-500/5"
685
+ badgeColor="text-green-600"
686
+ />
687
+ );
688
+ }
689
+
690
+ // Regular content
691
+ return (
692
+ <div
693
+ key={`${item.index}-${idx}`}
694
+ className="border border-border rounded p-2 bg-background"
695
+ >
696
+ <div className="flex items-center gap-2 mb-2">
697
+ <span className="text-[10px] font-semibold text-muted-foreground uppercase">
698
+ {item.role}
699
+ </span>
700
+ </div>
701
+ {renderContent(item.content)}
702
+ </div>
703
+ );
704
+ });
705
+ })()}
706
+ </div>
707
+ </div>
708
+ );
709
+ } catch {
710
+ // If parse fails, fall back to JSON display
711
+ return <JsonSection title={title} data={data} />;
712
+ }
713
+ };
714
+
171
715
  return (
172
716
  <Dialog.Root open={!!span} onOpenChange={(open) => !open && onClose()}>
173
717
  <Dialog.Portal>
@@ -292,18 +836,20 @@ export function SpanDetailsPanel({
292
836
  </div>
293
837
  )}
294
838
 
295
- {/* Tool Usage */}
296
- {callCount > 0 && (
297
- <div className="border-t border-border flex items-center gap-6 h-9">
298
- <span className="text-xs text-muted-foreground w-24 shrink-0">
299
- Tool Usage
300
- </span>
301
- <span className="text-sm text-foreground">
302
- {toolCount} {toolCount === 1 ? "tool" : "tools"},{" "}
303
- {callCount} tool {callCount === 1 ? "call" : "calls"}
304
- </span>
305
- </div>
306
- )}
839
+ {/* Tool Usage - only show for parent spans, not individual tool calls */}
840
+ {callCount > 0 &&
841
+ spanType !== "tool_call" &&
842
+ spanType !== "subagent" && (
843
+ <div className="border-t border-border flex items-center gap-6 h-9">
844
+ <span className="text-xs text-muted-foreground w-24 shrink-0">
845
+ Tool Usage
846
+ </span>
847
+ <span className="text-sm text-foreground">
848
+ {toolCount} {toolCount === 1 ? "tool" : "tools"},{" "}
849
+ {callCount} tool {callCount === 1 ? "call" : "calls"}
850
+ </span>
851
+ </div>
852
+ )}
307
853
 
308
854
  {/* Logs */}
309
855
  {logCount > 0 && (
@@ -318,21 +864,63 @@ export function SpanDetailsPanel({
318
864
  )}
319
865
  </div>
320
866
 
867
+ {/* Tool Call Input/Output - only show for tool_call and subagent spans */}
868
+ {(spanType === "tool_call" || spanType === "subagent") && (
869
+ <>
870
+ <JsonSection title="Input" data={attrs["tool.input"]} />
871
+ <JsonSection title="Output" data={attrs["tool.output"]} />
872
+ </>
873
+ )}
874
+
875
+ {/* Chat Messages - only show for chat spans */}
876
+ {spanType === "chat" && (
877
+ <>
878
+ <MessageSection
879
+ title="Input Messages"
880
+ data={attrs["gen_ai.input.messages"]}
881
+ />
882
+ <MessageSection
883
+ title="Output Messages"
884
+ data={attrs["gen_ai.output.messages"]}
885
+ />
886
+ </>
887
+ )}
888
+
321
889
  {/* Attributes */}
322
- {attrs && Object.keys(attrs).length > 0 && (
323
- <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
324
- <div className="p-3 border-b border-border">
325
- <h3 className="text-[8px] font-bold text-muted-foreground uppercase tracking-wider">
326
- Attributes
327
- </h3>
328
- </div>
329
- <div className="p-3">
330
- <pre className="text-[11px] font-mono text-foreground leading-[14px] whitespace-pre-wrap break-all">
331
- {JSON.stringify(attrs, null, 2)}
332
- </pre>
890
+ {(() => {
891
+ // Filter out span-specific keys
892
+ let keysToExclude: string[] = [];
893
+
894
+ if (spanType === "tool_call" || spanType === "subagent") {
895
+ keysToExclude = ["tool.name", "tool.input", "tool.output"];
896
+ } else if (spanType === "chat") {
897
+ keysToExclude = [
898
+ "gen_ai.input.messages",
899
+ "gen_ai.output.messages",
900
+ ];
901
+ }
902
+
903
+ const filteredAttrs = { ...attrs };
904
+ for (const key of keysToExclude) {
905
+ delete filteredAttrs[key];
906
+ }
907
+
908
+ // Only show if there are attributes remaining
909
+ return Object.keys(filteredAttrs).length > 0 ? (
910
+ <div className="bg-muted/50 border border-border rounded-lg overflow-hidden">
911
+ <div className="p-3 border-b border-border">
912
+ <h3 className="text-[8px] font-bold text-muted-foreground uppercase tracking-wider">
913
+ Attributes
914
+ </h3>
915
+ </div>
916
+ <div className="p-3">
917
+ <pre className="text-[11px] font-mono text-foreground leading-[14px] whitespace-pre-wrap break-all">
918
+ {JSON.stringify(filteredAttrs, null, 2)}
919
+ </pre>
920
+ </div>
333
921
  </div>
334
- </div>
335
- )}
922
+ ) : null;
923
+ })()}
336
924
 
337
925
  {/* Resource */}
338
926
  {resourceAttrs && Object.keys(resourceAttrs).length > 0 && (
package/src/server.ts CHANGED
@@ -35,6 +35,7 @@ export function startDebuggerServer(
35
35
  const otlpApp = createOtlpServer({ dbPath });
36
36
  const otlpServer = serve({
37
37
  fetch: otlpApp.fetch,
38
+ hostname: Bun.env.BIND_HOST || "localhost",
38
39
  port: otlpPort,
39
40
  });
40
41