@tonyclaw/agent-inspector 2.0.17 → 2.0.19

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 (73) hide show
  1. package/.output/cli.js +149 -40
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-DDg7Cs4N.js → CompareDrawer-D-90PIPw.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-CxRDWacg.js +114 -0
  5. package/.output/public/assets/ReplayDialog-DzlLYoTO.js +1 -0
  6. package/.output/public/assets/{RequestAnatomy-DR8OICOJ.js → RequestAnatomy-0hbyf6oa.js} +1 -1
  7. package/.output/public/assets/ResponseView-CKe0tNqr.js +1 -0
  8. package/.output/public/assets/StreamingChunkSequence-enhfdeNh.js +1 -0
  9. package/.output/public/assets/_sessionId-CJ-SusES.js +1 -0
  10. package/.output/public/assets/index-BbFsmQRC.js +1 -0
  11. package/.output/public/assets/index-BfBgrkgU.css +1 -0
  12. package/.output/public/assets/{main-bwZlEXw2.js → main-o9-hg8g1.js} +2 -2
  13. package/.output/server/_libs/lucide-react.mjs +141 -118
  14. package/.output/server/_libs/tanstack__react-virtual.mjs +13 -2
  15. package/.output/server/_libs/tanstack__virtual-core.mjs +24 -1
  16. package/.output/server/{_sessionId-BEuJJhqT.mjs → _sessionId-F8lm6PIb.mjs} +3 -2
  17. package/.output/server/_ssr/{CompareDrawer-yTO93GMz.mjs → CompareDrawer-Cf1yfwfd.mjs} +4 -3
  18. package/.output/server/_ssr/{ProxyViewerContainer-C8qCkHGB.mjs → ProxyViewerContainer-Bmu9867L.mjs} +349 -74
  19. package/.output/server/_ssr/{ReplayDialog-DRF9PG7Z.mjs → ReplayDialog-DxZfLBOe.mjs} +199 -17
  20. package/.output/server/_ssr/{RequestAnatomy-DBcuV_jV.mjs → RequestAnatomy-CbdERGZd.mjs} +4 -3
  21. package/.output/server/_ssr/{ResponseView-JJm78HZT.mjs → ResponseView-CvLd6qxI.mjs} +4 -3
  22. package/.output/server/_ssr/{StreamingChunkSequence-DyGKjBYx.mjs → StreamingChunkSequence-B_lkRB6o.mjs} +4 -3
  23. package/.output/server/_ssr/{index-T7JG28t6.mjs → index-ejrSItfz.mjs} +3 -2
  24. package/.output/server/_ssr/index.mjs +2 -2
  25. package/.output/server/_ssr/{router-BJob8RN3.mjs → router-SbCnbDc2.mjs} +896 -240
  26. package/.output/server/{_tanstack-start-manifest_v-CuiFi3XC.mjs → _tanstack-start-manifest_v-yx8ftqWz.mjs} +1 -1
  27. package/.output/server/index.mjs +60 -60
  28. package/README.md +26 -109
  29. package/package.json +2 -1
  30. package/src/cli/doctor.ts +160 -8
  31. package/src/cli/templates/skill-onboard.ts +2 -20
  32. package/src/components/OnboardingBanner.tsx +13 -13
  33. package/src/components/ProxyViewer.tsx +55 -38
  34. package/src/components/ProxyViewerContainer.tsx +5 -0
  35. package/src/components/providers/SettingsDialog.tsx +113 -2
  36. package/src/components/proxy-viewer/ConversationGroup.tsx +20 -3
  37. package/src/components/proxy-viewer/ConversationGroupList.tsx +144 -0
  38. package/src/components/proxy-viewer/ConversationHeader.tsx +0 -2
  39. package/src/components/proxy-viewer/ReplayDialog.tsx +200 -13
  40. package/src/components/proxy-viewer/index.ts +1 -1
  41. package/src/knowledge/openclawClient.ts +0 -8
  42. package/src/knowledge/types.ts +0 -2
  43. package/src/lib/export-logs.ts +72 -5
  44. package/src/lib/providerTestContract.ts +0 -1
  45. package/src/lib/useProviders.ts +1 -1
  46. package/src/mcp/server.ts +14 -6
  47. package/src/mcp/toolHandlers.ts +21 -4
  48. package/src/proxy/chunkStorage.ts +2 -4
  49. package/src/proxy/dataDir.ts +37 -15
  50. package/src/proxy/formats/anthropic/index.ts +0 -1
  51. package/src/proxy/formats/anthropic/schemas.ts +0 -2
  52. package/src/proxy/handler.ts +41 -29
  53. package/src/proxy/logFinalizer.ts +93 -11
  54. package/src/proxy/logger.ts +35 -10
  55. package/src/proxy/providers.ts +12 -5
  56. package/src/proxy/rawStreamCapture.ts +85 -0
  57. package/src/proxy/schemas.ts +1 -51
  58. package/src/proxy/sessionRuntime.ts +0 -9
  59. package/src/proxy/socketTracker.ts +15 -8
  60. package/src/proxy/store.ts +414 -13
  61. package/src/routes/api/logs.$id.replay.ts +66 -1
  62. package/src/routes/api/logs.stream.ts +11 -3
  63. package/src/routes/api/logs.ts +51 -17
  64. package/src/routes/api/providers.$providerId.ts +10 -3
  65. package/src/routes/api/providers.ts +6 -3
  66. package/.output/public/assets/ProxyViewerContainer-DbbK1y7N.js +0 -114
  67. package/.output/public/assets/ReplayDialog-C1zWNkoz.js +0 -1
  68. package/.output/public/assets/ResponseView-Dh9iSj6h.js +0 -1
  69. package/.output/public/assets/StreamingChunkSequence-B_xElyH3.js +0 -1
  70. package/.output/public/assets/_sessionId-gv1a_NNu.js +0 -1
  71. package/.output/public/assets/index-D_nZj9Vt.css +0 -1
  72. package/.output/public/assets/index-u-LwDaeH.js +0 -1
  73. package/src/components/ui/json-expansion-button.tsx +0 -56
