@tonyclaw/llm-inspector 1.14.7 → 1.14.9

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.
Files changed (34) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/index-Dv-dj1xH.js +105 -0
  3. package/.output/public/assets/index-bqeypwJB.css +1 -0
  4. package/.output/public/assets/{main-BV7uNIIz.js → main-C8OUJKbz.js} +1 -1
  5. package/.output/server/_libs/lucide-react.mjs +87 -79
  6. package/.output/server/_libs/radix-ui__react-id.mjs +1 -1
  7. package/.output/server/_ssr/{index-BvHLASu8.mjs → index-_9xcAkkw.mjs} +861 -608
  8. package/.output/server/_ssr/index.mjs +2 -2
  9. package/.output/server/_ssr/{router-lUOA8pi6.mjs → router-CmanwZJc.mjs} +45 -14
  10. package/.output/server/{_tanstack-start-manifest_v-XNH7fVPN.mjs → _tanstack-start-manifest_v-BVIiyDeJ.mjs} +1 -1
  11. package/.output/server/index.mjs +23 -23
  12. package/package.json +1 -1
  13. package/src/components/ProxyViewer.tsx +137 -146
  14. package/src/components/providers/ProviderCard.tsx +79 -26
  15. package/src/components/providers/ProviderForm.tsx +37 -22
  16. package/src/components/providers/ProvidersPanel.tsx +79 -47
  17. package/src/components/providers/SettingsDialog.tsx +25 -15
  18. package/src/components/proxy-viewer/ConversationGroup.tsx +74 -11
  19. package/src/components/proxy-viewer/ConversationHeader.tsx +63 -2
  20. package/src/components/proxy-viewer/LogEntry.tsx +184 -54
  21. package/src/components/proxy-viewer/LogEntryHeader.tsx +148 -143
  22. package/src/components/proxy-viewer/ReplayDialog.tsx +16 -6
  23. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +24 -16
  24. package/src/components/proxy-viewer/ThreadConnector.tsx +93 -0
  25. package/src/components/proxy-viewer/index.ts +2 -1
  26. package/src/lib/stopReason.ts +57 -0
  27. package/src/proxy/formats/anthropic/handler.ts +2 -5
  28. package/src/proxy/formats/openai/handler.ts +33 -7
  29. package/src/proxy/formats/openai/schemas.ts +1 -0
  30. package/src/proxy/formats/openai/stream.ts +24 -0
  31. package/src/proxy/handler.ts +8 -2
  32. package/src/proxy/schemas.ts +6 -3
  33. package/.output/public/assets/index-Cmi8TfeU.js +0 -105
  34. package/.output/public/assets/index-DXUNTCVh.css +0 -1
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  ArrowDown,
3
3
  ArrowUp,
4
- Check,
5
4
  ChevronDown,
6
5
  ChevronRight,
7
6
  Clock,
@@ -10,16 +9,16 @@ import {
10
9
  Loader2,
11
10
  MessageSquare,
12
11
  Radio,
13
- User,
14
12
  Wrench,
15
13
  Zap,
16
14
  } from "lucide-react";
17
15
  import type { JSX } from "react";
18
16
  import { memo } from "react";
19
17
  import { cn, formatTokens, getStatusCategory, type StatusCategory } from "../../lib/utils";
20
- import type { CapturedLog, InspectorRequest } from "../../proxy/schemas";
18
+ import type { CapturedLog } from "../../proxy/schemas";
21
19
  import { Badge } from "../ui/badge";
22
20
  import { ProviderLogo, detectProvider } from "../providers/ProviderLogo";
21
+ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
23
22
  import type { CacheTrend } from "./cacheTrend";
24
23
 
