@tonyclaw/llm-inspector 1.15.0 → 1.16.0

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 (54) hide show
  1. package/.output/cli.js +1 -0
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/index-BmkN9DxE.js +107 -0
  4. package/.output/public/assets/index-DPe3eOih.css +1 -0
  5. package/.output/public/assets/{main-BLYgekFx.js → main-BjnjXVBU.js} +1 -1
  6. package/.output/server/_libs/diff.mjs +2 -2
  7. package/.output/server/_ssr/{index-P66uoVEU.mjs → index-BIOEVAzU.mjs} +783 -588
  8. package/.output/server/_ssr/index.mjs +2 -2
  9. package/.output/server/_ssr/{router-DpLCKk51.mjs → router-THS9ptvu.mjs} +439 -177
  10. package/.output/server/{_tanstack-start-manifest_v-C9Wq6YdJ.mjs → _tanstack-start-manifest_v-BYhN7q_z.mjs} +1 -1
  11. package/.output/server/index.mjs +31 -31
  12. package/README.md +200 -113
  13. package/package.json +1 -1
  14. package/src/cli.ts +1 -0
  15. package/src/components/ProxyViewer.tsx +77 -85
  16. package/src/components/ProxyViewerContainer.tsx +148 -76
  17. package/src/components/providers/ImportWizardDialog.tsx +27 -3
  18. package/src/components/proxy-viewer/CompareDrawer.tsx +17 -4
  19. package/src/components/proxy-viewer/ConversationGroup.tsx +15 -47
  20. package/src/components/proxy-viewer/ConversationHeader.tsx +58 -5
  21. package/src/components/proxy-viewer/LogEntry.tsx +297 -329
  22. package/src/components/proxy-viewer/LogEntryHeader.tsx +126 -137
  23. package/src/components/proxy-viewer/ResponseView.tsx +14 -34
  24. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +3 -3
  25. package/src/components/proxy-viewer/TurnGroup.tsx +25 -21
  26. package/src/components/proxy-viewer/diff/DiffView.tsx +5 -3
  27. package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +13 -9
  28. package/src/components/proxy-viewer/formats/anthropic/ResponseView.tsx +3 -3
  29. package/src/components/proxy-viewer/formats/index.tsx +19 -10
  30. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +7 -3
  31. package/src/components/proxy-viewer/log-formats/anthropic.ts +48 -0
  32. package/src/components/proxy-viewer/log-formats/index.ts +23 -0
  33. package/src/components/proxy-viewer/log-formats/openai.ts +40 -0
  34. package/src/components/proxy-viewer/log-formats/types.ts +33 -0
  35. package/src/components/proxy-viewer/log-formats/unknown.ts +14 -0
  36. package/src/components/proxy-viewer/viewerState.ts +58 -0
  37. package/src/components/ui/json-viewer.tsx +3 -3
  38. package/src/lib/objectUtils.ts +22 -0
  39. package/src/proxy/claudeCodeStrip.ts +5 -8
  40. package/src/proxy/formats/index.ts +1 -1
  41. package/src/proxy/formats/registry.ts +9 -0
  42. package/src/proxy/handler.ts +2 -8
  43. package/src/proxy/logIndex.ts +58 -43
  44. package/src/proxy/logger.ts +51 -27
  45. package/src/proxy/openaiOrphanToolStrip.ts +11 -17
  46. package/src/proxy/providerImporters.ts +245 -19
  47. package/src/proxy/providers.ts +20 -7
  48. package/src/proxy/schemas.ts +5 -9
  49. package/src/proxy/socketTracker.ts +109 -78
  50. package/src/proxy/store.ts +68 -83
  51. package/src/routes/api/logs.ts +31 -2
  52. package/styles/globals.css +22 -0
  53. package/.output/public/assets/index-CMuJQyt1.js +0 -105
  54. package/.output/public/assets/index-DciyfYBk.css +0 -1
@@ -1,5 +1,4 @@
1
- import { type JSX, useCallback, useEffect, useMemo, useState, useRef } from "react";
2
- import { useVirtualizer } from "@tanstack/react-virtual";
1
+ import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
3
2
  import { Download } from "lucide-react";