@@ -101,8 +101,6 @@ agent_inspector_data_dir() {
101
101
  case "$AGENT_INSPECTOR_DATA_DIR" in /*) printf '%s\n' "$AGENT_INSPECTOR_DATA_DIR" ;; *) printf '%s\n' "$base/$AGENT_INSPECTOR_DATA_DIR" ;; esac
102
102
  elif [ -n "\${AGENT_INSPECTOR_CONFIG_DIR:-}" ]; then
103
103
  case "$AGENT_INSPECTOR_CONFIG_DIR" in /*) printf '%s\n' "$AGENT_INSPECTOR_CONFIG_DIR" ;; *) printf '%s\n' "$base/$AGENT_INSPECTOR_CONFIG_DIR" ;; esac
104
- elif { [ ! -e "$base/.agent-inspector/providers.json" ] && [ ! -e "$base/.agent-inspector/config.json" ] && [ ! -e "$base/.agent-inspector/logs" ] && [ ! -e "$base/.agent-inspector/chunks" ]; } && { [ -e "$base/.llm-inspector/providers.json" ] || [ -e "$base/.llm-inspector/config.json" ] || [ -e "$base/.llm-inspector/logs" ] || [ -e "$base/.llm-inspector/chunks" ]; }; then
105
- printf '%s\n' "$base/.llm-inspector"
106
104
  else
107
105
  printf '%s\n' "$base/.agent-inspector"
108
106
  fi
@@ -149,14 +147,7 @@ function Resolve-AgentInspectorDataDir {
149
147
  if ([System.IO.Path]::IsPathRooted($env:AGENT_INSPECTOR_CONFIG_DIR)) { return $env:AGENT_INSPECTOR_CONFIG_DIR }
150
148
  return (Join-Path $base $env:AGENT_INSPECTOR_CONFIG_DIR)
151
149
  }
152
- $current = Join-Path $base '.agent-inspector'
153
- $legacy = Join-Path $base '.llm-inspector'
154
- $currentHasState = (Test-Path (Join-Path $current 'providers.json')) -or (Test-Path (Join-Path $current 'config.json')) -or (Test-Path (Join-Path $current 'logs')) -or (Test-Path (Join-Path $current 'chunks'))
155
- $legacyHasState = (Test-Path (Join-Path $legacy 'providers.json')) -or (Test-Path (Join-Path $legacy 'config.json')) -or (Test-Path (Join-Path $legacy 'logs')) -or (Test-Path (Join-Path $legacy 'chunks'))
156
- if (-not $currentHasState -and $legacyHasState) {
157
- return $legacy
158
- }
159
- return $current
150
+ return (Join-Path $base '.agent-inspector')
160
151
  }
161
152
  $dataDir = Resolve-AgentInspectorDataDir
162
153
  $providers = Join-Path $dataDir 'providers.json'
@@ -272,8 +263,6 @@ agent_inspector_data_dir() {
272
263
  case "$AGENT_INSPECTOR_DATA_DIR" in /*) printf '%s\n' "$AGENT_INSPECTOR_DATA_DIR" ;; *) printf '%s\n' "$base/$AGENT_INSPECTOR_DATA_DIR" ;; esac
273
264
  elif [ -n "\${AGENT_INSPECTOR_CONFIG_DIR:-}" ]; then
274
265
  case "$AGENT_INSPECTOR_CONFIG_DIR" in /*) printf '%s\n' "$AGENT_INSPECTOR_CONFIG_DIR" ;; *) printf '%s\n' "$base/$AGENT_INSPECTOR_CONFIG_DIR" ;; esac
275
- elif { [ ! -e "$base/.agent-inspector/providers.json" ] && [ ! -e "$base/.agent-inspector/config.json" ] && [ ! -e "$base/.agent-inspector/logs" ] && [ ! -e "$base/.agent-inspector/chunks" ]; } && { [ -e "$base/.llm-inspector/providers.json" ] || [ -e "$base/.llm-inspector/config.json" ] || [ -e "$base/.llm-inspector/logs" ] || [ -e "$base/.llm-inspector/chunks" ]; }; then
276
- printf '%s\n' "$base/.llm-inspector"
277
266
  else
278
267
  printf '%s\n' "$base/.agent-inspector"
279
268
  fi
@@ -317,14 +306,7 @@ function Resolve-AgentInspectorDataDir {
317
306
  if ([System.IO.Path]::IsPathRooted($env:AGENT_INSPECTOR_CONFIG_DIR)) { return $env:AGENT_INSPECTOR_CONFIG_DIR }
318
307
  return (Join-Path $base $env:AGENT_INSPECTOR_CONFIG_DIR)
319
308
  }
320
- $current = Join-Path $base '.agent-inspector'
321
- $legacy = Join-Path $base '.llm-inspector'
322
- $currentHasState = (Test-Path (Join-Path $current 'providers.json')) -or (Test-Path (Join-Path $current 'config.json')) -or (Test-Path (Join-Path $current 'logs')) -or (Test-Path (Join-Path $current 'chunks'))
323
- $legacyHasState = (Test-Path (Join-Path $legacy 'providers.json')) -or (Test-Path (Join-Path $legacy 'config.json')) -or (Test-Path (Join-Path $legacy 'logs')) -or (Test-Path (Join-Path $legacy 'chunks'))
324
- if (-not $currentHasState -and $legacyHasState) {
325
- return $legacy
326
- }
327
- return $current
309
+ return (Join-Path $base '.agent-inspector')
328
310
  }
329
311
  $dir = Resolve-AgentInspectorDataDir
330
312
  $file = Join-Path $dir 'providers.json'
@@ -25,41 +25,41 @@ export function OnboardingBanner(): JSX.Element | null {
25
25
  ? "Agent Inspector is ready in Full mode"
26
26
  : "Agent Inspector is ready in Simple mode"}
27
27
  </div>
28
- <ul className="space-y-0.5 text-xs leading-relaxed text-muted-foreground">
28
+ <ol className="space-y-0.5 text-xs leading-relaxed text-muted-foreground">
29
+ <li>
30
+ <strong>1. Start in the right mode</strong>: this process is currently running in{" "}
31
+ <code>{captureMode}</code>. Mode is fixed at startup.
32
+ </li>
29
33
  {isFullMode ? (
30
34
  <>
31
35
  <li>
32
- <strong>Full mode</strong>: raw headers, raw response, and SSE chunks are captured
33
- for diagnostics.
36
+ <strong>2. Full diagnostics</strong>: raw headers, raw response, and SSE chunks are
37
+ captured for deep investigations.
34
38
  </li>
35
39
  <li>
36
- <strong>Daily work</strong>: restart with <code>agent-inspector --mode simple</code>{" "}
37
- to reduce capture overhead.
40
+ <strong>3. Daily work</strong>: restart with{" "}
41
+ <code>agent-inspector --mode simple</code> to reduce capture overhead.
38
42
  </li>
39
43
  </>
40
44
  ) : (
41
45
  <>
42
46
  <li>
43
- <strong>Simple mode</strong>: lightweight capture for requests, responses, tools,
44
- timing, and token usage.
47
+ <strong>2. Simple capture</strong>: requests, responses, tools, timing, tokens,
48
+ replay, and memory candidates are available with lower overhead.
45
49
  </li>
46
50
  <li>
47
- <strong>Need diagnostics?</strong> Restart with{" "}
51
+ <strong>3. Need raw diagnostics?</strong> Restart with{" "}
48
52
  <code>agent-inspector --mode full</code> for raw headers, raw response, and SSE
49
53
  chunks.
50
54
  </li>
51
55
  </>
52
56
  )}
53
- <li>
54
- <strong>Replay and Memory</strong>: available in both modes for provider checks and
55
- reviewable knowledge candidates.
56
- </li>
57
57
  <li>
58
58
  <strong>Agent setup</strong>: run <code>agent-inspector onboard --status</code>, then
59
59
  use <code>/agent-inspector:onboard</code> in Claude Code or ask Codex to use{" "}
60
60
  <code>agent-inspector-onboard</code>.
61
61
  </li>
62
- </ul>
62
+ </ol>
63
63
  </div>
64
64
  <button
65
65
  type="button"
@@ -2,13 +2,13 @@ import { type JSX, useCallback, useEffect, useMemo, useRef, useState, Suspense }
2
2
  import { ArrowDownRight, ArrowLeft, ArrowUpRight, Check, Copy, Download, Plus } from "lucide-react";
3
3
 
4
4
  import type { CapturedLog } from "../proxy/schemas";
5
- import { exportLogsAsZip } from "../lib/export-logs";
5
+ import { exportLogsAsZip, type ExportMode } from "../lib/export-logs";
6
6
  import type { CaptureMode, TimeDisplayFormat } from "../lib/runtimeConfig";
7
7
  import { formatTimestampRange } from "../lib/timeDisplay";
8
8
  import { cn, formatContextWindowTokens, formatTokens } from "../lib/utils";
9
9
  import { useProviders } from "../lib/useProviders";
10
10
  import packageJson from "../../package.json";
11
- import { ConversationGroup, groupLogsByConversation } from "./proxy-viewer";
11
+ import { groupLogsByConversation } from "./proxy-viewer";
12
12
 
13
13
  import { CrabLogo } from "./ui/crab-logo";
14
14
  import { crabVariants } from "./ui/crab-variants";
@@ -19,6 +19,7 @@ import { SettingsDialog } from "./providers/SettingsDialog";
19
19
  import { computeCacheTrends } from "./proxy-viewer/cacheTrend";
20
20
  import { LazyCompareDrawer } from "./proxy-viewer/lazy";
21
21
  import { dispatchLogFocusRequest } from "./proxy-viewer/logFocus";
22
+ import { ConversationGroupList } from "./proxy-viewer/ConversationGroupList";
22
23
  import { buildValidPredecessors } from "./proxy-viewer/viewerState";
23
24
  import { useKeyboardNavigation } from "./proxy-viewer/useKeyboardNavigation";
24
25
  import { SegmentBar } from "./proxy-viewer/anatomy/SegmentBar";
@@ -34,7 +35,7 @@ import {
34
35
 
35
36
  function truncateSessionId(id: string): string {
36
37
  if (id.length <= 30) return id;
37
- return id.slice(0, 12) + "…" + id.slice(-12);
38
+ return `${id.slice(0, 12)}...${id.slice(-12)}`;
38
39
  }
39
40
 
40
41
  function computeTokenSummary(logs: CapturedLog[]): { totalIn: number; totalOut: number } {
@@ -635,7 +636,7 @@ export function ProxyViewer({
635
636
  }: ProxyViewerProps): JSX.Element {
636
637
  const { totalIn, totalOut } = useMemo(() => computeTokenSummary(logs), [logs]);
637
638
  const { providers } = useProviders();
638
- const [exporting, setExporting] = useState(false);
639
+ const [exporting, setExporting] = useState<ExportMode | null>(null);
639
640
  const [comparePair, setComparePair] = useState<[CapturedLog, CapturedLog] | null>(null);
640
641
  const [crabEntrancePhase, setCrabEntrancePhase] = useState<"hidden" | "playing" | "done">(
641
642
  "hidden",
@@ -665,14 +666,17 @@ export function ProxyViewer({
665
666
  document.title = `${truncateSessionId(pinnedSessionId)} - ${requestLabel} - Agent Inspector`;
666
667
  }, [logs.length, pinnedSessionId]);
667
668
 
668
- const handleExport = useCallback(async () => {
669
- setExporting(true);
670
- try {
671
- await exportLogsAsZip(logs);
672
- } finally {
673
- setExporting(false);
674
- }
675
- }, [logs]);
669
+ const handleExport = useCallback(
670
+ async (mode: ExportMode) => {
671
+ setExporting(mode);
672
+ try {
673
+ await exportLogsAsZip(logs, mode);
674
+ } finally {
675
+ setExporting(null);
676
+ }
677
+ },
678
+ [logs],
679
+ );
676
680
  // Close the compare drawer when the user changes the session or model
677
681
  // filter, since the predecessor relationship may no longer be meaningful.
678
682
  useEffect(() => {
@@ -710,11 +714,11 @@ export function ProxyViewer({
710
714
 
711
715
  return (
712
716
  <div className="max-w-[1400px] xl:max-w-[1600px] 2xl:max-w-[1800px] mx-auto px-6 pb-6">
713
- {/* Sticky chrome brand row, optional pinned-session context, and the
717
+ {/* Sticky chrome: brand row, optional pinned-session context, and the
714
718
  filter/control bar stay visible while the user scrolls through the
715
719
  log list. `z-30` sits above the per-conversation sticky header
716
720
  (`z-10`) but below modal-level overlays (dialogs, dropdowns,
717
- tooltips `z-50`). Background is fully opaque so logs don't bleed
721
+ tooltips: `z-50`). Background is fully opaque so logs don't bleed
718
722
  through. */}