25
24
  function formatElapsed(ms: number): string {
@@ -56,44 +55,34 @@ function CacheTrendIndicator({ trend }: { trend: CacheTrend | null }): JSX.Eleme
56
55
 
57
56
  export type LogEntryHeaderProps = {
58
57
  log: CapturedLog;
59
- parsedRequest: InspectorRequest | null;
58
+ /** Number of messages in the request (supports both Anthropic and OpenAI formats). */
59
+ messageCount?: number | null;
60
+ /** Number of tools defined in the request (supports both Anthropic and OpenAI formats). */
61
+ toolCount?: number | null;
60
62
  expanded: boolean;
61
63
  onToggle: () => void;
62
- /** Suppress the API format badge when log is displayed within a group */
63
- suppressApiFormatBadge?: boolean;
64
- /**
65
- * Per-log cache token trend (creation + read) relative to the previous log
64
+ /** Tool call names extracted from the model response (e.g., ["read_file", "grep"]). */
65
+ responseToolNames?: string[] | null;
66
+ /** Per-log cache token trend (creation + read) relative to the previous log
66
67
  * in the same conversation group. When `undefined` or a field is `null`,
67
68
  * the corresponding cache span renders as it did before — no arrow.
68
69
  */
69
70
  cacheTrend?: { creation: CacheTrend | null; read: CacheTrend | null } | null;
70
- /** Whether this log is currently marked for comparison. */
71
- isSelected?: boolean;
72
- /** Toggle this log in/out of the comparison selection. */
73
- onToggleSelect?: (logId: number) => void;
74
71
  };
75
72
 
76
73
  export const LogEntryHeader = memo(function ({
77
74
  log,
78
- parsedRequest,
75
+ messageCount = null,
76
+ toolCount = null,
79
77
  expanded,
80
78
  onToggle,
81
- suppressApiFormatBadge = false,
79
+ responseToolNames = null,
82
80
  cacheTrend = null,
83
- isSelected = false,
84
- onToggleSelect,
85
81
  }: LogEntryHeaderProps): JSX.Element {
86
82
  const statusCategory = getStatusCategory(log.responseStatus);
87
83
 
88
84
  const hasTokens = log.inputTokens !== null || log.outputTokens !== null;
89
85
 
90
- const messageCount = parsedRequest !== null ? parsedRequest.messages.length : null;
91
-
92
- const toolCount =
93
- parsedRequest !== null && parsedRequest.tools !== undefined && parsedRequest.tools.length > 0
94
- ? parsedRequest.tools.length
95
- : null;
96
-
97
86
  return (
98
87
  <div
99
88
  role="button"
@@ -111,86 +100,57 @@ export const LogEntryHeader = memo(function ({
111
100
  }
112
101
  }}
113
102
  >
114
- {/* Selection checkbox (for log-request comparison) */}
115
- {onToggleSelect !== undefined && (
116
- <button
117
- type="button"
118
- onClick={(e) => {
119
- e.stopPropagation();
120
- onToggleSelect(log.id);
121
- }}
122
- aria-label={isSelected ? "Deselect for comparison" : "Select for comparison"}
123
- aria-pressed={isSelected}
124
- className={cn(
125
- "shrink-0 size-4 rounded-sm border flex items-center justify-center transition-colors cursor-pointer",
126
- isSelected
127
- ? "bg-amber-400 border-amber-400 text-amber-950"
128
- : "border-muted-foreground/40 hover:border-amber-400 hover:bg-amber-400/10",
129
- )}
130
- >
131
- {isSelected && <Check className="size-3" strokeWidth={3} />}
132
- </button>
133
- )}
134
-
135
103
  {/* Request ID */}
136
104
  <span className="text-blue-400/80 font-mono text-xs font-semibold tabular-nums shrink-0">
137
105
  #{log.id}
138
106
  </span>
139
107
 
140
- {/* Model */}
108
+ {/* Model — logo icon only, model name in tooltip */}
141
109
  {log.model !== null && (
142
- <>
143
- <ProviderLogo provider={detectProvider(log.model)} className="size-4 shrink-0" />
144
- <Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-mono">
145
- {log.model}
146
- </Badge>
147
- </>
148
- )}
149
-
150
- {/* API Format Badge */}
151
- {!suppressApiFormatBadge && (
152
- <Badge
153
- variant="outline"
154
- className={cn(
155
- "text-[10px] px-1.5 py-0 h-5 font-mono shrink-0",
156
- log.apiFormat === "openai" && "border-blue-500/40 text-blue-400",
157
- log.apiFormat === "anthropic" && "border-orange-500/40 text-orange-400",
158
- log.apiFormat === "unknown" && "border-muted text-muted-foreground",
159
- )}
160
- >
161
- {log.apiFormat === "anthropic"
162
- ? "Anthropic"
163
- : log.apiFormat === "openai"
164
- ? "OpenAI"
165
- : "Unknown"}
166
- </Badge>
110
+ <TooltipProvider>
111
+ <Tooltip>
112
+ <TooltipTrigger asChild>
113
+ <span className="shrink-0">
114
+ <ProviderLogo provider={detectProvider(log.model)} className="size-4" />
115
+ </span>
116
+ </TooltipTrigger>
117
+ <TooltipContent>{log.model}</TooltipContent>
118
+ </Tooltip>
119
+ </TooltipProvider>
167
120
  )}
168
121
 
169
- {/* Response Status */}
170
- {statusCategory === "server_error" ? (
171
- <Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-5 font-mono tabular-nums">
172
- {log.responseStatus}
173
- </Badge>
174
- ) : statusCategory === "pending" ? (
175
- <Badge
176
- variant="outline"
177
- className={cn(
178
- "text-[10px] px-1.5 py-0 h-5 font-mono tabular-nums",
179
- STATUS_BADGE_CLASSES[statusCategory],
180
- )}
181
- >
182
- <Loader2 className="size-3 animate-spin" />
183
- </Badge>
184
- ) : (
185
- <Badge
186
- variant="outline"
187
- className={cn(
188
- "text-[10px] px-1.5 py-0 h-5 font-mono tabular-nums",
189
- STATUS_BADGE_CLASSES[statusCategory],
122
+ {/* Response Status — only shown for non-200 or pending */}
123
+ {statusCategory !== "success" && (
124
+ <>
125
+ {statusCategory === "server_error" ? (
126
+ <Badge
127
+ variant="destructive"
128
+ className="text-[10px] px-1.5 py-0 h-5 font-mono tabular-nums"
129
+ >
130
+ {log.responseStatus}
131
+ </Badge>
132
+ ) : statusCategory === "pending" ? (
133
+ <Badge
134
+ variant="outline"
135
+ className={cn(
136
+ "text-[10px] px-1.5 py-0 h-5 font-mono tabular-nums",
137
+ STATUS_BADGE_CLASSES[statusCategory],
138
+ )}
139
+ >
140
+ <Loader2 className="size-3 animate-spin" />
141
+ </Badge>
142
+ ) : (
143
+ <Badge
144
+ variant="outline"
145
+ className={cn(
146
+ "text-[10px] px-1.5 py-0 h-5 font-mono tabular-nums",
147
+ STATUS_BADGE_CLASSES[statusCategory],
148
+ )}
149
+ >
150
+ {log.responseStatus}
151
+ </Badge>
190
152
  )}
191
- >
192
- {log.responseStatus}
193
- </Badge>
153
+ </>
194
154
  )}
195
155
 
196
156
  {/* Elapsed time */}
@@ -220,36 +180,81 @@ export const LogEntryHeader = memo(function ({
220
180
  )}
221
181
  {/* Cache tokens */}
222
182
  {log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
223
- <span className="flex items-center gap-1 text-xs shrink-0">
224
- <CacheTrendIndicator trend={cacheTrend?.creation ?? null} />
225
- <span className="font-mono tabular-nums text-emerald-400">
226
- Cache +{formatTokens(log.cacheCreationInputTokens)}
227
- </span>
228
- </span>
183
+ <TooltipProvider>
184
+ <Tooltip>
185
+ <TooltipTrigger asChild>
186
+ <span className="flex items-center gap-1 text-xs shrink-0">
187
+ <CacheTrendIndicator trend={cacheTrend?.creation ?? null} />
188
+ <span className="font-mono tabular-nums text-emerald-400">
189
+ Cache +{formatTokens(log.cacheCreationInputTokens)}
190
+ </span>
191
+ </span>
192
+ </TooltipTrigger>
193
+ <TooltipContent>Tokens cached for reuse, reducing future API cost</TooltipContent>
194
+ </Tooltip>
195
+ </TooltipProvider>
229
196
  )}
230
197
  {log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
231
- <span className="flex items-center gap-1 text-xs shrink-0">
232
- <CacheTrendIndicator trend={cacheTrend?.read ?? null} />
233
- <span className="font-mono tabular-nums text-purple-400">
234
- Cache ~{formatTokens(log.cacheReadInputTokens)}
235
- </span>
236
- </span>
198
+ <TooltipProvider>
199
+ <Tooltip>
200
+ <TooltipTrigger asChild>
201
+ <span className="flex items-center gap-1 text-xs shrink-0">
202
+ <CacheTrendIndicator trend={cacheTrend?.read ?? null} />
203
+ <span className="font-mono tabular-nums text-purple-400">
204
+ Cache ~{formatTokens(log.cacheReadInputTokens)}
205
+ </span>
206
+ </span>
207
+ </TooltipTrigger>
208
+ <TooltipContent>Tokens served from cache, reducing API cost</TooltipContent>
209
+ </Tooltip>
210
+ </TooltipProvider>
237
211
  )}
238
212
 
239
213
  {/* Message count */}
240
214
  {messageCount !== null && (
241
- <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
242
- <MessageSquare className="size-3" />
243
- <span className="font-mono tabular-nums">{messageCount}</span>
244
- </span>
215
+ <TooltipProvider>
216
+ <Tooltip>
217
+ <TooltipTrigger asChild>
218
+ <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
219
+ <MessageSquare className="size-3" />
220
+ <span className="font-mono tabular-nums">{messageCount}</span>
221
+ </span>
222
+ </TooltipTrigger>
223
+ <TooltipContent>Number of messages in the conversation</TooltipContent>
224
+ </Tooltip>
225
+ </TooltipProvider>
245
226
  )}
246
227
 
247
228
  {/* Tool count */}
248
229
  {toolCount !== null && (
249
- <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
250
- <Wrench className="size-3" />
251
- <span className="font-mono tabular-nums">{toolCount}</span>
252
- </span>
230
+ <TooltipProvider>
231
+ <Tooltip>
232
+ <TooltipTrigger asChild>
233
+ <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
234
+ <Wrench className="size-3" />
235
+ <span className="font-mono tabular-nums">{toolCount}</span>
236
+ </span>
237
+ </TooltipTrigger>
238
+ <TooltipContent>Number of tools defined in the request</TooltipContent>
239
+ </Tooltip>
240
+ </TooltipProvider>
241
+ )}
242
+
243
+ {/* Response tool calls — tool names the model requested to invoke */}
244
+ {responseToolNames !== null && responseToolNames.length > 0 && (
245
+ <TooltipProvider>
246
+ <Tooltip>
247
+ <TooltipTrigger asChild>
248
+ <span className="flex items-center gap-1 text-amber-400/80 text-xs shrink-0">
249
+ <Wrench className="size-3" />
250
+ <span className="font-mono tabular-nums truncate max-w-[160px]">
251
+ {responseToolNames.join(", ")}
252
+ </span>
253
+ </span>
254
+ </TooltipTrigger>
255
+ <TooltipContent>Tools called by model: {responseToolNames.join(", ")}</TooltipContent>
256
+ </Tooltip>
257
+ </TooltipProvider>
253
258
  )}
254
259
 
255
260
  {/* Origin */}
@@ -265,40 +270,40 @@ export const LogEntryHeader = memo(function ({
265
270
  </span>
266
271
  )}
267
272
 
268
- {/* User-Agent */}
269
- {log.userAgent !== null && (
270
- <span
271
- className="flex items-center gap-1 text-muted-foreground text-xs shrink-0"
272
- title={`User-Agent: ${log.userAgent}`}
273
- >
274
- <User className="size-3" />
275
- <span className="font-mono tabular-nums truncate max-w-[150px]" title={log.userAgent}>
276
- {log.userAgent}
277
- </span>
278
- </span>
279
- )}
280
-
281
273
  {/* Client info (PID + project folder) */}
282
274
  {(log.clientPid !== null || log.clientProjectFolder !== null) && (
283
- <span
284
- className="flex items-center gap-1 text-purple-400/80 text-xs shrink-0"
285
- title={
286
- log.clientCwd !== null
287
- ? `PID: ${log.clientPid ?? "?"} | CWD: ${log.clientCwd}`
288
- : `PID: ${log.clientPid ?? "?"}`
289
- }
290
- >
291
- <FileTerminal className="size-3" />
292
- {log.clientProjectFolder !== null ? (
293
- <span className="font-mono tabular-nums">{log.clientProjectFolder}</span>
294
- ) : (
295
- <span className="font-mono tabular-nums">PID {log.clientPid}</span>
296
- )}
297
- </span>
275
+ <TooltipProvider>
276
+ <Tooltip>
277
+ <TooltipTrigger asChild>
278
+ <span className="flex items-center gap-1 text-purple-400/80 text-xs shrink-0">
279
+ <FileTerminal className="size-3" />
280
+ {log.clientProjectFolder !== null ? (
281
+ <span className="font-mono tabular-nums">{log.clientProjectFolder}</span>
282
+ ) : (
283
+ <span className="font-mono tabular-nums">PID {log.clientPid}</span>
284
+ )}
285
+ </span>
286
+ </TooltipTrigger>
287
+ <TooltipContent>
288
+ {log.clientCwd !== null
289
+ ? `PID: ${log.clientPid ?? "?"} CWD: ${log.clientCwd}`
290
+ : `Process ID: ${log.clientPid ?? "?"}`}
291
+ </TooltipContent>
292
+ </Tooltip>
293
+ </TooltipProvider>
298
294
  )}
299
295
 
300
296
  {/* Streaming indicator */}
301
- {log.streaming && <Radio className="size-3 text-muted-foreground/60 shrink-0" />}
297
+ {log.streaming && (
298
+ <TooltipProvider>
299
+ <Tooltip>
300
+ <TooltipTrigger asChild>
301
+ <Radio className="size-3 text-muted-foreground/60 shrink-0" />
302
+ </TooltipTrigger>
303
+ <TooltipContent>Request used SSE streaming</TooltipContent>
304
+ </Tooltip>
305
+ </TooltipProvider>
306
+ )}
302
307
 
303
308
  {/* Spacer */}
304
309
  <span className="flex-1 min-w-0" />
@@ -3,6 +3,7 @@ import type { JSX } from "react";
3
3
  import { useState } from "react";
4
4
  import { z } from "zod";
5
5
  import { Button } from "../ui/button";
6
+ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
6
7
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
7
8
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
8
9
  import { ResponseView } from "./ResponseView";
@@ -92,12 +93,21 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
92
93
  <TabsContent value="modified" className="space-y-4">
93
94
  <div>
94
95
  <label className="text-sm font-medium mb-2 block">Request Body (JSON)</label>
95
- <textarea
96
- className="w-full h-64 p-3 font-mono text-xs bg-muted rounded-md border border-input resize-none focus:outline-none focus:ring-2 focus:ring-ring"
97
- value={modifiedBody}
98
- onChange={(e) => setModifiedBody(e.target.value)}
99
- spellCheck={false}
100
- />
96
+ <TooltipProvider>
97
+ <Tooltip>
98
+ <TooltipTrigger asChild>
99
+ <textarea
100
+ className="w-full h-64 p-3 font-mono text-xs bg-muted rounded-md border border-input resize-none focus:outline-none focus:ring-2 focus:ring-ring"
101
+ value={modifiedBody}
102
+ onChange={(e) => setModifiedBody(e.target.value)}
103
+ spellCheck={false}
104
+ />
105
+ </TooltipTrigger>
106
+ <TooltipContent>
107
+ Edit the request body before re-sending to the provider
108
+ </TooltipContent>
109
+ </Tooltip>
110
+ </TooltipProvider>
101
111
  </div>
102
112
 
103
113
  {error !== null && error !== "" && (
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, type JSX } from "react";
2
2
  import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
3
3
  import { Badge } from "../ui/badge";
4
4
  import { JsonViewer } from "../ui/json-viewer";
5
+ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
5
6
  import type { StreamingChunk } from "../../proxy/schemas";
6
7
 
7
8
  export type StreamingChunkSequenceProps = {
@@ -153,22 +154,29 @@ export function StreamingChunkSequence({
153
154
 
154
155
  return (
155
156
  <div className="space-y-1">
156
- <button
157
- type="button"
158
- className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
159
- onClick={() => setContainerExpanded((v) => !v)}
160
- >
161
- {containerExpanded ? (
162
- <ChevronDown className="size-3" />
163
- ) : (
164
- <ChevronRight className="size-3" />
165
- )}
166
- <span>Raw SSE Events</span>
167
- <Badge variant="outline" className="text-[9px] px-1 py-0 h-4 font-mono ml-1">
168
- {logId}
169
- {truncated === true ? "+" : ""}
170
- </Badge>
171
- </button>
157
+ <TooltipProvider>
158
+ <Tooltip>
159
+ <TooltipTrigger asChild>
160
+ <button
161
+ type="button"
162
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
163
+ onClick={() => setContainerExpanded((v) => !v)}
164
+ >
165
+ {containerExpanded ? (
166
+ <ChevronDown className="size-3" />
167
+ ) : (
168
+ <ChevronRight className="size-3" />
169
+ )}
170
+ <span>Raw SSE Events</span>
171
+ <Badge variant="outline" className="text-[9px] px-1 py-0 h-4 font-mono ml-1">
172
+ {logId}
173
+ {truncated === true ? "+" : ""}
174
+ </Badge>
175
+ </button>
176
+ </TooltipTrigger>
177
+ <TooltipContent>Server-Sent Events streaming chunks from the provider</TooltipContent>
178
+ </Tooltip>
179
+ </TooltipProvider>
172
180
 
173
181
  {containerExpanded === true ? (
174
182
  <div className="rounded-md border border-border bg-muted/20 overflow-auto max-h-64">
@@ -0,0 +1,93 @@
1
+ import type { JSX } from "react";
2
+ import { cn } from "../../lib/utils";
3
+ import type { StopReason } from "../../lib/stopReason";
4
+
5
+ export type ThreadConnectorProps = {
6
+ stopReason: StopReason;
7
+ isPending: boolean;
8
+ isFirst: boolean;
9
+ isLast: boolean;
10
+ /** True when this entry starts a new turn (first overall, or after end_turn/stop). */
11
+ isTurnStart: boolean;
12
+ };
13
+
14
+ /**
15
+ * Vertical timeline connector for thread view. Uses flexbox layout (no
16
+ * absolute positioning) so the connector naturally tracks its sibling
17
+ * LogEntry height — no scroll jitter.
18
+ */
19
+ export function ThreadConnector({
20
+ stopReason,
21
+ isPending,
22
+ isFirst,
23
+ isLast: _isLast,
24
+ isTurnStart,
25
+ }: ThreadConnectorProps): JSX.Element {
26
+ const isBoundary = stopReason === "end_turn" || stopReason === "stop";
27
+ const isToolUse = stopReason === "tool_use";
28
+
29
+ return (
30
+ <div className="flex flex-col items-center w-6 shrink-0">
31
+ {/* Top: incoming line from previous entry, or empty spacer for first.
32
+ Fixed height so the marker stays near the LogEntry header row. */}
33
+ <div className="flex justify-center h-4">
34
+ {!isFirst && <div className="w-0.5 bg-muted-foreground/30" />}
35
+ </div>
36
+
37
+ {/* Center marker — aligned with the LogEntry header row */}
38
+ <div className="flex items-center justify-center py-0.5">
39
+ {isBoundary ? (
40
+ <div
41
+ className={cn(
42
+ "size-2.5 rounded-full border-2",
43
+ "bg-background border-amber-400",
44
+ "shadow-[0_0_6px_rgba(251,191,36,0.4)]",
45
+ )}
46
+ title={stopReason === "end_turn" ? "End of Turn (Anthropic)" : "End of Turn (OpenAI)"}
47
+ />
48
+ ) : isToolUse ? (
49
+ <div
50
+ className={cn(
51
+ "size-2 rounded-full",
52
+ isTurnStart
53
+ ? "bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]"
54
+ : "bg-muted-foreground/25",
55
+ )}
56
+ title={isTurnStart ? "Tool Use — start of turn" : "Tool Use — turn continues"}
57
+ />
58
+ ) : isPending ? (
59
+ <div
60
+ className="size-2.5 rounded-full border-2 border-dashed border-muted-foreground/30 animate-pulse"
61
+ title="Response pending"
62
+ />
63
+ ) : (
64
+ <div
65
+ className={cn(
66
+ "size-1.5 rounded-full",
67
+ isTurnStart
68
+ ? "bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]"
69
+ : "bg-muted-foreground/30",
70
+ )}
71
+ />
72
+ )}
73
+ </div>
74
+
75
+ {/* Bottom: outgoing line to next entry, or short terminator at boundaries.
76
+ flex-1 fills the remaining height of the LogEntry card. */}
77
+ <div className="flex-1 flex justify-center min-h-1">
78
+ {isBoundary ? (
79
+ <div className="w-0.5 bg-muted-foreground/10 h-4" />
80
+ ) : (
81
+ <div
82
+ className={cn(
83
+ "w-0.5 h-full",
84
+ isPending
85
+ ? "border-dashed bg-transparent border-l-2 border-muted-foreground/20"
86
+ : "bg-muted-foreground/30",
87
+ )}
88
+ />
89
+ )}
90
+ </div>
91
+ </div>
92
+ );
93
+ }
@@ -4,5 +4,6 @@ export {
4
4
  getConversationId,
5
5
  groupLogsByConversation,
6
6
  } from "./ConversationHeader";
7
- export type { ConversationGroupData } from "./ConversationHeader";
7
+ export type { ConversationGroupData, ViewMode } from "./ConversationHeader";
8
8
  export { LogEntry } from "./LogEntry";
9
+ export { ThreadConnector } from "./ThreadConnector";
@@ -0,0 +1,57 @@
1
+ import type { CapturedLog } from "../proxy/schemas";
2
+
3
+ export type StopReason = "end_turn" | "tool_use" | "stop" | null;
4
+
5
+ function isRecord(value: unknown): value is Record<string, unknown> {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+
9
+ /**
10
+ * Extracts the stop/finish reason from a captured log's response text.
11
+ * Returns the raw stop_reason value for Anthropic, the finish_reason for
12
+ * OpenAI, or null if the response is pending, malformed, or unrecognized.
13
+ */
14
+ export function extractStopReason(log: CapturedLog): StopReason {
15
+ if (log.responseText === null) return null;
16
+
17
+ try {
18
+ let json: unknown = JSON.parse(log.responseText);
19
+ // Handle double-encoded JSON
20
+ if (typeof json === "string") {
21
+ json = JSON.parse(json);
22
+ }
23
+ if (!isRecord(json)) return null;
24
+
25
+ // Anthropic: { stop_reason: "end_turn" | "tool_use" | ... }
26
+ if (log.apiFormat === "anthropic" && typeof json.stop_reason === "string") {
27
+ if (json.stop_reason === "end_turn" || json.stop_reason === "tool_use") {
28
+ return json.stop_reason;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ // OpenAI: { choices: [{ finish_reason: "stop" | ... }] }
34
+ if (
35
+ log.apiFormat === "openai" &&
36
+ Array.isArray(json.choices) &&
37
+ json.choices.length > 0 &&
38
+ isRecord(json.choices[0]) &&
39
+ typeof json.choices[0].finish_reason === "string" &&
40
+ json.choices[0].finish_reason === "stop"
41
+ ) {
42
+ return "stop";
43
+ }
44
+
45
+ return null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Returns true when the stop reason indicates the assistant completed its
53
+ * turn naturally (Anthropic end_turn or OpenAI stop).
54
+ */
55
+ export function isTurnBoundary(stopReason: StopReason): boolean {
56
+ return stopReason === "end_turn" || stopReason === "stop";
57
+ }
@@ -60,11 +60,8 @@ export const AnthropicFormatHandler: FormatHandler = {
60
60
  const json: unknown = JSON.parse(rawBody);
61
61
  if (typeof json === "object" && json !== null && !Array.isArray(json)) {
62
62
  const keys = Object.keys(json);
63
- if (keys.includes("model") && keys.includes("messages")) {
64
- if (keys.includes("system") || keys.includes("tools")) {
65
- return true;
66
- }
67
- }
63
+ // Anthropic puts `system` as a top-level key alongside `model` and `messages`
64
+ return keys.includes("model") && keys.includes("messages") && keys.includes("system");
68
65
  }
69
66
  return false;
70
67
  } catch {