4
3
 
5
4
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./ui/tooltip";
@@ -14,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
14
13
  import { SettingsDialog } from "./providers/SettingsDialog";
15
14
  import { computeCacheTrends } from "./proxy-viewer/cacheTrend";
16
15
  import { CompareDrawer } from "./proxy-viewer/CompareDrawer";
16
+ import { buildValidPredecessors } from "./proxy-viewer/viewerState";
17
17
 
18
18
  function truncateSessionId(id: string): string {
19
19
  if (id.length <= 30) return id;
@@ -91,6 +91,9 @@ export type ProxyViewerProps = {
91
91
  onSessionChange: (session: string) => void;
92
92
  onModelChange: (model: string) => void;
93
93
  onClearAll: () => void;
94
+ /** Clear only the logs whose ids are passed. Called by the per-group
95
+ * Clear button on each conversation header. */
96
+ onClearGroup: (ids: number[]) => void;
94
97
  viewMode: "simple" | "full";
95
98
  onViewModeChange: (mode: "simple" | "full") => void;
96
99
  /** Live strip-Claude-Code-billing-header flag, sourced once at the container. */
@@ -106,13 +109,29 @@ export function ProxyViewer({
106
109
  onSessionChange,
107
110
  onModelChange,
108
111
  onClearAll,
112
+ onClearGroup,
109
113
  viewMode,
110
114
  onViewModeChange,
111
115
  strip,
112
116
  }: ProxyViewerProps): JSX.Element {
113
- const { totalIn, totalOut } = computeTokenSummary(logs);
117
+ const { totalIn, totalOut } = useMemo(() => computeTokenSummary(logs), [logs]);
114
118
  const [exporting, setExporting] = useState(false);
115
119
  const [comparePair, setComparePair] = useState<[CapturedLog, CapturedLog] | null>(null);
120
+ const [crabEntrancePhase, setCrabEntrancePhase] = useState<"hidden" | "playing" | "done">(
121
+ "hidden",
122
+ );
123
+
124
+ useEffect(() => {
125
+ const perCrabDuration = 400;
126
+ const startDelay = 50;
127
+ const playingDone = startDelay + crabVariants.length * perCrabDuration;
128
+ const t1 = setTimeout(() => setCrabEntrancePhase("playing"), startDelay);
129
+ const t2 = setTimeout(() => setCrabEntrancePhase("done"), playingDone);
130
+ return () => {
131
+ clearTimeout(t1);
132
+ clearTimeout(t2);
133
+ };
134
+ }, []);
116
135
 
117
136
  const handleExport = useCallback(async () => {
118
137
  setExporting(true);
@@ -122,8 +141,6 @@ export function ProxyViewer({
122
141
  setExporting(false);
123
142
  }
124
143
  }, [logs]);
125
- const parentRef = useRef<HTMLDivElement>(null);
126
-
127
144
  // Close the compare drawer when the user changes the session or model
128
145
  // filter, since the predecessor relationship may no longer be meaningful.
129
146
  useEffect(() => {
@@ -134,62 +151,60 @@ export function ProxyViewer({
134
151
  setComparePair(null);
135
152
  }, []);
136
153
 
154
+ const groups = useMemo(() => groupLogsByConversation(logs), [logs]);
155
+ const cacheTrends = useMemo(() => computeCacheTrends(groups), [groups]);
156
+ const comparisonPredecessors = useMemo(() => buildValidPredecessors(groups), [groups]);
137
157
  const handleCompareWithPrevious = useCallback(
138
158
  (log: CapturedLog) => {
139
- const idx = logs.indexOf(log);
140
- if (idx <= 0) return;
141
- const predecessor = logs[idx - 1];
142
- if (predecessor === undefined) return;
143
- setComparePair([predecessor, log]);
159
+ const predecessor = comparisonPredecessors.get(log.id);
160
+ if (predecessor !== undefined) setComparePair([predecessor, log]);
144
161
  },
145
- [logs],
162
+ [comparisonPredecessors],
146
163
  );
147
164
 
148
- const groups = useMemo(() => groupLogsByConversation(logs), [logs]);
149
- const cacheTrends = useMemo(() => computeCacheTrends(groups), [groups]);
150
-
151
- const rowVirtualizer = useVirtualizer({
152
- count: groups.length,
153
- getScrollElement: () => parentRef.current,
154
- estimateSize: () => 150,
155
- measureElement:
156
- typeof window !== "undefined"
157
- ? (element) => element.getBoundingClientRect().height
158
- : undefined,
159
- overscan: 5,
160
- });
161
-
162
165
  return (
163
166
  <div className="max-w-[1200px] mx-auto flex flex-col h-screen" style={{ maxHeight: "100vh" }}>
164
167
  {/* Brand row */}
165
- <div className="flex items-end px-6 pt-6 pb-3 relative">
168
+ <div className="flex items-end px-6 pt-6 pb-8 relative">
166
169
  <h1 className="text-lg font-bold flex items-end gap-2 absolute left-1/2 -translate-x-1/2 whitespace-nowrap">
167
170
  {/* Crab family — hover to animate together */}
168
171
  <span className="flex items-end gap-1 group cursor-default" aria-hidden="true">
169
172
  <CrabLogo className="size-10 text-amber-500 transition-all duration-300 group-hover:scale-125 group-hover:-translate-y-1.5" />
170
173
  <span className="flex items-end gap-0.5">
171
- {crabVariants.map((Crab, i) => (
172
- <Crab
173
- key={i}
174
- className={`size-5 ${
175
- [
176
- "text-amber-500",
177
- "text-rose-500",
178
- "text-sky-500",
179
- "text-emerald-500",
180
- "text-violet-500",
181
- "text-orange-500",
182
- "text-cyan-500",
183
- "text-pink-500",
184
- "text-lime-500",
185
- "text-blue-500",
186
- "text-yellow-500",
187
- "text-fuchsia-500",
188
- ][i]
189
- } transition-all duration-300 ease-out group-hover:scale-125 group-hover:-translate-y-1`}
190
- style={{ transitionDelay: `${i * 50}ms` }}
191
- />
192
- ))}
174
+ {crabVariants.map((Crab, i) => {
175
+ const color = [
176
+ "text-amber-500",
177
+ "text-rose-500",
178
+ "text-sky-500",
179
+ "text-emerald-500",
180
+ "text-violet-500",
181
+ "text-orange-500",
182
+ "text-cyan-500",
183
+ "text-pink-500",
184
+ "text-lime-500",
185
+ "text-blue-500",
186
+ "text-yellow-500",
187
+ "text-fuchsia-500",
188
+ ][i];
189
+ const entranceClass =
190
+ crabEntrancePhase === "hidden"
191
+ ? "opacity-0 scale-0"
192
+ : crabEntrancePhase === "playing"
193
+ ? "animate-crab-piano-pop"
194
+ : "";
195
+ return (
196
+ <Crab
197
+ key={i}
198
+ className={`size-5 ${color} transition-all duration-300 ease-out group-hover:scale-125 group-hover:-translate-y-1 ${entranceClass}`}
199
+ style={{
200
+ transitionDelay: `${i * 50}ms`,
201
+ ...(crabEntrancePhase === "playing"
202
+ ? { animationDelay: `${i * 400}ms` }
203
+ : {}),
204
+ }}
205
+ />
206
+ );
207
+ })}
193
208
  </span>
194
209
  </span>
195
210
  <span className="flex items-baseline gap-2">
@@ -304,48 +319,25 @@ export function ProxyViewer({
304
319
  <div className="flex-1 min-h-0 px-6 pb-6">
305
320
  {logs.length === 0 ? (
306
321
  <div className="text-center text-muted-foreground py-16 space-y-4">
307
- <CrabLogo className="size-12 text-muted-foreground/20 mx-auto" />
308
322
  <p className="text-sm">No requests captured yet.</p>
309
323
  <p className="text-xs">Route AI coding tools through the proxy:</p>
310
324
  <CopyableCommand command="LLM_BASE_URL=http://localhost:25947/proxy <your-tool>" />
311
325
  </div>
312
326
  ) : (
313
- <div ref={parentRef} className="overflow-y-auto h-full">
314
- <div
315
- style={{
316
- height: `${rowVirtualizer.getTotalSize()}px`,
317
- width: "100%",
318
- position: "relative",
319
- }}
320
- >
321
- {rowVirtualizer.getVirtualItems().map((virtualRow) => {
322
- const group = groups[virtualRow.index];
323
- if (group === undefined) return null;
324
- return (
325
- <div
326
- key={group.id}
327
- data-index={virtualRow.index}
328
- ref={rowVirtualizer.measureElement}
329
- style={{
330
- position: "absolute",
331
- top: 0,
332
- left: 0,
333
- width: "100%",
334
- transform: `translateY(${virtualRow.start}px)`,
335
- }}
336
- >
337
- <ConversationGroup
338
- group={group}
339
- viewMode={viewMode}
340
- strip={strip}
341
- cacheTrends={cacheTrends}
342
- onCompareWithPrevious={handleCompareWithPrevious}
343
- standalone={groups.length === 1}
344
- />
345
- </div>
346
- );
347
- })}
348
- </div>
327
+ <div className="overflow-y-auto h-full flex flex-col gap-2">
328
+ {groups.map((group) => (
329
+ <ConversationGroup
330
+ key={group.id}
331
+ group={group}
332
+ viewMode={viewMode}
333
+ strip={strip}
334
+ cacheTrends={cacheTrends}
335
+ onCompareWithPrevious={handleCompareWithPrevious}
336
+ comparisonPredecessors={comparisonPredecessors}
337
+ onClearGroup={onClearGroup}
338
+ standalone={groups.length === 1}
339
+ />
340
+ ))}
349
341
  </div>
350
342
  )}
351
343
  </div>
@@ -1,23 +1,9 @@
1
- import { useState, useEffect, useCallback, useRef, type JSX } from "react";
1
+ import { useState, useEffect, useCallback, useRef, useMemo, type JSX } from "react";
2
2
  import { z } from "zod";
3
3
  import { CapturedLogSchema, type CapturedLog } from "../proxy/schemas";
4
4
  import { useStripConfig } from "../lib/useStripConfig";
5
5
  import { ProxyViewer } from "./ProxyViewer";
6
6
 
7
- type logsResponse = {
8
- logs: CapturedLog[];
9
- total: number;
10
- offset: number;
11
- limit: number;
12
- };
13
-
14
- const LogsResponseSchema = z.object({
15
- logs: z.array(CapturedLogSchema),
16
- total: z.number(),
17
- offset: z.number(),
18
- limit: z.number(),
19
- });
20
-
21
7
  type SSEUpdate =
22
8
  | {
23
9
  type: "init";
@@ -55,8 +41,31 @@ function extractModels(logs: CapturedLog[]): string[] {
55
41
  return [...set];
56
42
  }
57
43
 
44
+ /**
45
+ * Filter logs by selected session/model. Both `__all__` and missing values
46
+ * pass through. Returns the input array reference when no log matches the
47
+ * filter, so referential equality is preserved for empty results.
48
+ */
49
+ function filterLogs(
50
+ logs: CapturedLog[],
51
+ selectedSession: string,
52
+ selectedModel: string,
53
+ ): CapturedLog[] {
54
+ if (selectedSession === "__all__" && selectedModel === "__all__") return logs;
55
+ return logs.filter((l) => {
56
+ if (selectedSession !== "__all__" && l.sessionId !== selectedSession) return false;
57
+ if (selectedModel !== "__all__" && l.model !== selectedModel) return false;
58
+ return true;
59
+ });
60
+ }
61
+
62
+ const DEBOUNCE_MS = 50;
63
+
58
64
  export function ProxyViewerContainer(): JSX.Element {
59
- const [logs, setLogs] = useState<CapturedLog[]>([]);
65
+ // `allLogs` is the unfiltered set populated by the SSE. The single SSE
66
+ // connection never re-opens on filter change — we always carry the full
67
+ // set and derive the displayed view with `useMemo` below.
68
+ const [allLogs, setAllLogs] = useState<CapturedLog[]>([]);
60
69
  const [sessions, setSessions] = useState<string[]>([]);
61
70
  const [models, setModels] = useState<string[]>([]);
62
71
  const [selectedSession, setSelectedSession] = useState("__all__");
@@ -66,24 +75,63 @@ export function ProxyViewerContainer(): JSX.Element {
66
75
  const eventSourceRef = useRef<EventSource | null>(null);
67
76
  const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
68
77
 
69
- const fetchSessionsAndModels = useCallback(async () => {
70
- try {
71
- const [sessionsRes, modelsRes] = await Promise.all([
72
- fetch("/api/sessions"),
73
- fetch("/api/models"),
74
- ]);
75
- if (sessionsRes.ok && modelsRes.ok) {
76
- const sessionsJson: unknown = await sessionsRes.json();
77
- const modelsJson: unknown = await modelsRes.json();
78
- const sessionsResult = z.array(z.string()).safeParse(sessionsJson);
79
- const modelsResult = z.array(z.string()).safeParse(modelsJson);
80
- if (sessionsResult.success) setSessions(sessionsResult.data);
81
- if (modelsResult.success) setModels(modelsResult.data);
78
+ // O(1) log lookup by id
79
+ const logIndexRef = useRef<Map<number, number>>(new Map());
80
+ const logsRef = useRef<CapturedLog[]>([]);
81
+
82
+ // Debounce buffer for SSE updates
83
+ const pendingUpdatesRef = useRef<CapturedLog[]>([]);
84
+ const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
85
+
86
+ // Derived view: the logs the user actually sees. Recomputed only when the
87
+ // underlying set or filter changes — never on every SSE message.
88
+ const logs = useMemo(
89
+ () => filterLogs(allLogs, selectedSession, selectedModel),
90
+ [allLogs, selectedSession, selectedModel],
91
+ );
92
+
93
+ const flushUpdates = useCallback(() => {
94
+ flushTimerRef.current = null;
95
+ const updates = pendingUpdatesRef.current;
96
+ pendingUpdatesRef.current = [];
97
+ if (updates.length === 0) return;
98
+
99
+ setAllLogs((prev) => {
100
+ let next = prev;
101
+ let sessionsChanged = false;
102
+ let modelsChanged = false;
103
+ for (const log of updates) {
104
+ const idx = logIndexRef.current.get(log.id);
105
+ if (idx !== undefined) {
106
+ // Replace existing entry
107
+ next = [...next.slice(0, idx), log, ...next.slice(idx + 1)];
108
+ } else {
109
+ logIndexRef.current.set(log.id, next.length);
110
+ next = [...next, log];
111
+ }
112
+ if (log.sessionId !== null && log.sessionId !== "" && !sessions.includes(log.sessionId)) {
113
+ sessionsChanged = true;
114
+ }
115
+ if (log.model !== null && log.model !== "" && !models.includes(log.model)) {
116
+ modelsChanged = true;
117
+ }
82
118
  }
83
- } catch {
84
- // SSE already has sessions/models from init
85
- }
86
- }, []);
119
+ logsRef.current = next;
120
+ if (sessionsChanged) setSessions((s) => extractSessions(next));
121
+ if (modelsChanged) setModels((m) => extractModels(next));
122
+ return next;
123
+ });
124
+ }, [sessions, models]);
125
+
126
+ const scheduleUpdate = useCallback(
127
+ (log: CapturedLog) => {
128
+ pendingUpdatesRef.current.push(log);
129
+ if (flushTimerRef.current === null) {
130
+ flushTimerRef.current = setTimeout(flushUpdates, DEBOUNCE_MS);
131
+ }
132
+ },
133
+ [flushUpdates],
134
+ );
87
135
 
88
136
  const connectSSE = useCallback(() => {
89
137
  // Clean up existing connection
@@ -91,11 +139,12 @@ export function ProxyViewerContainer(): JSX.Element {
91
139
  eventSourceRef.current.close();
92
140
  }
93
141
 
94
- const params = new URLSearchParams();
95
- if (selectedSession !== "__all__") params.set("sessionId", selectedSession);
96
- if (selectedModel !== "__all__") params.set("model", selectedModel);
97
-
98
- const es = new EventSource(`/api/logs/stream?${params}`);
142
+ // Stable, unfiltered connection. The frontend derives filtered views
143
+ // from the complete set, so the SSE never needs to reopen on filter
144
+ // change. If the in-memory set ever grows past what the client can
145
+ // handle, swap this back to a parameterized URL and reconnect on
146
+ // filter change.
147
+ const es = new EventSource("/api/logs/stream");
99
148
  eventSourceRef.current = es;
100
149
 
101
150
  es.onmessage = (event: MessageEvent) => {
@@ -109,42 +158,27 @@ export function ProxyViewerContainer(): JSX.Element {
109
158
  }
110
159
  const update = updateResult.data;
111
160
  if (update.type === "init") {
112
- setLogs(update.logs);
161
+ // Flush any pending debounce
162
+ if (flushTimerRef.current !== null) {
163
+ clearTimeout(flushTimerRef.current);
164
+ flushTimerRef.current = null;
165
+ }
166
+ pendingUpdatesRef.current = [];
167
+
168
+ // Build fresh index
169
+ const idx = new Map<number, number>();
170
+ for (let i = 0; i < update.logs.length; i++) {
171
+ const log = update.logs[i];
172
+ if (log !== undefined) idx.set(log.id, i);
173
+ }
174
+ logIndexRef.current = idx;
175
+ logsRef.current = update.logs;
176
+ setAllLogs(update.logs);
113
177
  setSessions(extractSessions(update.logs));
114
178
  setModels(extractModels(update.logs));
115
179
  setError(null);
116
180
  } else if (update.type === "update") {
117
- setLogs((prev) => {
118
- // Filter by current selection before adding
119
- const sessionMatch =
120
- selectedSession === "__all__" || update.log.sessionId === selectedSession;
121
- const modelMatch = selectedModel === "__all__" || update.log.model === selectedModel;
122
- if (!sessionMatch || !modelMatch) return prev;
123
-
124
- // Update existing log or add as new
125
- const existsIndex = prev.findIndex((l) => l.id === update.log.id);
126
- if (existsIndex >= 0) {
127
- // Replace the existing log with updated data
128
- return prev.map((l) => (l.id === update.log.id ? update.log : l));
129
- }
130
- // Add to end (newest)
131
- return [...prev, update.log];
132
- });
133
- // Update sessions and models
134
- setSessions((prev) => {
135
- const sessionId = update.log.sessionId;
136
- if (sessionId !== null && sessionId !== undefined && sessionId !== "") {
137
- return prev.includes(sessionId) ? prev : [...prev, sessionId];
138
- }
139
- return prev;
140
- });
141
- setModels((prev) => {
142
- const model = update.log.model;
143
- if (model !== null && model !== undefined && model !== "") {
144
- return prev.includes(model) ? prev : [...prev, model];
145
- }
146
- return prev;
147
- });
181
+ scheduleUpdate(update.log);
148
182
  }
149
183
  } catch {
150
184
  setError("Failed to parse SSE data");
@@ -154,16 +188,12 @@ export function ProxyViewerContainer(): JSX.Element {
154
188
  es.onerror = () => {
155
189
  setError("SSE connection lost, reconnecting...");
156
190
  es.close();
157
- // Clear any existing reconnect timeout
158
191
  if (reconnectTimeoutRef.current !== null) {
159
192
  clearTimeout(reconnectTimeoutRef.current);
160
193
  }
161
- // Reconnect after 3 seconds
162
194
  reconnectTimeoutRef.current = setTimeout(connectSSE, 3000);
163
195
  };
164
-
165
- void fetchSessionsAndModels();
166
- }, [selectedSession, selectedModel, fetchSessionsAndModels]);
196
+ }, [scheduleUpdate]);
167
197
 
168
198
  useEffect(() => {
169
199
  connectSSE();
@@ -176,6 +206,10 @@ export function ProxyViewerContainer(): JSX.Element {
176
206
  clearTimeout(reconnectTimeoutRef.current);
177
207
  reconnectTimeoutRef.current = null;
178
208
  }
209
+ if (flushTimerRef.current !== null) {
210
+ clearTimeout(flushTimerRef.current);
211
+ flushTimerRef.current = null;
212
+ }
179
213
  };
180
214
  }, [connectSSE]);
181
215
 
@@ -187,7 +221,9 @@ export function ProxyViewerContainer(): JSX.Element {
187
221
  setError("Failed to clear logs");
188
222
  return;
189
223
  }
190
- setLogs([]);
224
+ logIndexRef.current.clear();
225
+ logsRef.current = [];
226
+ setAllLogs([]);
191
227
  setSessions([]);
192
228
  setModels([]);
193
229
  setError(null);
@@ -197,6 +233,41 @@ export function ProxyViewerContainer(): JSX.Element {
197
233
  })();
198
234
  }, []);
199
235
 
236
+ const handleClearGroup = useCallback((ids: number[]) => {
237
+ if (ids.length === 0) return;
238
+ void (async () => {
239
+ try {
240
+ const res = await fetch("/api/logs", {
241
+ method: "DELETE",
242
+ headers: { "Content-Type": "application/json" },
243
+ body: JSON.stringify({ ids }),
244
+ });
245
+ if (!res.ok) {
246
+ setError("Failed to clear group");
247
+ return;
248
+ }
249
+ const idSet = new Set(ids);
250
+ setAllLogs((prev) => {
251
+ const remaining = prev.filter((l) => !idSet.has(l.id));
252
+ // Rebuild index
253
+ const idx = new Map<number, number>();
254
+ for (let i = 0; i < remaining.length; i++) {
255
+ const log = remaining[i];
256
+ if (log !== undefined) idx.set(log.id, i);
257
+ }
258
+ logIndexRef.current = idx;
259
+ logsRef.current = remaining;
260
+ setSessions(extractSessions(remaining));
261
+ setModels(extractModels(remaining));
262
+ return remaining;
263
+ });
264
+ setError(null);
265
+ } catch (err) {
266
+ setError(err instanceof Error ? err.message : "Unknown error clearing group");
267
+ }
268
+ })();
269
+ }, []);
270
+
200
271
  // Read the strip config once at the container so the virtualized list does
201
272
  // not need N independent SWR subscriptions per row.
202
273
  const { strip } = useStripConfig();
@@ -217,6 +288,7 @@ export function ProxyViewerContainer(): JSX.Element {
217
288
  onSessionChange={setSelectedSession}
218
289
  onModelChange={setSelectedModel}
219
290
  onClearAll={handleClearAll}
291
+ onClearGroup={handleClearGroup}
220
292
  viewMode={viewMode}
221
293
  onViewModeChange={setViewMode}
222
294
  strip={strip}
@@ -38,6 +38,29 @@ function OpenCodeIcon(): JSX.Element {
38
38
  );
39
39
  }
40
40
 
41
+ function MiMoCodeIcon(): JSX.Element {
42
+ return (
43
+ <svg
44
+ fill="currentColor"
45
+ viewBox="0 0 24 24"
46
+ xmlns="http://www.w3.org/2000/svg"
47
+ className="size-6 shrink-0"
48
+ >
49
+ <title>MiMo Code</title>
50
+ <text
51
+ x="12"
52
+ y="18"
53
+ textAnchor="middle"
54
+ fontSize="16"
55
+ fontWeight="800"
56
+ fontFamily="system-ui, sans-serif"
57
+ >
58
+ M
59
+ </text>
60
+ </svg>
61
+ );
62
+ }
63
+
41
64
  const ExternalProviderSchema = z.object({
42
65
  name: z.string(),
43
66
  apiKey: z.string(),
@@ -45,7 +68,7 @@ const ExternalProviderSchema = z.object({
45
68
  anthropicBaseUrl: z.string(),
46
69
  openaiBaseUrl: z.string(),
47
70
  models: z.array(z.string()),
48
- sourceTool: z.enum(["claude-code", "opencode"]),
71
+ sourceTool: z.enum(["claude-code", "opencode", "mimo-code"]),
49
72
  alreadyExists: z.boolean(),
50
73
  });
51
74
 
@@ -64,6 +87,7 @@ const ImportResponseSchema = z.object({
64
87
  const sourceLogoMap: Record<string, () => JSX.Element> = {
65
88
  "claude-code": ClaudeCodeIcon,
66
89
  opencode: OpenCodeIcon,
90
+ "mimo-code": MiMoCodeIcon,
67
91
  };
68
92
 
69
93
  type ImportWizardDialogProps = {
@@ -213,7 +237,7 @@ export function ImportWizardDialog({
213
237
  <DialogHeader>
214
238
  <DialogTitle>Import from External Tools</DialogTitle>
215
239
  <p className="text-xs text-muted-foreground">
216
- Detect provider configurations from Claude Code and OpenCode.
240
+ Detect provider configurations from Claude Code, OpenCode, and MiMo Code.
217
241
  </p>
218
242
  </DialogHeader>
219
243
 
@@ -238,7 +262,7 @@ export function ImportWizardDialog({
238
262
  <br />
239
263
  <span className="text-xs">
240
264
  Supported tools: Claude Code (~/.claude/settings.json), OpenCode
241
- (~/.config/opencode/opencode.json)
265
+ (~/.config/opencode/opencode.json), MiMo Code (~/.config/mimocode/mimocode.jsonc)
242
266
  </span>
243
267
  </p>
244
268
  )}
@@ -14,7 +14,6 @@ import {
14
14
  } from "lucide-react";
15
15
  import { cn, formatTokens } from "../../lib/utils";
16
16
  import type { CapturedLog } from "../../proxy/schemas";
17
- import { parseRequest } from "../../proxy/schemas";
18
17
  import {
19
18
  type DiffOp,
20
19
  type JsonNode,
@@ -25,6 +24,7 @@ import {
25
24
  import { getConversationId } from "./ConversationHeader";
26
25
  import { JsonViewerFromString } from "../ui/json-viewer";
27
26
  import { Badge } from "../ui/badge";
27
+ import { getLogFormatAdapter, resolveLogFormat } from "./log-formats";
28
28
 
29
29
  export type CompareDrawerProps = {
30
30
  /** Log selected first (shown on the left). */
@@ -570,10 +570,23 @@ function SideSummary({ log, side }: { log: CapturedLog; side: "left" | "right" }
570
570
  export function CompareDrawer({ left, right, onClose }: CompareDrawerProps): JSX.Element {
571
571
  // Memoize the diff so re-renders (e.g. parent re-renders) don't recompute.
572
572
  const ops = useMemo<DiffOp[]>(() => {
573
- const l = normalizeRequest(parseRequest(left.rawRequestBody) ?? left.rawRequestBody);
574
- const r = normalizeRequest(parseRequest(right.rawRequestBody) ?? right.rawRequestBody);
573
+ const leftRequest = getLogFormatAdapter(resolveLogFormat(left)).analyzeRequest(
574
+ left.rawRequestBody,
575
+ );
576
+ const rightRequest = getLogFormatAdapter(resolveLogFormat(right)).analyzeRequest(
577
+ right.rawRequestBody,
578
+ );
579
+ const l = normalizeRequest(leftRequest.comparisonValue);
580
+ const r = normalizeRequest(rightRequest.comparisonValue);
575
581
  return diffTrees(l, r);
576
- }, [left.rawRequestBody, right.rawRequestBody]);
582
+ }, [
583
+ left.apiFormat,
584
+ left.path,
585
+ left.rawRequestBody,
586
+ right.apiFormat,
587
+ right.path,
588
+ right.rawRequestBody,
589
+ ]);
577
590
 
578
591
  const grouped = useMemo(() => groupContiguousEquals(ops), [ops]);
579
592