719
723
  <div className="sticky top-0 z-30 bg-background pt-6">
720
724
  {/* Brand row */}
@@ -722,7 +726,7 @@ export function ProxyViewer({
722
726
  <div />
723
727
  <h1 className="flex min-w-0 flex-col items-center gap-2 text-center">
724
728
  <span className="flex max-w-[calc(100vw-7rem)] items-end gap-2 whitespace-nowrap">
725
- {/* Crab family hover to animate together */}
729
+ {/* Crab family: hover to animate together */}
726
730
  <span
727
731
  className="flex shrink-0 items-end gap-1 group cursor-default"
728
732
  aria-hidden="true"
@@ -858,7 +862,7 @@ export function ProxyViewer({
858
862
  <span className="text-muted-foreground text-xs font-mono">
859
863
  {logs.length} request{logs.length !== 1 ? "s" : ""}
860
864
  {totalIn > 0 || totalOut > 0
861
- ? ` · ${formatTokens(totalIn)} in / ${formatTokens(totalOut)} out`
865
+ ? ` - ${formatTokens(totalIn)} in / ${formatTokens(totalOut)} out`
862
866
  : ""}
863
867
  </span>
864
868
  )}
@@ -866,13 +870,13 @@ export function ProxyViewer({
866
870
  <button
867
871
  type="button"
868
872
  onClick={() => {
869
- void handleExport();
873
+ void handleExport("redacted");
870
874
  }}
871
- disabled={exporting}
875
+ disabled={exporting !== null}
872
876
  className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-1.5 rounded-md hover:bg-muted"
873
- title="Export all logs as JSON ZIP"
877
+ title="Export redacted logs as JSON ZIP"
874
878
  >
875
- {exporting ? (
879
+ {exporting === "redacted" ? (
876
880
  <span>Exporting...</span>
877
881
  ) : (
878
882
  <>
@@ -882,6 +886,19 @@ export function ProxyViewer({
882
886
  )}
883
887
  </button>
884
888
  )}
889
+ {logs.length > 0 && (
890
+ <button
891
+ type="button"
892
+ onClick={() => {
893
+ void handleExport("raw");
894
+ }}
895
+ disabled={exporting !== null}
896
+ className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed rounded-md hover:bg-muted"
897
+ title="Export raw logs without redaction"
898
+ >
899
+ {exporting === "raw" ? "Exporting..." : "Raw"}
900
+ </button>
901
+ )}
885
902
  <button
886
903
  type="button"
887
904
  onClick={onClearAll}
@@ -923,7 +940,10 @@ export function ProxyViewer({
923
940
  <div className="text-center text-muted-foreground py-16 space-y-4">
924
941
  <p className="text-sm">No requests captured yet.</p>
925
942
  <p className="text-xs">Route AI coding tools through the proxy:</p>
926
- <CopyableCommand command="LLM_BASE_URL=http://localhost:25947/proxy <your-tool>" />
943
+ <div className="flex flex-col items-center gap-2">
944
+ <CopyableCommand command="ANTHROPIC_BASE_URL=http://localhost:25947/proxy <your-tool>" />
945
+ <CopyableCommand command="OPENAI_BASE_URL=http://localhost:25947/proxy <your-tool>" />
946
+ </div>
927
947
  </div>
928
948
  )
929
949
  ) : (
@@ -933,28 +953,25 @@ export function ProxyViewer({
933
953
  className="flex flex-col gap-2 focus:outline-none"
934
954
  >
935
955
  <div ref={logListRef}>
936
- {groups.map((group) => (
937
- <ConversationGroup
938
- key={group.id}
939
- group={group}
940
- viewMode={viewMode}
941
- strip={strip}
942
- slowResponseThresholdSeconds={slowResponseThresholdSeconds}
943
- cacheTrends={cacheTrends}
944
- onCompareWithPrevious={handleCompareWithPrevious}
945
- comparisonPredecessors={comparisonPredecessors}
946
- onClearGroup={onClearGroup}
947
- standalone={groups.length === 1}
948
- hasPinnedSessionContext={hasSessionContext}
949
- timeDisplayFormat={timeDisplayFormat}
950
- />
951
- ))}
956
+ <ConversationGroupList
957
+ groups={groups}
958
+ viewMode={viewMode}
959
+ strip={strip}
960
+ slowResponseThresholdSeconds={slowResponseThresholdSeconds}
961
+ cacheTrends={cacheTrends}
962
+ onCompareWithPrevious={handleCompareWithPrevious}
963
+ comparisonPredecessors={comparisonPredecessors}
964
+ onClearGroup={onClearGroup}
965
+ standalone={groups.length === 1}
966
+ hasPinnedSessionContext={hasSessionContext}
967
+ timeDisplayFormat={timeDisplayFormat}
968
+ />
952
969
  </div>
953
970
  </div>
954
971
  )}
955
972
  </div>
956
973
 
957
- {/* Compare drawer sibling of the log list, not a route change. */}
974
+ {/* Compare drawer: sibling of the log list, not a route change. */}
958
975
  {comparePair !== null && (
959
976
  <Suspense fallback={null}>
960
977
  <LazyCompareDrawer left={comparePair[0]} right={comparePair[1]} onClose={closeCompare} />
@@ -4,6 +4,7 @@ import { CapturedLogSchema, type CapturedLog } from "../proxy/schemas";
4
4
  import { useStripConfig } from "../lib/useStripConfig";
5
5
  import { OnboardingBanner } from "./OnboardingBanner";
6
6
  import { ProxyViewer } from "./ProxyViewer";
7
+ import { dispatchLogFocusRequest } from "./proxy-viewer/logFocus";
7
8
 
8
9
  type SSEUpdate =
9
10
  | {
@@ -222,6 +223,10 @@ export function ProxyViewerContainer({
222
223
  if (!hash.startsWith("#log-")) return;
223
224
  if (handledHashRef.current === hash) return;
224
225
  const targetId = hash.slice(1);
226
+ const logId = Number(targetId.replace(/^log-/, ""));
227
+ if (Number.isInteger(logId) && logId > 0) {
228
+ dispatchLogFocusRequest({ logId, tab: "request" });
229
+ }
225
230
  let cancelled = false;
226
231
  let attempts = 0;
227
232
  let highlightedTarget: HTMLElement | null = null;
@@ -1,5 +1,6 @@
1
- import { type JSX, useState, useCallback, useMemo } from "react";
1
+ import { type JSX, useState, useCallback, useEffect, useMemo } from "react";
2
2
  import { Check, Copy, Settings, Terminal } from "lucide-react";
3
+ import { z } from "zod";
3
4
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
4
5
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
5
6
  import { Button } from "../ui/button";
@@ -72,6 +73,7 @@ export function SettingsDialog(): JSX.Element {
72
73
  <TabsList>
73
74
  <TabsTrigger value="providers">Providers</TabsTrigger>
74
75
  <TabsTrigger value="proxy">Proxy</TabsTrigger>
76
+ <TabsTrigger value="storage">Storage</TabsTrigger>
75
77
  <TabsTrigger value="onboarding">Onboarding</TabsTrigger>
76
78
  </TabsList>
77
79
 
@@ -94,6 +96,9 @@ export function SettingsDialog(): JSX.Element {
94
96
  <TabsContent value="proxy">
95
97
  <ProxySettingsTab />
96
98
  </TabsContent>
99
+ <TabsContent value="storage">
100
+ <StorageSettingsTab />
101
+ </TabsContent>
97
102
  <TabsContent value="onboarding">
98
103
  <OnboardingSettingsTab />
99
104
  </TabsContent>
@@ -104,6 +109,109 @@ export function SettingsDialog(): JSX.Element {
104
109
  );
105
110
  }
106
111
 
112
+ type LogStorageStats = {
113
+ memoryCount: number;
114
+ logDir: string;
115
+ logFileCount: number;
116
+ logBytes: number;
117
+ chunkDir: string;
118
+ chunkFileCount: number;
119
+ chunkBytes: number;
120
+ };
121
+
122
+ const LogStorageStatsSchema = z.object({
123
+ memoryCount: z.number(),
124
+ logDir: z.string(),
125
+ logFileCount: z.number(),
126
+ logBytes: z.number(),
127
+ chunkDir: z.string(),
128
+ chunkFileCount: z.number(),
129
+ chunkBytes: z.number(),
130
+ });
131
+
132
+ function formatBytes(bytes: number): string {
133
+ if (bytes < 1024) return `${bytes} B`;
134
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
135
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
136
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`;
137
+ }
138
+
139
+ function StorageSettingsTab(): JSX.Element {
140
+ const [stats, setStats] = useState<LogStorageStats | null>(null);
141
+ const [error, setError] = useState<string | null>(null);
142
+ const [loading, setLoading] = useState(false);
143
+
144
+ const refresh = useCallback(async () => {
145
+ setLoading(true);
146
+ setError(null);
147
+ try {
148
+ const response = await fetch("/api/logs?stats=1");
149
+ if (!response.ok) {
150
+ setError(`Failed to load storage stats: HTTP ${String(response.status)}`);
151
+ return;
152
+ }
153
+ const data: unknown = await response.json();
154
+ const parsed = LogStorageStatsSchema.safeParse(data);
155
+ if (!parsed.success) {
156
+ setError("Storage stats response was not valid.");
157
+ return;
158
+ }
159
+ setStats(parsed.data);
160
+ } catch (err) {
161
+ setError(err instanceof Error ? err.message : String(err));
162
+ } finally {
163
+ setLoading(false);
164
+ }
165
+ }, []);
166
+
167
+ useEffect(() => {
168
+ void refresh();
169
+ }, [refresh]);
170
+
171
+ return (
172
+ <div className="space-y-4">
173
+ <div className="flex items-center justify-between gap-3">
174
+ <div>
175
+ <h3 className="text-sm font-semibold">Log storage</h3>
176
+ <p className="text-xs text-muted-foreground">
177
+ Inspect local log and streaming chunk footprint before exporting or clearing sessions.
178
+ </p>
179
+ </div>
180
+ <Button type="button" variant="outline" size="sm" onClick={() => void refresh()}>
181
+ {loading ? "Refreshing..." : "Refresh"}
182
+ </Button>
183
+ </div>
184
+
185
+ {stats !== null && (
186
+ <div className="grid gap-2 text-xs">
187
+ <div className="rounded-md border border-border bg-muted/20 px-3 py-2">
188
+ <div className="font-medium">In-memory logs</div>
189
+ <div className="mt-1 font-mono text-muted-foreground">
190
+ {stats.memoryCount.toLocaleString()} loaded
191
+ </div>
192
+ </div>
193
+ <div className="rounded-md border border-border bg-muted/20 px-3 py-2">
194
+ <div className="font-medium">Log files</div>
195
+ <div className="mt-1 break-all font-mono text-muted-foreground">{stats.logDir}</div>
196
+ <div className="mt-1 font-mono text-muted-foreground">
197
+ {stats.logFileCount.toLocaleString()} files / {formatBytes(stats.logBytes)}
198
+ </div>
199
+ </div>
200
+ <div className="rounded-md border border-border bg-muted/20 px-3 py-2">
201
+ <div className="font-medium">Streaming chunks</div>
202
+ <div className="mt-1 break-all font-mono text-muted-foreground">{stats.chunkDir}</div>
203
+ <div className="mt-1 font-mono text-muted-foreground">
204
+ {stats.chunkFileCount.toLocaleString()} files / {formatBytes(stats.chunkBytes)}
205
+ </div>
206
+ </div>
207
+ </div>
208
+ )}
209
+
210
+ {error !== null && <p className="text-xs text-destructive">{error}</p>}
211
+ </div>
212
+ );
213
+ }
214
+
107
215
  function CopyableSetupValue({
108
216
  id,
109
217
  label,
@@ -278,6 +386,9 @@ function ProxySettingsTab(): JSX.Element {
278
386
  <p className="text-xs text-muted-foreground">
279
387
  This process is running in <code>{captureMode}</code> mode. Restart with{" "}
280
388
  <code>agent-inspector --mode {alternateCaptureMode}</code> to change it.
389
+ {captureMode === "simple"
390
+ ? " Simple mode keeps replay, parsed requests, responses, timing, tools, and token usage while skipping raw headers, raw response retention, and detailed SSE chunk retention."
391
+ : " Full mode retains raw headers, raw responses, and SSE chunks for diagnostics; use simple mode for everyday lower-overhead capture."}
281
392
  </p>
282
393
  </div>
283
394
 
@@ -305,7 +416,7 @@ function ProxySettingsTab(): JSX.Element {
305
416
  className="size-4 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
306
417
  />
307
418
  <span className="text-sm">
308
- {isLoading ? "Loading" : strip ? "Stripping enabled" : "Stripping disabled"}
419
+ {isLoading ? "Loading..." : strip ? "Stripping enabled" : "Stripping disabled"}
309
420
  </span>
310
421
  </label>
311
422
 
@@ -1,4 +1,4 @@
1
- import { useState, memo, useEffect, useMemo } from "react";
1
+ import { useState, memo, useCallback, useEffect, useMemo } from "react";
2
2
  import type { JSX } from "react";
3
3
  import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
4
4
  import type { CapturedLog } from "../../proxy/schemas";
@@ -37,6 +37,10 @@ export type ConversationGroupProps = {
37
37
  timeDisplayFormat: TimeDisplayFormat;
38
38
  /** Clear all logs that belong to this group. */
39
39
  onClearGroup: (ids: number[]) => void;
40
+ /** Optional controlled expansion state used by virtualized lists. */
41
+ expanded?: boolean;
42
+ /** Optional controlled expansion setter used by virtualized lists. */
43
+ onExpandedChange?: (expanded: boolean) => void;
40
44
  };
41
45
 
42
46
  function computeStats(logs: CapturedLog[]): {
@@ -64,8 +68,21 @@ export const ConversationGroup = memo(function ({
64
68
  standalone = false,
65
69
  hasPinnedSessionContext = false,
66
70
  timeDisplayFormat,
71
+ expanded: controlledExpanded,
72
+ onExpandedChange,
67
73
  }: ConversationGroupProps): JSX.Element {
68
- const [expanded, setExpanded] = useState(false);
74
+ const [internalExpanded, setInternalExpanded] = useState(false);
75
+ const expanded = controlledExpanded ?? internalExpanded;
76
+ const setExpanded = useCallback(
77
+ (nextExpanded: boolean) => {
78
+ if (onExpandedChange !== undefined) {
79
+ onExpandedChange(nextExpanded);
80
+ return;
81
+ }
82
+ setInternalExpanded(nextExpanded);
83
+ },
84
+ [onExpandedChange],
85
+ );
69
86
  const stats = useMemo(() => computeStats(group.logs), [group.logs]);
70
87
  const startTime = group.logs[0]?.timestamp ?? new Date().toISOString();
71
88
  const endTime = group.logs[group.logs.length - 1]?.timestamp ?? new Date().toISOString();
@@ -89,7 +106,7 @@ export const ConversationGroup = memo(function ({
89
106
  return () => {
90
107
  window.removeEventListener(LOG_FOCUS_REQUEST_EVENT, handleLogFocusRequest);
91
108
  };
92
- }, [group.logs, standalone]);
109
+ }, [group.logs, setExpanded, standalone]);
93
110
 
94
111
  return (
95
112
  <div className="mb-2">
@@ -0,0 +1,144 @@
1
+ import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useWindowVirtualizer } from "@tanstack/react-virtual";
3
+ import type { CapturedLog } from "../../proxy/schemas";
4
+ import { ConversationGroup, type ConversationGroupProps } from "./ConversationGroup";
5
+ import type { ConversationGroupData } from "./ConversationHeader";
6
+ import { LOG_FOCUS_REQUEST_EVENT, readLogFocusRequest } from "./logFocus";
7
+
8
+ const VIRTUALIZE_GROUP_THRESHOLD = 30;
9
+ const ESTIMATED_GROUP_HEIGHT = 112;
10
+ const VIRTUAL_OVERSCAN = 6;
11
+
12
+ type ConversationGroupListProps = Omit<
13
+ ConversationGroupProps,
14
+ "group" | "standalone" | "expanded" | "onExpandedChange"
15
+ > & {
16
+ groups: ConversationGroupData[];
17
+ standalone: boolean;
18
+ };
19
+
20
+ export function shouldVirtualizeConversationGroups(groupCount: number): boolean {
21
+ return groupCount > VIRTUALIZE_GROUP_THRESHOLD;
22
+ }
23
+
24
+ export function findConversationGroupIndexForLog(
25
+ groups: readonly ConversationGroupData[],
26
+ logId: number,
27
+ ): number | null {
28
+ for (let index = 0; index < groups.length; index++) {
29
+ const group = groups[index];
30
+ if (group !== undefined && group.logs.some((log: CapturedLog) => log.id === logId)) {
31
+ return index;
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export function ConversationGroupList({
38
+ groups,
39
+ standalone,
40
+ ...groupProps
41
+ }: ConversationGroupListProps): JSX.Element {
42
+ if (!shouldVirtualizeConversationGroups(groups.length)) {
43
+ return (
44
+ <>
45
+ {groups.map((group) => (
46
+ <ConversationGroup key={group.id} group={group} standalone={standalone} {...groupProps} />
47
+ ))}
48
+ </>
49
+ );
50
+ }
51
+
52
+ return (
53
+ <VirtualizedConversationGroupList groups={groups} standalone={standalone} {...groupProps} />
54
+ );
55
+ }
56
+
57
+ function VirtualizedConversationGroupList({
58
+ groups,
59
+ standalone,
60
+ ...groupProps
61
+ }: ConversationGroupListProps): JSX.Element {
62
+ const [expandedByGroupId, setExpandedByGroupId] = useState<ReadonlyMap<string, boolean>>(
63
+ () => new Map(),
64
+ );
65
+
66
+ const virtualizer = useWindowVirtualizer({
67
+ count: groups.length,
68
+ estimateSize: () => ESTIMATED_GROUP_HEIGHT,
69
+ overscan: VIRTUAL_OVERSCAN,
70
+ getItemKey: (index) => groups[index]?.id ?? index,
71
+ });
72
+
73
+ const setGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
74
+ setExpandedByGroupId((previous) => {
75
+ const next = new Map(previous);
76
+ if (expanded) {
77
+ next.set(groupId, true);
78
+ } else {
79
+ next.delete(groupId);
80
+ }
81
+ return next;
82
+ });
83
+ }, []);
84
+
85
+ const indexByLogId = useMemo(() => {
86
+ const result = new Map<number, number>();
87
+ for (let index = 0; index < groups.length; index++) {
88
+ const group = groups[index];
89
+ if (group === undefined) continue;
90
+ for (const log of group.logs) {
91
+ result.set(log.id, index);
92
+ }
93
+ }
94
+ return result;
95
+ }, [groups]);
96
+
97
+ useEffect(() => {
98
+ const handleLogFocusRequest = (event: Event): void => {
99
+ const request = readLogFocusRequest(event);
100
+ if (request === null) return;
101
+ const index = indexByLogId.get(request.logId);
102
+ if (index === undefined) return;
103
+ const group = groups[index];
104
+ if (group === undefined) return;
105
+ setGroupExpanded(group.id, true);
106
+ virtualizer.scrollToIndex(index, { align: "center" });
107
+ };
108
+
109
+ window.addEventListener(LOG_FOCUS_REQUEST_EVENT, handleLogFocusRequest);
110
+ return () => {
111
+ window.removeEventListener(LOG_FOCUS_REQUEST_EVENT, handleLogFocusRequest);
112
+ };
113
+ }, [groups, indexByLogId, setGroupExpanded, virtualizer]);
114
+
115
+ return (
116
+ <div
117
+ className="relative w-full"
118
+ style={{ height: `${virtualizer.getTotalSize()}px` }}
119
+ data-virtualized-conversation-list="true"
120
+ >
121
+ {virtualizer.getVirtualItems().map((virtualItem) => {
122
+ const group = groups[virtualItem.index];
123
+ if (group === undefined) return null;
124
+ return (
125
+ <div
126
+ key={virtualItem.key}
127
+ data-index={virtualItem.index}
128
+ ref={virtualizer.measureElement}
129
+ className="absolute left-0 top-0 w-full"
130
+ style={{ transform: `translateY(${virtualItem.start}px)` }}
131
+ >
132
+ <ConversationGroup
133
+ group={group}
134
+ standalone={standalone}
135
+ expanded={expandedByGroupId.get(group.id) ?? false}
136
+ onExpandedChange={(expanded) => setGroupExpanded(group.id, expanded)}
137
+ {...groupProps}
138
+ />
139
+ </div>
140
+ );
141
+ })}
142
+ </div>
143
+ );
144
+ }