autotel-terminal 17.0.1 → 17.0.3

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 (42) hide show
  1. package/dist/cli.cjs +1314 -262
  2. package/dist/cli.cjs.map +1 -1
  3. package/dist/cli.js +1314 -262
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.cjs +1264 -241
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +12 -0
  8. package/dist/index.d.ts +12 -0
  9. package/dist/index.js +1264 -241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +14 -7
  12. package/src/ai/provider.test.ts +47 -0
  13. package/src/ai/provider.ts +124 -0
  14. package/src/ai/stream.ts +44 -0
  15. package/src/ai/system-prompt.ts +24 -0
  16. package/src/ai/tools.ts +233 -0
  17. package/src/ai/types.ts +19 -0
  18. package/src/cli-stream.ts +0 -1
  19. package/src/cli.integration.test.ts +11 -16
  20. package/src/cli.ts +51 -19
  21. package/src/index.tsx +1446 -466
  22. package/src/lib/dashboard-keymap.test.ts +33 -17
  23. package/src/lib/dashboard-keymap.ts +1 -7
  24. package/src/lib/error-model.test.ts +0 -1
  25. package/src/lib/error-model.ts +3 -2
  26. package/src/lib/export-model.test.ts +0 -1
  27. package/src/lib/export-model.ts +0 -1
  28. package/src/lib/filters.test.ts +28 -2
  29. package/src/lib/filters.ts +5 -2
  30. package/src/lib/format.test.ts +33 -1
  31. package/src/lib/format.ts +28 -0
  32. package/src/lib/log-model.ts +3 -5
  33. package/src/lib/recording-regression.test.ts +6 -2
  34. package/src/lib/stats-model.test.ts +13 -4
  35. package/src/lib/stats-model.ts +3 -5
  36. package/src/lib/topology-model.test.ts +6 -5
  37. package/src/lib/topology-model.ts +1 -5
  38. package/src/lib/trace-model.test.ts +84 -13
  39. package/src/lib/trace-model.ts +18 -11
  40. package/src/log-stream.ts +0 -1
  41. package/src/otlp-http-json.test.ts +11 -5
  42. package/src/otlp-http-json.ts +39 -12
package/dist/cli.cjs CHANGED
@@ -8,6 +8,8 @@ var react = require('react');
8
8
  var ink = require('ink');
9
9
  var autotel = require('autotel');
10
10
  var tracerProvider = require('autotel/tracer-provider');
11
+ var ai = require('ai');
12
+ var zod = require('zod');
11
13
  require('autotel/exporters');
12
14
  var jsxRuntime = require('react/jsx-runtime');
13
15
 
@@ -200,7 +202,8 @@ function sortSpansForWaterfall(spans) {
200
202
  }
201
203
  const withDepth = spans.map((s) => ({ span: s, depth: depth(s) }));
202
204
  withDepth.sort((a, b) => {
203
- if (a.span.startTime !== b.span.startTime) return a.span.startTime - b.span.startTime;
205
+ if (a.span.startTime !== b.span.startTime)
206
+ return a.span.startTime - b.span.startTime;
204
207
  return a.depth - b.depth;
205
208
  });
206
209
  return withDepth;
@@ -215,7 +218,9 @@ function filterTraceSummaries(summaries, searchQuery, errorsOnly) {
215
218
  let list = summaries;
216
219
  if (searchQuery.trim() === "") return list;
217
220
  const q = searchQuery.toLowerCase();
218
- return list.filter((s) => s.spans.some((sp) => sp.name.toLowerCase().includes(q)));
221
+ return list.filter(
222
+ (s) => s.spans.some((sp) => sp.name.toLowerCase().includes(q))
223
+ );
219
224
  }
220
225
  function computeStats(spans) {
221
226
  const total = spans.length;
@@ -260,6 +265,17 @@ function truncate(s, width) {
260
265
  if (s.length <= width) return s;
261
266
  return s.slice(0, Math.max(0, width - 1)) + "\u2026";
262
267
  }
268
+ function buildWaterfallBar(spanStart, spanDuration, traceStart, traceDuration, width) {
269
+ if (traceDuration <= 0) return " ".repeat(width);
270
+ const offsetRatio = (spanStart - traceStart) / traceDuration;
271
+ const widthRatio = spanDuration / traceDuration;
272
+ const barStart = Math.max(0, Math.floor(offsetRatio * width));
273
+ const barLen = Math.max(1, Math.round(widthRatio * width));
274
+ const clampedStart = Math.min(barStart, width - 1);
275
+ const clampedLen = Math.min(barLen, width - clampedStart);
276
+ const trailing = Math.max(0, width - clampedStart - clampedLen);
277
+ return " ".repeat(clampedStart) + "\u2588".repeat(clampedLen) + " ".repeat(trailing);
278
+ }
263
279
 
264
280
  // src/lib/log-model.ts
265
281
  function filterLogsBySearch(logs, searchQuery, minLevel) {
@@ -423,10 +439,11 @@ function getStatusCode(span) {
423
439
  return void 0;
424
440
  }
425
441
  function applySpanFilters(spans, state) {
426
- const { serviceName, route, statusGroup, errorsOnly, searchQuery } = state;
442
+ const { serviceName, route, statusGroup, errorsOnly, searchQuery, traceId } = state;
427
443
  const q = searchQuery?.trim().toLowerCase() ?? "";
428
444
  return spans.filter((span) => {
429
445
  const attrs = span.attributes ?? {};
446
+ if (traceId && span.traceId !== traceId) return false;
430
447
  if (errorsOnly && span.status !== "ERROR") return false;
431
448
  if (serviceName) {
432
449
  const svc = getServiceName(span);
@@ -482,17 +499,17 @@ function getStatusCodeFromSpans(spans) {
482
499
  }
483
500
  function buildErrorSummaries(traceSummaries) {
484
501
  const out = [];
485
- for (const t of traceSummaries) {
486
- const errorSpans = t.spans.filter((s) => s.status === "ERROR");
502
+ for (const t2 of traceSummaries) {
503
+ const errorSpans = t2.spans.filter((s) => s.status === "ERROR");
487
504
  if (errorSpans.length === 0) continue;
488
505
  out.push({
489
- traceId: t.traceId,
490
- rootName: t.rootName,
491
- serviceName: getServiceNameFromSpans(t.spans),
492
- route: getRouteFromSpans(t.spans),
493
- statusCode: getStatusCodeFromSpans(t.spans),
506
+ traceId: t2.traceId,
507
+ rootName: t2.rootName,
508
+ serviceName: getServiceNameFromSpans(t2.spans),
509
+ route: getRouteFromSpans(t2.spans),
510
+ statusCode: getStatusCodeFromSpans(t2.spans),
494
511
  errorCount: errorSpans.length,
495
- lastEndTime: t.lastEndTime
512
+ lastEndTime: t2.lastEndTime
496
513
  });
497
514
  }
498
515
  out.sort((a, b) => b.lastEndTime - a.lastEndTime);
@@ -528,6 +545,302 @@ function exportTraceToJson(trace, logs) {
528
545
  };
529
546
  return JSON.stringify(exported, null, 2);
530
547
  }
548
+
549
+ // src/ai/provider.ts
550
+ async function detectOllama(baseUrl = "http://127.0.0.1:11434") {
551
+ try {
552
+ const res = await fetch(`${baseUrl}/api/tags`, {
553
+ signal: AbortSignal.timeout(1e3)
554
+ });
555
+ return res.ok;
556
+ } catch {
557
+ return false;
558
+ }
559
+ }
560
+ function resolveConfig(options = {}) {
561
+ const provider = options.provider ?? process.env.AI_PROVIDER ?? void 0;
562
+ const model = options.model ?? process.env.AI_MODEL;
563
+ const apiKey = options.apiKey ?? process.env.AI_API_KEY ?? (provider === "openai" ? process.env.OPENAI_API_KEY : void 0);
564
+ const baseUrl = options.baseUrl ?? process.env.AI_BASE_URL;
565
+ if (provider) {
566
+ return {
567
+ provider,
568
+ model: model ?? (provider === "ollama" ? "granite4" : "gpt-4o"),
569
+ apiKey,
570
+ baseUrl
571
+ };
572
+ }
573
+ return null;
574
+ }
575
+ var defaultAutoDetectDeps = { detectOllama };
576
+ async function resolveConfigWithAutoDetect(options = {}, deps = defaultAutoDetectDeps) {
577
+ const config = resolveConfig(options);
578
+ if (config) return config;
579
+ const ollamaUrl = options.baseUrl ?? process.env.AI_BASE_URL ?? "http://127.0.0.1:11434";
580
+ if (await deps.detectOllama(ollamaUrl)) {
581
+ return {
582
+ provider: "ollama",
583
+ model: options.model ?? process.env.AI_MODEL ?? "granite4",
584
+ baseUrl: ollamaUrl
585
+ };
586
+ }
587
+ const openaiKey = options.apiKey ?? process.env.AI_API_KEY ?? process.env.OPENAI_API_KEY;
588
+ if (openaiKey) {
589
+ return {
590
+ provider: "openai",
591
+ model: options.model ?? process.env.AI_MODEL ?? "gpt-4o",
592
+ apiKey: openaiKey
593
+ };
594
+ }
595
+ return null;
596
+ }
597
+ async function createAIModel(config) {
598
+ switch (config.provider) {
599
+ case "ollama": {
600
+ const { createOllama } = await import('ai-sdk-ollama');
601
+ const ollama = createOllama({
602
+ baseURL: config.baseUrl ?? "http://127.0.0.1:11434"
603
+ });
604
+ return { model: ollama(config.model), providerType: "ollama", config };
605
+ }
606
+ case "openai": {
607
+ const { createOpenAI } = await import('@ai-sdk/openai');
608
+ const openai = createOpenAI({
609
+ apiKey: config.apiKey,
610
+ ...config.baseUrl ? { baseURL: config.baseUrl } : {}
611
+ });
612
+ return { model: openai(config.model), providerType: "openai", config };
613
+ }
614
+ case "openai-compatible": {
615
+ const { createOpenAICompatible } = await import('@ai-sdk/openai-compatible');
616
+ const provider = createOpenAICompatible({
617
+ baseURL: config.baseUrl ?? "http://127.0.0.1:11434/v1",
618
+ name: "custom",
619
+ ...config.apiKey ? { headers: { Authorization: `Bearer ${config.apiKey}` } } : {}
620
+ });
621
+ return {
622
+ model: provider(config.model),
623
+ providerType: "openai-compatible",
624
+ config
625
+ };
626
+ }
627
+ default: {
628
+ throw new Error(
629
+ `Unsupported provider: "${config.provider}". Expected "ollama", "openai", or "openai-compatible".`
630
+ );
631
+ }
632
+ }
633
+ }
634
+
635
+ // src/ai/system-prompt.ts
636
+ function buildSystemPrompt(viewMode, contextJson) {
637
+ return `You are an OpenTelemetry expert assistant analyzing live telemetry data from a running application.
638
+ The user is viewing their ${viewMode} dashboard in a terminal TUI.
639
+
640
+ You have tools to query the telemetry data precisely. Use them to answer questions:
641
+ - getOverviewStats: high-level stats (spans, errors, latency)
642
+ - listServices: all services with error rates and p95
643
+ - findSlowestSpans: find slow spans, optionally by service
644
+ - findErrorTraces: find traces with errors
645
+ - getTraceDetail: deep dive into a specific trace
646
+ - searchSpans: search spans by name
647
+ - searchLogs: search logs by message content
648
+
649
+ Use tools first to gather data, then synthesize a concise answer.
650
+ Keep responses under 300 words.
651
+ Use specific span names, durations, and attribute values from the data.
652
+ Format for a narrow terminal column \u2014 use short paragraphs, not wide tables.
653
+
654
+ Current dashboard summary:
655
+ ${contextJson}`;
656
+ }
657
+ var t = ai.tool;
658
+ function createTelemetryTools(ctx) {
659
+ return {
660
+ getOverviewStats: t({
661
+ description: "Get high-level stats: total spans, error count, average duration, p95 duration, and service count.",
662
+ parameters: zod.z.object({}),
663
+ execute: async () => ({
664
+ totalSpans: ctx.stats.total,
665
+ errors: ctx.stats.errors,
666
+ avgMs: Math.round(ctx.stats.avg),
667
+ p95Ms: Math.round(ctx.stats.p95),
668
+ serviceCount: ctx.serviceStats.length,
669
+ traceCount: ctx.traces.length
670
+ })
671
+ }),
672
+ listServices: t({
673
+ description: "List all services with their span counts, error counts, and p95 latencies.",
674
+ parameters: zod.z.object({}),
675
+ execute: async () => ctx.serviceStats.map((s) => ({
676
+ serviceName: s.serviceName,
677
+ total: s.total,
678
+ errors: s.errors,
679
+ avgMs: Math.round(s.avgMs),
680
+ p95Ms: Math.round(s.p95Ms)
681
+ }))
682
+ }),
683
+ findSlowestSpans: t({
684
+ description: "Find the slowest spans, optionally filtered by service name. Returns span name, duration, status, and key attributes.",
685
+ parameters: zod.z.object({
686
+ service: zod.z.string().optional().describe("Filter by service name"),
687
+ limit: zod.z.number().optional().describe("Max results (default 10)")
688
+ }),
689
+ execute: async ({
690
+ service,
691
+ limit
692
+ }) => {
693
+ const max = limit ?? 10;
694
+ let filtered = ctx.spans;
695
+ if (service) {
696
+ filtered = filtered.filter(
697
+ (s) => s.attributes?.["service.name"] === service
698
+ );
699
+ }
700
+ return filtered.toSorted((a, b) => b.durationMs - a.durationMs).slice(0, max).map((s) => ({
701
+ name: s.name,
702
+ durationMs: Math.round(s.durationMs),
703
+ status: s.status,
704
+ service: s.attributes?.["service.name"] ?? "unknown",
705
+ route: s.attributes?.["http.route"],
706
+ traceId: s.traceId.slice(0, 8)
707
+ }));
708
+ }
709
+ }),
710
+ findErrorTraces: t({
711
+ description: "Find traces that contain errors, with root span name, service, route, and error count.",
712
+ parameters: zod.z.object({
713
+ service: zod.z.string().optional().describe("Filter by service name"),
714
+ limit: zod.z.number().optional().describe("Max results (default 10)")
715
+ }),
716
+ execute: async ({
717
+ service,
718
+ limit
719
+ }) => {
720
+ const max = limit ?? 10;
721
+ let errors = ctx.errorSummaries;
722
+ if (service) {
723
+ errors = errors.filter((e) => e.serviceName === service);
724
+ }
725
+ return errors.slice(0, max).map((e) => ({
726
+ traceId: e.traceId.slice(0, 8),
727
+ rootName: e.rootName,
728
+ serviceName: e.serviceName,
729
+ route: e.route,
730
+ errorCount: e.errorCount
731
+ }));
732
+ }
733
+ }),
734
+ getTraceDetail: t({
735
+ description: "Get full detail of a specific trace by trace ID prefix. Returns all spans with their parent relationships, durations, and attributes.",
736
+ parameters: zod.z.object({
737
+ traceIdPrefix: zod.z.string().describe("First 8+ characters of the trace ID")
738
+ }),
739
+ execute: async ({ traceIdPrefix }) => {
740
+ const trace = ctx.traces.find(
741
+ (t2) => t2.traceId.startsWith(traceIdPrefix)
742
+ );
743
+ if (!trace) {
744
+ return { error: `No trace found matching ${traceIdPrefix}` };
745
+ }
746
+ const traceLogs = ctx.logs.filter((l) => l.traceId === trace.traceId);
747
+ return {
748
+ traceId: trace.traceId.slice(0, 16),
749
+ rootName: trace.rootName,
750
+ durationMs: Math.round(trace.durationMs),
751
+ hasError: trace.hasError,
752
+ spanCount: trace.spanCount,
753
+ spans: trace.spans.map((s) => ({
754
+ name: s.name,
755
+ durationMs: Math.round(s.durationMs),
756
+ status: s.status,
757
+ kind: s.kind,
758
+ parentSpanId: s.parentSpanId?.slice(0, 8),
759
+ attrs: Object.fromEntries(
760
+ Object.entries(s.attributes ?? {}).filter(
761
+ ([k]) => [
762
+ "http.method",
763
+ "http.route",
764
+ "http.status_code",
765
+ "db.operation",
766
+ "db.system",
767
+ "service.name",
768
+ "error.message",
769
+ "error.type"
770
+ ].includes(k)
771
+ )
772
+ )
773
+ })),
774
+ logs: traceLogs.slice(0, 10).map((l) => ({
775
+ level: l.level,
776
+ message: l.message.slice(0, 100)
777
+ }))
778
+ };
779
+ }
780
+ }),
781
+ searchSpans: t({
782
+ description: "Search spans by name pattern (case-insensitive substring match). Returns matching spans with details.",
783
+ parameters: zod.z.object({
784
+ query: zod.z.string().describe("Search string to match against span names"),
785
+ limit: zod.z.number().optional().describe("Max results (default 20)")
786
+ }),
787
+ execute: async ({ query, limit }) => {
788
+ const max = limit ?? 20;
789
+ const q = query.toLowerCase();
790
+ return ctx.spans.filter((s) => s.name.toLowerCase().includes(q)).slice(0, max).map((s) => ({
791
+ name: s.name,
792
+ durationMs: Math.round(s.durationMs),
793
+ status: s.status,
794
+ traceId: s.traceId.slice(0, 8),
795
+ service: s.attributes?.["service.name"] ?? "unknown"
796
+ }));
797
+ }
798
+ }),
799
+ searchLogs: t({
800
+ description: "Search logs by message content (case-insensitive). Returns matching log entries.",
801
+ parameters: zod.z.object({
802
+ query: zod.z.string().describe("Search string to match against log messages"),
803
+ level: zod.z.enum(["debug", "info", "warn", "error"]).optional().describe("Filter by log level"),
804
+ limit: zod.z.number().optional().describe("Max results (default 20)")
805
+ }),
806
+ execute: async ({
807
+ query,
808
+ level,
809
+ limit
810
+ }) => {
811
+ const max = limit ?? 20;
812
+ const q = query.toLowerCase();
813
+ let filtered = ctx.logs.filter(
814
+ (l) => l.message.toLowerCase().includes(q)
815
+ );
816
+ if (level) {
817
+ filtered = filtered.filter((l) => l.level === level);
818
+ }
819
+ return filtered.slice(0, max).map((l) => ({
820
+ level: l.level,
821
+ message: l.message.slice(0, 200),
822
+ traceId: l.traceId?.slice(0, 8),
823
+ attrs: l.attributes
824
+ }));
825
+ }
826
+ })
827
+ };
828
+ }
829
+
830
+ // src/ai/stream.ts
831
+ async function providerStreamText(providerType, params) {
832
+ if (providerType === "ollama") {
833
+ const mod2 = await import('ai-sdk-ollama');
834
+ const result = await mod2.streamText(
835
+ params
836
+ );
837
+ return result;
838
+ }
839
+ const mod = await import('ai');
840
+ return mod.streamText(
841
+ params
842
+ );
843
+ }
531
844
  var KEY_ATTR_KEYS = /* @__PURE__ */ new Set([
532
845
  "http.route",
533
846
  "http.method",
@@ -544,13 +857,15 @@ var THROTTLE_MS = 50;
544
857
  var MAX_TRACES = 50;
545
858
  var NEW_ERROR_DISPLAY_MS = 2e3;
546
859
  var RECORD_LIMIT_DEFAULT = 200;
860
+ var LIST_HEIGHT = 20;
547
861
  function Dashboard({
548
862
  title,
549
863
  showStats,
550
864
  maxSpans,
551
865
  colors,
552
866
  stream,
553
- logStream
867
+ logStream,
868
+ aiConfig
554
869
  }) {
555
870
  const [paused, setPaused] = react.useState(false);
556
871
  const [recording, setRecording] = react.useState(false);
@@ -563,13 +878,43 @@ function Dashboard({
563
878
  const [spanFilters, setSpanFilters] = react.useState({
564
879
  statusGroup: "all"
565
880
  });
566
- const [selectedTraceId, setSelectedTraceId] = react.useState(null);
567
- const [selectedSpanIndex, setSelectedSpanIndex] = react.useState(0);
881
+ const [drilldownTraceId, setDrilldownTraceId] = react.useState(null);
882
+ const [drilldownSelectedIndex, setDrilldownSelectedIndex] = react.useState(0);
883
+ const [drilldownScrollOffset, setDrilldownScrollOffset] = react.useState(0);
884
+ const [drilldownTab, setDrilldownTab] = react.useState("timeline");
568
885
  const [newErrorCount, setNewErrorCount] = react.useState(0);
569
886
  const [searchMode, setSearchMode] = react.useState(false);
887
+ const [traceIdMode, setTraceIdMode] = react.useState(false);
888
+ const [traceIdInput, setTraceIdInput] = react.useState("");
570
889
  const throttleRef = react.useRef(null);
571
890
  const pendingSpansRef = react.useRef([]);
572
891
  const [logs, setLogs] = react.useState([]);
892
+ const [aiActive, setAiActive] = react.useState(false);
893
+ const [aiMessages, setAiMessages] = react.useState([]);
894
+ const [aiInput, setAiInput] = react.useState("");
895
+ const [aiState, setAiState] = react.useState({ status: "unconfigured" });
896
+ const [aiInputMode, setAiInputMode] = react.useState(false);
897
+ const aiModelRef = react.useRef(null);
898
+ const aiAbortRef = react.useRef(null);
899
+ react.useEffect(() => {
900
+ let cancelled = false;
901
+ resolveConfigWithAutoDetect(aiConfig).then(async (config) => {
902
+ if (cancelled || !config) return;
903
+ try {
904
+ const result = await createAIModel(config);
905
+ aiModelRef.current = result;
906
+ setAiState({ status: "idle" });
907
+ } catch {
908
+ setAiState({
909
+ status: "error",
910
+ message: "Failed to initialize AI model"
911
+ });
912
+ }
913
+ });
914
+ return () => {
915
+ cancelled = true;
916
+ };
917
+ }, [aiConfig]);
573
918
  react.useEffect(() => {
574
919
  if (!logStream) return;
575
920
  const unsubscribe = logStream.onLog((event) => {
@@ -601,13 +946,16 @@ function Dashboard({
601
946
  return next.slice(0, maxSpans);
602
947
  });
603
948
  setSelected(0);
604
- setSelectedTraceId(null);
949
+ setDrilldownTraceId(null);
605
950
  };
606
951
  const unsubscribe = stream.onSpanEnd((span) => {
607
952
  if (paused) return;
608
953
  if (span.status === "ERROR") {
609
954
  setNewErrorCount((n) => n + 1);
610
- setTimeout(() => setNewErrorCount((n) => Math.max(0, n - 1)), NEW_ERROR_DISPLAY_MS);
955
+ setTimeout(
956
+ () => setNewErrorCount((n) => Math.max(0, n - 1)),
957
+ NEW_ERROR_DISPLAY_MS
958
+ );
611
959
  }
612
960
  if (recording) {
613
961
  setSpans((prev) => {
@@ -619,7 +967,7 @@ function Dashboard({
619
967
  return next.slice(0, RECORD_LIMIT_DEFAULT);
620
968
  });
621
969
  setSelected(0);
622
- setSelectedTraceId(null);
970
+ setDrilldownTraceId(null);
623
971
  return;
624
972
  }
625
973
  pendingSpansRef.current = [span, ...pendingSpansRef.current];
@@ -650,21 +998,24 @@ function Dashboard({
650
998
  () => buildTraceSummaries(traceMap),
651
999
  [traceMap]
652
1000
  );
653
- const filteredSummaries = react.useMemo(
654
- () => filterTraceSummaries(traceSummaries, ""),
655
- [traceSummaries]
656
- );
1001
+ const filteredSummaries = react.useMemo(() => {
1002
+ const base = filterTraceSummaries(traceSummaries, "");
1003
+ return spanFilters.traceId ? base.filter((t2) => t2.traceId === spanFilters.traceId) : base;
1004
+ }, [traceSummaries, spanFilters.traceId]);
657
1005
  const filteredSpans = react.useMemo(
658
1006
  () => filterBySearch(filteredSpanBuffer, ""),
659
1007
  [filteredSpanBuffer]
660
1008
  );
661
1009
  const stats = react.useMemo(() => computeStats(spans), [spans]);
662
- const perSpanNameStats = react.useMemo(() => computePerSpanNameStats(spans), [spans]);
663
- const logStats = react.useMemo(() => computeLogStats(logs), [logs]);
664
- const filteredLogs = react.useMemo(
665
- () => filterLogsBySearch(logs, searchQuery),
666
- [logs, searchQuery]
1010
+ const perSpanNameStats = react.useMemo(
1011
+ () => computePerSpanNameStats(spans),
1012
+ [spans]
667
1013
  );
1014
+ const logStats = react.useMemo(() => computeLogStats(logs), [logs]);
1015
+ const filteredLogs = react.useMemo(() => {
1016
+ const traceFilteredLogs = spanFilters.traceId ? logs.filter((l) => l.traceId === spanFilters.traceId) : logs;
1017
+ return filterLogsBySearch(traceFilteredLogs, searchQuery);
1018
+ }, [logs, searchQuery, spanFilters.traceId]);
668
1019
  const serviceStats = react.useMemo(() => computeServiceStats(spans), [spans]);
669
1020
  const selectedServiceName = serviceStats[selected]?.serviceName ?? null;
670
1021
  const spansForSelectedService = react.useMemo(() => {
@@ -681,24 +1032,147 @@ function Dashboard({
681
1032
  () => findHotSpanNames(spansForSelectedService, 8),
682
1033
  [spansForSelectedService]
683
1034
  );
684
- const selectedTraceSummary = selectedTraceId == null ? filteredSummaries[selected] ?? null : filteredSummaries.find((t) => t.traceId === selectedTraceId) ?? null;
1035
+ const selectedTraceSummary = drilldownTraceId == null ? filteredSummaries[selected] ?? null : filteredSummaries.find((t2) => t2.traceId === drilldownTraceId) ?? null;
685
1036
  const errorSummaries = react.useMemo(
686
1037
  () => buildErrorSummaries(traceSummaries),
687
1038
  [traceSummaries]
688
1039
  );
1040
+ const filteredErrorSummaries = spanFilters.traceId ? errorSummaries.filter((e) => e.traceId === spanFilters.traceId) : errorSummaries;
689
1041
  const traceTree = selectedTraceSummary == null ? [] : flattenTraceTree(buildTraceTree(selectedTraceSummary.spans));
690
1042
  const waterfallSpans = selectedTraceSummary == null ? [] : sortSpansForWaterfall(selectedTraceSummary.spans);
691
- const currentSpanInTrace = traceTree[selectedSpanIndex] ?? null;
1043
+ const currentSpanInTrace = traceTree[drilldownSelectedIndex] ?? null;
692
1044
  const currentSpanInFlat = filteredSpans[selected] ?? null;
693
- const selectedTraceSummaryForDetails = viewMode === "trace" && selectedTraceId == null && filteredSummaries[selected] ? filteredSummaries[selected] : null;
1045
+ const selectedTraceSummaryForDetails = viewMode === "trace" && drilldownTraceId == null && filteredSummaries[selected] ? filteredSummaries[selected] : null;
694
1046
  const rootSpanOfSelectedTrace = selectedTraceSummaryForDetails != null && selectedTraceSummaryForDetails.spans.length > 0 ? selectedTraceSummaryForDetails.spans.find(
695
1047
  (s) => !selectedTraceSummaryForDetails.spans.some(
696
1048
  (p) => p.spanId === s.parentSpanId
697
1049
  )
698
1050
  ) ?? selectedTraceSummaryForDetails.spans[0] : null;
699
- const currentSpan = viewMode === "trace" ? selectedTraceId == null ? rootSpanOfSelectedTrace ?? null : currentSpanInTrace?.span ?? null : viewMode === "span" ? currentSpanInFlat : null;
1051
+ const currentSpan = viewMode === "trace" ? drilldownTraceId == null ? rootSpanOfSelectedTrace ?? null : currentSpanInTrace?.span ?? null : viewMode === "span" ? currentSpanInFlat : null;
700
1052
  const selectedTraceLogs = selectedTraceSummary?.traceId && logs.length > 0 ? logs.filter((l) => l.traceId === selectedTraceSummary.traceId) : [];
701
1053
  const timelineItems = selectedTraceSummary && (selectedTraceSummary.spans.length > 0 || selectedTraceLogs.length > 0) ? buildTraceTimeline(selectedTraceSummary.spans, selectedTraceLogs) : [];
1054
+ const drilldownSpans = react.useMemo(
1055
+ () => drilldownTraceId ? spans.filter((s) => s.traceId === drilldownTraceId) : [],
1056
+ [spans, drilldownTraceId]
1057
+ );
1058
+ const drilldownTree = react.useMemo(
1059
+ () => drilldownSpans.length > 0 ? flattenTraceTree(buildTraceTree(drilldownSpans)) : [],
1060
+ [drilldownSpans]
1061
+ );
1062
+ const drilldownLogs = react.useMemo(
1063
+ () => drilldownTraceId ? logs.filter((l) => l.traceId === drilldownTraceId) : [],
1064
+ [logs, drilldownTraceId]
1065
+ );
1066
+ const drilldownTimeline = react.useMemo(
1067
+ () => drilldownSpans.length > 0 || drilldownLogs.length > 0 ? buildTraceTimeline(drilldownSpans, drilldownLogs) : [],
1068
+ [drilldownSpans, drilldownLogs]
1069
+ );
1070
+ const drilldownSummary = react.useMemo(
1071
+ () => drilldownTraceId ? traceSummaries.find((t2) => t2.traceId === drilldownTraceId) ?? null : null,
1072
+ [traceSummaries, drilldownTraceId]
1073
+ );
1074
+ const drilldownSelectedItem = react.useMemo(() => {
1075
+ if (!drilldownTraceId) return null;
1076
+ if (drilldownTab === "timeline") {
1077
+ const item = drilldownTimeline[drilldownSelectedIndex];
1078
+ if (!item) return null;
1079
+ return item;
1080
+ }
1081
+ if (drilldownTab === "spans") {
1082
+ return drilldownTree[drilldownSelectedIndex] ? {
1083
+ type: "span",
1084
+ span: drilldownTree[drilldownSelectedIndex].span
1085
+ } : null;
1086
+ }
1087
+ if (drilldownTab === "logs") {
1088
+ return drilldownLogs[drilldownSelectedIndex] ? { type: "log", log: drilldownLogs[drilldownSelectedIndex] } : null;
1089
+ }
1090
+ return null;
1091
+ }, [
1092
+ drilldownTraceId,
1093
+ drilldownTab,
1094
+ drilldownSelectedIndex,
1095
+ drilldownTimeline,
1096
+ drilldownTree,
1097
+ drilldownLogs
1098
+ ]);
1099
+ const sendAIQuery = async (question) => {
1100
+ const aiResult = aiModelRef.current;
1101
+ if (!aiResult || aiState.status === "streaming") return;
1102
+ const userMsg = { role: "user", content: question };
1103
+ setAiMessages((prev) => [...prev, userMsg]);
1104
+ setAiInput("");
1105
+ const abort = new AbortController();
1106
+ aiAbortRef.current = abort;
1107
+ setAiState({ status: "streaming", abortController: abort });
1108
+ const toolCtx = {
1109
+ spans,
1110
+ logs,
1111
+ traces: traceSummaries,
1112
+ stats,
1113
+ serviceStats,
1114
+ errorSummaries
1115
+ };
1116
+ const tools = createTelemetryTools(toolCtx);
1117
+ const statsContext = JSON.stringify({
1118
+ viewMode,
1119
+ stats: {
1120
+ totalSpans: stats.total,
1121
+ errors: stats.errors,
1122
+ avgMs: Math.round(stats.avg),
1123
+ p95Ms: Math.round(stats.p95)
1124
+ },
1125
+ services: serviceStats.length,
1126
+ traces: traceSummaries.length
1127
+ });
1128
+ const drilldownContext = drilldownTraceId ? `
1129
+
1130
+ Currently viewing trace ${drilldownTraceId}. This trace has ${drilldownSpans.length} spans and ${drilldownLogs.length} logs. The root span is "${drilldownSummary?.rootName ?? "unknown"}" with duration ${drilldownSummary ? formatDurationMs(drilldownSummary.durationMs) : "unknown"}.` : "";
1131
+ const systemPrompt = buildSystemPrompt(viewMode, statsContext) + drilldownContext;
1132
+ try {
1133
+ setAiMessages((prev) => [...prev, { role: "assistant", content: "" }]);
1134
+ const result = await providerStreamText(aiResult.providerType, {
1135
+ model: aiResult.model,
1136
+ system: systemPrompt,
1137
+ messages: [...aiMessages, userMsg].map((m) => ({
1138
+ role: m.role,
1139
+ content: m.content
1140
+ })),
1141
+ tools,
1142
+ maxSteps: 10,
1143
+ abortSignal: abort.signal
1144
+ });
1145
+ let fullText = "";
1146
+ for await (const chunk of result.textStream) {
1147
+ if (abort.signal.aborted) break;
1148
+ fullText += chunk;
1149
+ const captured = fullText;
1150
+ setAiMessages((prev) => {
1151
+ const updated = [...prev];
1152
+ const lastMsg = updated.at(-1);
1153
+ if (lastMsg?.role === "assistant") {
1154
+ updated[updated.length - 1] = {
1155
+ role: "assistant",
1156
+ content: captured
1157
+ };
1158
+ }
1159
+ return updated;
1160
+ });
1161
+ }
1162
+ setAiState({ status: "idle" });
1163
+ } catch (error) {
1164
+ if (abort.signal.aborted) {
1165
+ setAiState({ status: "idle" });
1166
+ return;
1167
+ }
1168
+ setAiState({
1169
+ status: "error",
1170
+ message: error instanceof Error ? error.message : String(error)
1171
+ });
1172
+ } finally {
1173
+ aiAbortRef.current = null;
1174
+ }
1175
+ };
702
1176
  const { isRawModeSupported } = ink.useStdin();
703
1177
  ink.useInput(
704
1178
  (input, key) => {
@@ -716,53 +1190,184 @@ function Dashboard({
716
1190
  return;
717
1191
  }
718
1192
  if (searchMode) {
719
- if (key.backspace || key.delete) {
720
- setSearchQuery((q) => q.slice(0, -1));
721
- } else if (key.return) {
1193
+ if (key.escape || key.return) {
722
1194
  setSearchMode(false);
1195
+ } else if (key.backspace || key.delete) {
1196
+ setSearchQuery((q) => q.slice(0, -1));
1197
+ } else if (key.tab) {
1198
+ if (searchQuery.length >= 4) {
1199
+ const match = traceSummaries.find(
1200
+ (t2) => t2.traceId.toLowerCase().startsWith(searchQuery.toLowerCase())
1201
+ );
1202
+ if (match) {
1203
+ setSpanFilters((prev) => ({ ...prev, traceId: match.traceId }));
1204
+ setSearchMode(false);
1205
+ setSearchQuery("");
1206
+ }
1207
+ }
723
1208
  } else if (input && input.length === 1 && !key.ctrl && !key.meta) {
724
1209
  setSearchQuery((q) => q + input);
725
1210
  }
726
1211
  return;
727
1212
  }
728
- if (key.escape) {
729
- if (viewMode === "trace" && selectedTraceId != null) {
730
- setSelectedTraceId(null);
731
- setSelectedSpanIndex(0);
1213
+ if (traceIdMode) {
1214
+ if (key.escape) {
1215
+ setTraceIdMode(false);
1216
+ setTraceIdInput("");
1217
+ } else if (key.return) {
1218
+ if (traceIdInput.trim()) {
1219
+ const match = traceSummaries.find(
1220
+ (t2) => t2.traceId.toLowerCase().startsWith(traceIdInput.toLowerCase())
1221
+ );
1222
+ if (match) {
1223
+ setSpanFilters((prev) => ({ ...prev, traceId: match.traceId }));
1224
+ }
1225
+ } else {
1226
+ setSpanFilters((prev) => {
1227
+ const { traceId: _, ...rest } = prev;
1228
+ return rest;
1229
+ });
1230
+ }
1231
+ setTraceIdMode(false);
1232
+ setTraceIdInput("");
1233
+ } else if (key.tab) {
1234
+ if (traceIdInput.length >= 2) {
1235
+ const match = traceSummaries.find(
1236
+ (t2) => t2.traceId.toLowerCase().startsWith(traceIdInput.toLowerCase())
1237
+ );
1238
+ if (match) {
1239
+ setTraceIdInput(match.traceId);
1240
+ }
1241
+ }
1242
+ } else if (key.backspace || key.delete) {
1243
+ setTraceIdInput((q) => q.slice(0, -1));
1244
+ } else if (input && input.length === 1 && !key.ctrl && !key.meta) {
1245
+ setTraceIdInput((q) => q + input);
1246
+ }
1247
+ return;
1248
+ }
1249
+ if (aiInputMode) {
1250
+ if (key.escape) {
1251
+ if (aiState.status === "streaming") {
1252
+ aiAbortRef.current?.abort();
1253
+ } else {
1254
+ setAiInputMode(false);
1255
+ setAiActive(false);
1256
+ }
1257
+ return;
1258
+ }
1259
+ if (key.backspace || key.delete) {
1260
+ setAiInput((q) => q.slice(0, -1));
1261
+ return;
1262
+ }
1263
+ if (key.return) {
1264
+ if (aiInput.trim()) {
1265
+ sendAIQuery(aiInput.trim());
1266
+ }
1267
+ return;
1268
+ }
1269
+ if (input && input.length === 1 && !key.ctrl && !key.meta) {
1270
+ setAiInput((q) => q + input);
1271
+ }
1272
+ return;
1273
+ }
1274
+ if (input === "a") {
1275
+ setAiActive((v) => !v);
1276
+ if (aiActive) {
1277
+ setAiInputMode(false);
732
1278
  } else {
1279
+ if (aiState.status !== "unconfigured") {
1280
+ setAiInputMode(true);
1281
+ }
1282
+ }
1283
+ return;
1284
+ }
1285
+ if (key.escape) {
1286
+ if (drilldownTraceId == null) {
733
1287
  setSearchMode(false);
1288
+ } else {
1289
+ setDrilldownTraceId(null);
1290
+ setDrilldownSelectedIndex(0);
1291
+ setDrilldownScrollOffset(0);
1292
+ setDrilldownTab("timeline");
734
1293
  }
735
1294
  return;
736
1295
  }
737
- if (key.return && viewMode === "trace" && selectedTraceId == null && filteredSummaries[selected]) {
738
- setSelectedTraceId(filteredSummaries[selected].traceId);
739
- setSelectedSpanIndex(0);
1296
+ if (key.tab && drilldownTraceId != null) {
1297
+ const tabs = [
1298
+ "timeline",
1299
+ "spans",
1300
+ "logs"
1301
+ ];
1302
+ const currentIdx = tabs.indexOf(drilldownTab);
1303
+ const nextIdx = key.shift ? (currentIdx - 1 + tabs.length) % tabs.length : (currentIdx + 1) % tabs.length;
1304
+ setDrilldownTab(tabs[nextIdx]);
1305
+ setDrilldownSelectedIndex(0);
1306
+ setDrilldownScrollOffset(0);
740
1307
  return;
741
1308
  }
1309
+ if (key.return && drilldownTraceId == null) {
1310
+ let targetTraceId;
1311
+ let preSelectIndex = 0;
1312
+ if (viewMode === "trace" && filteredSummaries[selected]) {
1313
+ targetTraceId = filteredSummaries[selected].traceId;
1314
+ } else if (viewMode === "log" && filteredLogs[selected]?.traceId) {
1315
+ targetTraceId = filteredLogs[selected].traceId;
1316
+ const originLog = filteredLogs[selected];
1317
+ const traceSpans = spans.filter((s) => s.traceId === targetTraceId);
1318
+ const traceLogs = logs.filter((l) => l.traceId === targetTraceId);
1319
+ const timeline = buildTraceTimeline(traceSpans, traceLogs);
1320
+ preSelectIndex = timeline.findIndex(
1321
+ (item) => item.type === "log" && item.log === originLog
1322
+ );
1323
+ if (preSelectIndex < 0) preSelectIndex = 0;
1324
+ } else if (viewMode === "span" && filteredSpans[selected]?.traceId) {
1325
+ targetTraceId = filteredSpans[selected].traceId;
1326
+ const originSpan = filteredSpans[selected];
1327
+ const traceSpans = spans.filter((s) => s.traceId === targetTraceId);
1328
+ const traceLogs = logs.filter((l) => l.traceId === targetTraceId);
1329
+ const timeline = buildTraceTimeline(traceSpans, traceLogs);
1330
+ preSelectIndex = timeline.findIndex(
1331
+ (item) => item.type === "span" && item.span?.spanId === originSpan.spanId
1332
+ );
1333
+ if (preSelectIndex < 0) preSelectIndex = 0;
1334
+ }
1335
+ if (targetTraceId) {
1336
+ setDrilldownTraceId(targetTraceId);
1337
+ setDrilldownSelectedIndex(preSelectIndex);
1338
+ setDrilldownScrollOffset(Math.max(0, preSelectIndex - LIST_HEIGHT + 1));
1339
+ setDrilldownTab("timeline");
1340
+ return;
1341
+ }
1342
+ }
742
1343
  if (key.upArrow || key.downArrow) {
1344
+ if (drilldownTraceId != null) {
1345
+ const listLength = drilldownTab === "timeline" ? drilldownTimeline.length : drilldownTab === "spans" ? drilldownTree.length : drilldownLogs.length;
1346
+ if (key.upArrow) {
1347
+ setDrilldownSelectedIndex((prev) => {
1348
+ const next = Math.max(0, prev - 1);
1349
+ setDrilldownScrollOffset((off) => Math.min(next, off));
1350
+ return next;
1351
+ });
1352
+ } else {
1353
+ setDrilldownSelectedIndex((prev) => {
1354
+ const next = Math.min(listLength - 1, prev + 1);
1355
+ setDrilldownScrollOffset(
1356
+ (off) => next >= off + LIST_HEIGHT ? next - LIST_HEIGHT + 1 : off
1357
+ );
1358
+ return next;
1359
+ });
1360
+ }
1361
+ return;
1362
+ }
743
1363
  switch (viewMode) {
744
1364
  case "trace": {
745
1365
  if (key.upArrow) {
746
- if (selectedTraceId != null && traceTree.length > 0) {
747
- setSelectedSpanIndex((i) => Math.max(0, i - 1));
748
- } else {
749
- setSelected((i) => Math.max(0, i - 1));
750
- setSelectedSpanIndex(0);
751
- }
1366
+ setSelected((i) => Math.max(0, i - 1));
1367
+ setDrilldownSelectedIndex(0);
752
1368
  } else if (key.downArrow) {
753
- if (selectedTraceId != null && selectedSpanIndex < traceTree.length - 1) {
754
- setSelectedSpanIndex((i) => Math.min(traceTree.length - 1, i + 1));
755
- } else if (selectedTraceId != null && traceTree.length > 0 && selectedSpanIndex >= traceTree.length - 1) {
756
- const nextIdx = filteredSummaries.findIndex((t) => t.traceId === selectedTraceId) + 1;
757
- if (nextIdx < filteredSummaries.length) {
758
- setSelected(nextIdx);
759
- setSelectedTraceId(filteredSummaries[nextIdx].traceId);
760
- setSelectedSpanIndex(0);
761
- }
762
- } else if (selectedTraceId == null) {
763
- setSelected((i) => Math.min(filteredSummaries.length - 1, i + 1));
764
- setSelectedSpanIndex(0);
765
- }
1369
+ setSelected((i) => Math.min(filteredSummaries.length - 1, i + 1));
1370
+ setDrilldownSelectedIndex(0);
766
1371
  }
767
1372
  break;
768
1373
  }
@@ -794,7 +1399,9 @@ function Dashboard({
794
1399
  if (key.upArrow) {
795
1400
  setSelected((i) => Math.max(0, i - 1));
796
1401
  } else if (key.downArrow) {
797
- setSelected((i) => Math.min(errorSummaries.length - 1, i + 1));
1402
+ setSelected(
1403
+ (i) => Math.min(filteredErrorSummaries.length - 1, i + 1)
1404
+ );
798
1405
  }
799
1406
  break;
800
1407
  }
@@ -805,35 +1412,40 @@ function Dashboard({
805
1412
  if (input === "t") {
806
1413
  setViewMode((m) => m === "trace" ? "span" : "trace");
807
1414
  setSelected(0);
808
- setSelectedTraceId(null);
809
- setSelectedSpanIndex(0);
1415
+ setDrilldownTraceId(null);
1416
+ setDrilldownSelectedIndex(0);
1417
+ setDrilldownScrollOffset(0);
810
1418
  }
811
1419
  if (input === "l") {
812
1420
  setViewMode((m) => m === "log" ? "trace" : "log");
813
1421
  setSelected(0);
814
- setSelectedTraceId(null);
815
- setSelectedSpanIndex(0);
1422
+ setDrilldownTraceId(null);
1423
+ setDrilldownSelectedIndex(0);
1424
+ setDrilldownScrollOffset(0);
816
1425
  }
817
1426
  if (input === "v") {
818
1427
  setViewMode(
819
1428
  (m) => m === "service-summary" ? "trace" : "service-summary"
820
1429
  );
821
1430
  setSelected(0);
822
- setSelectedTraceId(null);
823
- setSelectedSpanIndex(0);
1431
+ setDrilldownTraceId(null);
1432
+ setDrilldownSelectedIndex(0);
1433
+ setDrilldownScrollOffset(0);
824
1434
  }
825
1435
  if (input === "E") {
826
1436
  setViewMode((m) => m === "errors" ? "trace" : "errors");
827
1437
  setSelected(0);
828
- setSelectedTraceId(null);
829
- setSelectedSpanIndex(0);
1438
+ setDrilldownTraceId(null);
1439
+ setDrilldownSelectedIndex(0);
1440
+ setDrilldownScrollOffset(0);
830
1441
  }
831
1442
  if (input === "c") {
832
1443
  setSpans([]);
833
1444
  setLogs([]);
834
1445
  setSelected(0);
835
- setSelectedTraceId(null);
836
- setSelectedSpanIndex(0);
1446
+ setDrilldownTraceId(null);
1447
+ setDrilldownSelectedIndex(0);
1448
+ setDrilldownScrollOffset(0);
837
1449
  setNewErrorCount(0);
838
1450
  setSpanFilters({ statusGroup: "all" });
839
1451
  setRecording(false);
@@ -843,8 +1455,9 @@ function Dashboard({
843
1455
  setSpans([]);
844
1456
  setLogs([]);
845
1457
  setSelected(0);
846
- setSelectedTraceId(null);
847
- setSelectedSpanIndex(0);
1458
+ setDrilldownTraceId(null);
1459
+ setDrilldownSelectedIndex(0);
1460
+ setDrilldownScrollOffset(0);
848
1461
  setNewErrorCount(0);
849
1462
  setSpanFilters({ statusGroup: "all" });
850
1463
  setPaused(false);
@@ -853,8 +1466,14 @@ function Dashboard({
853
1466
  if (input === "x") {
854
1467
  setSpanFilters({ statusGroup: "all" });
855
1468
  setSelected(0);
856
- setSelectedTraceId(null);
857
- setSelectedSpanIndex(0);
1469
+ setDrilldownTraceId(null);
1470
+ setDrilldownSelectedIndex(0);
1471
+ setDrilldownScrollOffset(0);
1472
+ }
1473
+ if (input === "f") {
1474
+ setTraceIdMode(true);
1475
+ setTraceIdInput(spanFilters.traceId ?? "");
1476
+ return;
858
1477
  }
859
1478
  if (input === "H") {
860
1479
  setSpanFilters((prev) => {
@@ -862,16 +1481,18 @@ function Dashboard({
862
1481
  return { ...prev, statusGroup: next };
863
1482
  });
864
1483
  setSelected(0);
865
- setSelectedTraceId(null);
866
- setSelectedSpanIndex(0);
1484
+ setDrilldownTraceId(null);
1485
+ setDrilldownSelectedIndex(0);
1486
+ setDrilldownScrollOffset(0);
867
1487
  }
868
1488
  if (input === "S") {
869
1489
  const svc = currentSpan?.attributes?.["service.name"];
870
1490
  if (typeof svc === "string" && svc.trim()) {
871
1491
  setSpanFilters((prev) => ({ ...prev, serviceName: svc }));
872
1492
  setSelected(0);
873
- setSelectedTraceId(null);
874
- setSelectedSpanIndex(0);
1493
+ setDrilldownTraceId(null);
1494
+ setDrilldownSelectedIndex(0);
1495
+ setDrilldownScrollOffset(0);
875
1496
  }
876
1497
  }
877
1498
  if (input === "R") {
@@ -879,14 +1500,77 @@ function Dashboard({
879
1500
  if (typeof route === "string" && route.trim()) {
880
1501
  setSpanFilters((prev) => ({ ...prev, route }));
881
1502
  setSelected(0);
882
- setSelectedTraceId(null);
883
- setSelectedSpanIndex(0);
1503
+ setDrilldownTraceId(null);
1504
+ setDrilldownSelectedIndex(0);
1505
+ setDrilldownScrollOffset(0);
1506
+ }
1507
+ }
1508
+ if (input === "T") {
1509
+ let traceId;
1510
+ if (drilldownTraceId) {
1511
+ traceId = drilldownTraceId;
1512
+ } else {
1513
+ switch (viewMode) {
1514
+ case "log": {
1515
+ traceId = filteredLogs[selected]?.traceId;
1516
+ break;
1517
+ }
1518
+ case "span": {
1519
+ traceId = filteredSpans[selected]?.traceId;
1520
+ break;
1521
+ }
1522
+ case "errors": {
1523
+ traceId = filteredErrorSummaries[selected]?.traceId;
1524
+ break;
1525
+ }
1526
+ }
1527
+ }
1528
+ if (traceId && viewMode !== "trace") {
1529
+ setSpanFilters((prev) => ({ ...prev, traceId }));
1530
+ setViewMode("trace");
1531
+ setSelected(0);
1532
+ setDrilldownTraceId(null);
1533
+ setDrilldownSelectedIndex(0);
1534
+ setDrilldownScrollOffset(0);
1535
+ setDrilldownTab("timeline");
1536
+ }
1537
+ return;
1538
+ }
1539
+ if (input === "L") {
1540
+ let traceId;
1541
+ if (drilldownTraceId) {
1542
+ traceId = drilldownTraceId;
1543
+ } else {
1544
+ switch (viewMode) {
1545
+ case "trace": {
1546
+ traceId = filteredSummaries[selected]?.traceId;
1547
+ break;
1548
+ }
1549
+ case "span": {
1550
+ traceId = filteredSpans[selected]?.traceId;
1551
+ break;
1552
+ }
1553
+ case "errors": {
1554
+ traceId = filteredErrorSummaries[selected]?.traceId;
1555
+ break;
1556
+ }
1557
+ }
1558
+ }
1559
+ if (traceId && viewMode !== "log") {
1560
+ setSpanFilters((prev) => ({ ...prev, traceId }));
1561
+ setViewMode("log");
1562
+ setSelected(0);
1563
+ setDrilldownTraceId(null);
1564
+ setDrilldownSelectedIndex(0);
1565
+ setDrilldownScrollOffset(0);
1566
+ setDrilldownTab("timeline");
884
1567
  }
1568
+ return;
885
1569
  }
886
1570
  if (input === "J") {
887
- const t = selectedTraceSummary;
888
- if (!t) return;
889
- const json = exportTraceToJson(t, selectedTraceLogs);
1571
+ const t2 = selectedTraceSummary;
1572
+ if (!t2) return;
1573
+ const json = exportTraceToJson(t2, selectedTraceLogs);
890
1574
  process.stdout.write(`
891
1575
  [autotel-terminal] trace export
892
1576
  ${json}
@@ -899,22 +1583,32 @@ ${json}
899
1583
  const headerModeLabel = viewMode === "trace" ? "traces" : viewMode === "span" ? "spans" : viewMode === "log" ? "logs" : viewMode === "service-summary" ? "services" : "errors";
900
1584
  const showNewError = newErrorCount > 0;
901
1585
  function renderTreeRow(node, index) {
902
- const isSel = viewMode === "trace" && selectedTraceId != null && index === selectedSpanIndex;
1586
+ const isSel = drilldownTraceId != null && index === drilldownSelectedIndex;
903
1587
  const prefix = node.depth === 0 ? "" : " ".repeat(node.depth) + (node.children.length > 0 ? "\u251C\u2500\u2500 " : "\u2514\u2500\u2500 ");
904
1588
  const statusColor = node.span.status === "ERROR" ? "red" : node.span.durationMs > 500 ? "yellow" : "green";
905
- return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
906
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: isSel ? "cyan" : void 0, children: isSel ? "\u203A " : " " }),
907
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: prefix }),
908
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: colors ? statusColor : void 0, children: truncate(node.span.name, 24) }),
909
- /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
910
- " ",
911
- formatDurationMs(node.span.durationMs)
912
- ] })
913
- ] }, `${node.span.spanId}-${node.span.startTime}`);
1589
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1590
+ ink.Box,
1591
+ {
1592
+ flexDirection: "row",
1593
+ children: [
1594
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: isSel ? "cyan" : void 0, children: isSel ? "\u203A " : " " }),
1595
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: prefix }),
1596
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: colors ? statusColor : void 0, children: truncate(node.span.name, 24) }),
1597
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1598
+ " ",
1599
+ formatDurationMs(node.span.durationMs)
1600
+ ] })
1601
+ ]
1602
+ },
1603
+ `${node.span.spanId}-${node.span.startTime}`
1604
+ );
914
1605
  }
915
1606
  function keyAttrsAndRest(attrs) {
916
1607
  if (!attrs || Object.keys(attrs).length === 0)
917
- return { key: [], rest: [] };
1608
+ return {
1609
+ key: [],
1610
+ rest: []
1611
+ };
918
1612
  const entries = Object.entries(attrs);
919
1613
  const key = entries.filter(([k]) => KEY_ATTR_KEYS.has(k));
920
1614
  const rest = entries.filter(([k]) => !KEY_ATTR_KEYS.has(k));
@@ -926,9 +1620,7 @@ ${json}
926
1620
  ink.Box,
927
1621
  {
928
1622
  flexDirection: "column",
929
- borderStyle: "round",
930
- padding: 1,
931
- borderColor: colors ? "cyan" : void 0,
1623
+ paddingX: 1,
932
1624
  children: [
933
1625
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { justifyContent: "space-between", marginBottom: 1, children: [
934
1626
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { bold: true, children: [
@@ -942,37 +1634,28 @@ ${json}
942
1634
  /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: paused ? "yellow" : "green", children: headerRight }, "status")
943
1635
  ] })
944
1636
  ] }),
945
- showHelp ? /* @__PURE__ */ jsxRuntime.jsxs(
946
- ink.Box,
947
- {
948
- flexDirection: "column",
949
- borderStyle: "single",
950
- borderColor: "gray",
951
- paddingX: 1,
952
- marginBottom: 1,
953
- children: [
954
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: "Shortcuts" }),
955
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Navigation: \u2191/\u2193, Enter, Esc" }),
956
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Views: t (trace/spans), l (logs), v (services), E (errors)" }),
957
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Search: /" }),
958
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Filters: e (errors-only), S (service), R (route), H (status), x (clear)" }),
959
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Capture: p (pause), r (record snapshot), J (export trace JSON)" }),
960
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Other: c (clear), ? (help), Ctrl+C (exit)" })
961
- ]
962
- }
963
- ) : /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginBottom: 1, flexDirection: "row", justifyContent: "space-between", children: [
1637
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginBottom: 0, flexDirection: "column", children: [
964
1638
  searchMode ? /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "cyan", children: [
965
1639
  "Search: ",
966
- searchQuery || "(type to filter)"
967
- ] }, "search") : /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "\u2191/\u2193 select \u2022 Enter open \u2022 Esc back \u2022 t spans \u2022 l logs \u2022 v svc \u2022 E errors \u2022 / search \u2022 p pause \u2022 r record \u2022 e errors \u2022 c clear \u2022 ? help" }, "controls"),
968
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: viewMode === "trace" ? `traces ${filteredSummaries.length}/${traceSummaries.length}` : viewMode === "span" ? `spans ${filteredSpans.length}/${spans.length}` : viewMode === "service-summary" ? `services ${serviceStats.length}/${serviceStats.length}` : viewMode === "errors" ? `errors ${errorSummaries.length}/${errorSummaries.length}` : `logs ${filteredLogs.length}/${logs.length}` }, "count")
1640
+ searchQuery || "(type to filter)",
1641
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: " (Tab: match traceId, Esc: cancel)" })
1642
+ ] }) : traceIdMode ? /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "yellow", children: [
1643
+ "TraceId: ",
1644
+ traceIdInput || "(type prefix, Tab to complete)",
1645
+ traceIdInput.length >= 2 && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1646
+ " \u2192 ",
1647
+ traceSummaries.find(
1648
+ (t2) => t2.traceId.toLowerCase().startsWith(traceIdInput.toLowerCase())
1649
+ )?.traceId.slice(0, 16) ?? "no match",
1650
+ "\u2026"
1651
+ ] })
1652
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", justifyContent: "space-between", children: [
1653
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: drilldownTraceId == null ? "\u2191/\u2193 select \u2022 Enter open \u2022 Tab cycle tabs \u2022 Esc back \u2022 T trace \u2022 L logs \u2022 a AI \u2022 ? help" : "\u2191/\u2193 select \u2022 Tab cycle tabs \u2022 Esc back \u2022 T trace \u2022 L logs \u2022 a AI \u2022 ? help" }),
1654
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: viewMode === "trace" ? `traces ${filteredSummaries.length}/${traceSummaries.length}` : viewMode === "span" ? `spans ${filteredSpans.length}/${spans.length}` : viewMode === "service-summary" ? `services ${serviceStats.length}` : viewMode === "errors" ? `errors ${filteredErrorSummaries.length}/${errorSummaries.length}` : `logs ${filteredLogs.length}/${logs.length}` })
1655
+ ] }),
1656
+ showHelp && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Views: t/l/v/E \u2022 Search: / \u2022 Filters: e/S/R/H/f/x \u2022 Capture: p/r/J \u2022 AI: a \u2022 Clear: c" })
969
1657
  ] }),
970
- (spanFilters.serviceName || spanFilters.route || spanFilters.statusGroup !== "all") && /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { marginBottom: 1, children: /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
971
- "filters:",
972
- spanFilters.serviceName ? ` service=${spanFilters.serviceName}` : "",
973
- spanFilters.route ? ` route=${spanFilters.route}` : "",
974
- spanFilters.statusGroup && spanFilters.statusGroup !== "all" ? ` status=${spanFilters.statusGroup}` : ""
975
- ] }) }),
1658
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { marginBottom: 0, children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: spanFilters.serviceName || spanFilters.route || spanFilters.statusGroup !== "all" || spanFilters.traceId ? `filters:${spanFilters.serviceName ? ` service=${spanFilters.serviceName}` : ""}${spanFilters.route ? ` route=${spanFilters.route}` : ""}${spanFilters.statusGroup && spanFilters.statusGroup !== "all" ? ` status=${spanFilters.statusGroup}` : ""}${spanFilters.traceId ? ` trace=${spanFilters.traceId.slice(0, 8)}\u2026` : ""}` : "filters: none" }) }),
976
1659
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", gap: 2, children: [
977
1660
  /* @__PURE__ */ jsxRuntime.jsxs(
978
1661
  ink.Box,
@@ -986,113 +1669,305 @@ ${json}
986
1669
  children: [
987
1670
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginTop: 0, marginBottom: 1, children: [
988
1671
  /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: viewMode === "trace" ? "Recent traces" : viewMode === "span" ? "Recent spans" : viewMode === "service-summary" ? "Service summary" : viewMode === "errors" ? "Recent errors" : "Recent logs" }, "list-title"),
989
- filterErrorsOnly && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "red", children: " (errors only)" }, "errors-only-label"),
1672
+ filterErrorsOnly && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "red", children: [
1673
+ " ",
1674
+ "(errors only)"
1675
+ ] }, "errors-only-label"),
990
1676
  searchQuery && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
991
- " /",
1677
+ " ",
1678
+ "/",
992
1679
  searchQuery
993
1680
  ] }, "search-label")
994
1681
  ] }),
995
- viewMode === "trace" ? filteredSummaries.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
996
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "No traces yet. Call a traced function or hit an endpoint to see them here." }),
997
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Tip: trace() your handlers with autotel to get spans." })
998
- ] }) : /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: selectedTraceId == null ? filteredSummaries.slice(0, 20).map((t, i) => {
999
- const isSel = i === selected;
1000
- return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
1001
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: isSel ? "cyan" : void 0, children: isSel ? "\u203A " : " " }),
1002
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: t.hasError ? "red" : void 0, children: truncate(t.rootName, 20) }),
1003
- /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1004
- " ",
1005
- formatDurationMs(t.durationMs)
1006
- ] }),
1007
- /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1008
- " ",
1009
- truncate(t.traceId, 8)
1010
- ] }),
1011
- /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1012
- " ",
1013
- formatRelative(t.lastEndTime)
1014
- ] })
1015
- ] }, t.traceId);
1016
- }) : traceTree.slice(0, 20).map((node, i) => renderTreeRow(node, i)) }) : viewMode === "span" ? filteredSpans.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
1017
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "No spans yet. Call a traced function or hit an endpoint to see them here." }),
1018
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Tip: trace() your handlers with autotel to get spans." })
1019
- ] }) : filteredSpans.slice(0, 20).map((s, i) => {
1020
- const isSel = i === selected;
1021
- const statusColor = s.status === "ERROR" ? "red" : s.durationMs > 500 ? "yellow" : "green";
1022
- return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
1023
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: isSel ? "cyan" : void 0, children: isSel ? "\u203A " : " " }),
1024
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: colors ? statusColor : void 0, children: truncate(s.name, 26) }),
1025
- /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1026
- " ",
1027
- formatDurationMs(s.durationMs)
1028
- ] }),
1029
- /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1030
- " ",
1031
- formatRelative(s.endTime)
1032
- ] })
1033
- ] }, `${s.spanId}-${s.startTime}`);
1034
- }) : viewMode === "service-summary" ? serviceStats.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { flexDirection: "column", children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "No service stats yet. Add `service.name` attributes to spans." }) }) : serviceStats.slice(0, 20).map((svc, i) => {
1035
- const isSel = i === selected;
1036
- const errorRate = svc.total ? svc.errors / svc.total * 100 : 0;
1037
- return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
1038
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: isSel ? "cyan" : void 0, children: isSel ? "\u203A " : " " }),
1039
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: truncate(svc.serviceName, 16) }),
1040
- /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1041
- " ",
1042
- svc.errors,
1043
- "/",
1044
- svc.total
1045
- ] }),
1682
+ drilldownTraceId != null && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1683
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginBottom: 0, flexDirection: "row", gap: 2, children: [
1684
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "yellow", children: drilldownSummary?.rootName ?? "unknown" }),
1046
1685
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1047
- " ",
1048
- errorRate.toFixed(0),
1049
- "%"
1050
- ] }),
1051
- /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1052
- " p95 ",
1053
- formatDurationMs(svc.p95Ms)
1054
- ] })
1055
- ] }, svc.serviceName);
1056
- }) : viewMode === "errors" ? errorSummaries.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { flexDirection: "column", children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "No errors yet." }) }) : errorSummaries.slice(0, 20).map((e, i) => {
1057
- const isSel = i === selected;
1058
- return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
1059
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: isSel ? "cyan" : void 0, children: isSel ? "\u203A " : " " }),
1060
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "red", children: truncate(e.rootName, 16) }),
1061
- /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1062
- " ",
1063
- truncate(e.serviceName, 10)
1064
- ] }),
1065
- e.route && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1066
- " ",
1067
- truncate(e.route, 14)
1068
- ] }),
1069
- typeof e.statusCode === "number" && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1070
- " ",
1071
- e.statusCode
1686
+ drilldownTraceId.slice(0, 16),
1687
+ "\u2026"
1072
1688
  ] }),
1689
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "green", children: drilldownSummary ? formatDurationMs(drilldownSummary.durationMs) : "?" }),
1073
1690
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1074
- " (",
1075
- e.errorCount,
1076
- ")"
1691
+ drilldownSpans.length,
1692
+ " spans \u2022 ",
1693
+ drilldownLogs.length,
1694
+ " logs"
1077
1695
  ] })
1078
- ] }, e.traceId);
1079
- }) : filteredLogs.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
1080
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "No logs yet. Emit request logs or canonical log lines to see them here." }),
1081
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Tip: hook getTerminalLogStream() into your canonical log line drain." })
1082
- ] }) : filteredLogs.slice(0, 20).map((log, i) => {
1083
- const isSel = i === selected;
1084
- const levelColor = log.level === "error" ? "red" : log.level === "warn" ? "yellow" : log.level === "debug" ? "gray" : "green";
1085
- return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
1086
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: isSel ? "cyan" : void 0, children: isSel ? "\u203A " : " " }),
1087
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: colors ? levelColor : void 0, children: truncate(log.level.toUpperCase(), 5) }),
1088
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }),
1089
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: truncate(log.message, 32) })
1090
- ] }, `${log.time}-${i}`);
1091
- })
1696
+ ] }),
1697
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginBottom: 0, flexDirection: "row", gap: 2, children: [
1698
+ /* @__PURE__ */ jsxRuntime.jsx(
1699
+ ink.Text,
1700
+ {
1701
+ color: drilldownTab === "timeline" ? "yellow" : void 0,
1702
+ dimColor: drilldownTab !== "timeline",
1703
+ underline: drilldownTab === "timeline",
1704
+ children: "Timeline"
1705
+ }
1706
+ ),
1707
+ /* @__PURE__ */ jsxRuntime.jsxs(
1708
+ ink.Text,
1709
+ {
1710
+ color: drilldownTab === "spans" ? "yellow" : void 0,
1711
+ dimColor: drilldownTab !== "spans",
1712
+ underline: drilldownTab === "spans",
1713
+ children: [
1714
+ "Spans (",
1715
+ drilldownSpans.length,
1716
+ ")"
1717
+ ]
1718
+ }
1719
+ ),
1720
+ /* @__PURE__ */ jsxRuntime.jsxs(
1721
+ ink.Text,
1722
+ {
1723
+ color: drilldownTab === "logs" ? "yellow" : void 0,
1724
+ dimColor: drilldownTab !== "logs",
1725
+ underline: drilldownTab === "logs",
1726
+ children: [
1727
+ "Logs (",
1728
+ drilldownLogs.length,
1729
+ ")"
1730
+ ]
1731
+ }
1732
+ )
1733
+ ] })
1734
+ ] }),
1735
+ drilldownTraceId != null && drilldownTab === "timeline" && (() => {
1736
+ let traceStartMs = Infinity;
1737
+ for (const s of drilldownSummary?.spans ?? []) {
1738
+ if (s.startTime < traceStartMs) traceStartMs = s.startTime;
1739
+ }
1740
+ if (traceStartMs === Infinity) traceStartMs = 0;
1741
+ const traceDurMs = drilldownSummary?.durationMs ?? 1;
1742
+ const WATERFALL_WIDTH = 24;
1743
+ const items = drilldownTimeline.slice(drilldownScrollOffset, drilldownScrollOffset + LIST_HEIGHT);
1744
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1745
+ items.map((item, i) => {
1746
+ const isSel = i + drilldownScrollOffset === drilldownSelectedIndex;
1747
+ if (item.type === "span" && item.span) {
1748
+ const s = item.span;
1749
+ const node = drilldownTree.find(
1750
+ (n) => n.span.spanId === s.spanId
1751
+ );
1752
+ const depth = node?.depth ?? 0;
1753
+ const indent = " ".repeat(Math.min(depth, 4));
1754
+ const nameWidth = 24 - Math.min(depth, 4) * 2;
1755
+ const bar = buildWaterfallBar(
1756
+ s.startTime,
1757
+ s.durationMs,
1758
+ traceStartMs,
1759
+ traceDurMs,
1760
+ WATERFALL_WIDTH
1761
+ );
1762
+ return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
1763
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { backgroundColor: isSel ? "gray" : void 0, color: isSel ? "white" : void 0, children: [
1764
+ isSel ? "\u25B8" : " ",
1765
+ indent,
1766
+ truncate(s.name, nameWidth)
1767
+ ] }),
1768
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }),
1769
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: s.status === "ERROR" ? "red" : "green", children: bar }),
1770
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1771
+ " ",
1772
+ formatDurationMs(s.durationMs),
1773
+ s.kind ? ` ${s.kind}` : ""
1774
+ ] })
1775
+ ] }, `${s.spanId}-${i}`);
1776
+ } else if (item.type === "log" && item.log) {
1777
+ const l = item.log;
1778
+ const levelColor = l.level === "error" ? "red" : l.level === "warn" ? "yellow" : "blue";
1779
+ const relTime = drilldownSummary ? `+${formatDurationMs(l.time - traceStartMs)}` : "";
1780
+ const logOffset = drilldownSummary ? Math.floor((l.time - traceStartMs) / traceDurMs * WATERFALL_WIDTH) : 0;
1781
+ const clampedOffset = Math.max(0, Math.min(logOffset, WATERFALL_WIDTH - 1));
1782
+ const logBar = " ".repeat(clampedOffset) + "\xB7" + " ".repeat(WATERFALL_WIDTH - clampedOffset - 1);
1783
+ return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
1784
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { backgroundColor: isSel ? "gray" : void 0, color: isSel ? "white" : void 0, children: [
1785
+ isSel ? "\u25B8" : " ",
1786
+ " ",
1787
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: levelColor, children: l.level.toUpperCase() }),
1788
+ " ",
1789
+ truncate(l.message, 18)
1790
+ ] }),
1791
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }),
1792
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: logBar }),
1793
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1794
+ " ",
1795
+ relTime
1796
+ ] })
1797
+ ] }, `log-${i}`);
1798
+ }
1799
+ return null;
1800
+ }),
1801
+ Array.from({ length: Math.max(0, LIST_HEIGHT - items.length) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`))
1802
+ ] });
1803
+ })(),
1804
+ drilldownTraceId != null && drilldownTab === "spans" && drilldownTree.slice(drilldownScrollOffset, drilldownScrollOffset + LIST_HEIGHT).map((node, i) => renderTreeRow(node, i + drilldownScrollOffset)),
1805
+ drilldownTraceId != null && drilldownTab === "spans" && Array.from({ length: Math.max(0, LIST_HEIGHT - Math.min(drilldownTree.length, LIST_HEIGHT)) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`)),
1806
+ drilldownTraceId != null && drilldownTab === "logs" && drilldownLogs.slice(drilldownScrollOffset, drilldownScrollOffset + LIST_HEIGHT).map((log, i) => {
1807
+ const isSel = i + drilldownScrollOffset === drilldownSelectedIndex;
1808
+ const levelColor = log.level === "error" ? "red" : log.level === "warn" ? "yellow" : log.level === "info" ? "green" : void 0;
1809
+ return /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsxs(
1810
+ ink.Text,
1811
+ {
1812
+ backgroundColor: isSel ? "gray" : void 0,
1813
+ color: isSel ? "white" : void 0,
1814
+ children: [
1815
+ isSel ? "\u25B8" : " ",
1816
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: levelColor, children: [
1817
+ " ",
1818
+ log.level.toUpperCase()
1819
+ ] }),
1820
+ " ",
1821
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1822
+ "[",
1823
+ truncate(log.message, 50),
1824
+ "]"
1825
+ ] })
1826
+ ]
1827
+ }
1828
+ ) }, `log-${i}`);
1829
+ }),
1830
+ drilldownTraceId != null && drilldownTab === "logs" && Array.from({ length: Math.max(0, LIST_HEIGHT - Math.min(drilldownLogs.length, LIST_HEIGHT)) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`)),
1831
+ drilldownTraceId == null && /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: viewMode === "trace" ? filteredSummaries.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1832
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
1833
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "No traces yet. Call a traced function or hit an endpoint to see them here." }),
1834
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Tip: trace() your handlers with autotel to get spans." })
1835
+ ] }),
1836
+ Array.from({ length: Math.max(0, LIST_HEIGHT - 2) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`))
1837
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1838
+ filteredSummaries.slice(0, 20).map((t2, i) => {
1839
+ const isSel = i === selected;
1840
+ return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
1841
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: isSel ? "cyan" : void 0, children: isSel ? "\u25B8 " : " " }),
1842
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: t2.hasError ? "red" : "yellow", bold: isSel, children: truncate(t2.rootName, 28) }),
1843
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1844
+ " ",
1845
+ t2.spans.length,
1846
+ " spans"
1847
+ ] }),
1848
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "green", children: [
1849
+ " ",
1850
+ formatDurationMs(t2.durationMs)
1851
+ ] }),
1852
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1853
+ " ",
1854
+ formatRelative(t2.lastEndTime)
1855
+ ] }),
1856
+ t2.hasError && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "red", children: " \u25CF" })
1857
+ ] }, t2.traceId);
1858
+ }),
1859
+ Array.from({ length: Math.max(0, LIST_HEIGHT - Math.min(filteredSummaries.length, 20)) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`))
1860
+ ] }) : viewMode === "span" ? filteredSpans.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1861
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
1862
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "No spans yet. Call a traced function or hit an endpoint to see them here." }),
1863
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Tip: trace() your handlers with autotel to get spans." })
1864
+ ] }),
1865
+ Array.from({ length: Math.max(0, LIST_HEIGHT - 2) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`))
1866
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1867
+ filteredSpans.slice(0, 20).map((s, i) => {
1868
+ const isSel = i === selected;
1869
+ const statusColor = s.status === "ERROR" ? "red" : s.durationMs > 500 ? "yellow" : "green";
1870
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1871
+ ink.Box,
1872
+ {
1873
+ flexDirection: "row",
1874
+ children: [
1875
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: isSel ? "cyan" : void 0, children: isSel ? "\u203A " : " " }),
1876
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: colors ? statusColor : void 0, children: truncate(s.name, 26) }),
1877
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1878
+ " ",
1879
+ formatDurationMs(s.durationMs)
1880
+ ] }),
1881
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1882
+ " ",
1883
+ formatRelative(s.endTime)
1884
+ ] })
1885
+ ]
1886
+ },
1887
+ `${s.spanId}-${s.startTime}`
1888
+ );
1889
+ }),
1890
+ Array.from({ length: Math.max(0, LIST_HEIGHT - Math.min(filteredSpans.length, 20)) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`))
1891
+ ] }) : viewMode === "service-summary" ? serviceStats.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1892
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { flexDirection: "column", children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "No service stats yet. Add `service.name` attributes to spans." }) }),
1893
+ Array.from({ length: Math.max(0, LIST_HEIGHT - 1) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`))
1894
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1895
+ serviceStats.slice(0, 20).map((svc, i) => {
1896
+ const isSel = i === selected;
1897
+ const errorRate = svc.total ? svc.errors / svc.total * 100 : 0;
1898
+ return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
1899
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: isSel ? "cyan" : void 0, children: isSel ? "\u203A " : " " }),
1900
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: truncate(svc.serviceName, 16) }),
1901
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1902
+ " ",
1903
+ svc.errors,
1904
+ "/",
1905
+ svc.total
1906
+ ] }),
1907
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1908
+ " ",
1909
+ errorRate.toFixed(0),
1910
+ "%"
1911
+ ] }),
1912
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1913
+ " p95 ",
1914
+ formatDurationMs(svc.p95Ms)
1915
+ ] })
1916
+ ] }, svc.serviceName);
1917
+ }),
1918
+ Array.from({ length: Math.max(0, LIST_HEIGHT - Math.min(serviceStats.length, 20)) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`))
1919
+ ] }) : viewMode === "errors" ? filteredErrorSummaries.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1920
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { flexDirection: "column", children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "No errors yet." }) }),
1921
+ Array.from({ length: Math.max(0, LIST_HEIGHT - 1) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`))
1922
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1923
+ filteredErrorSummaries.slice(0, 20).map((e, i) => {
1924
+ const isSel = i === selected;
1925
+ return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
1926
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: isSel ? "cyan" : void 0, children: isSel ? "\u203A " : " " }),
1927
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "red", children: truncate(e.rootName, 16) }),
1928
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1929
+ " ",
1930
+ truncate(e.serviceName, 10)
1931
+ ] }),
1932
+ e.route && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1933
+ " ",
1934
+ truncate(e.route, 14)
1935
+ ] }),
1936
+ typeof e.statusCode === "number" && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1937
+ " ",
1938
+ e.statusCode
1939
+ ] }),
1940
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1941
+ " (",
1942
+ e.errorCount,
1943
+ ")"
1944
+ ] })
1945
+ ] }, e.traceId);
1946
+ }),
1947
+ Array.from({ length: Math.max(0, LIST_HEIGHT - Math.min(filteredErrorSummaries.length, 20)) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`))
1948
+ ] }) : filteredLogs.length === 0 ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1949
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
1950
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "No logs yet. Emit request logs or canonical log lines to see them here." }),
1951
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Tip: hook getTerminalLogStream() into your canonical log line drain." })
1952
+ ] }),
1953
+ Array.from({ length: Math.max(0, LIST_HEIGHT - 2) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`))
1954
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1955
+ filteredLogs.slice(0, 20).map((log, i) => {
1956
+ const isSel = i === selected;
1957
+ const levelColor = log.level === "error" ? "red" : log.level === "warn" ? "yellow" : log.level === "debug" ? "gray" : "green";
1958
+ return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
1959
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: isSel ? "cyan" : void 0, children: isSel ? "\u203A " : " " }),
1960
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: colors ? levelColor : void 0, children: truncate(log.level.toUpperCase(), 5) }),
1961
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }),
1962
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: truncate(log.message, 32) })
1963
+ ] }, `${log.time}-${i}`);
1964
+ }),
1965
+ Array.from({ length: Math.max(0, LIST_HEIGHT - Math.min(filteredLogs.length, 20)) }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: " " }) }, `pad-${i}`))
1966
+ ] }) })
1092
1967
  ]
1093
1968
  }
1094
1969
  ),
1095
- /* @__PURE__ */ jsxRuntime.jsxs(
1970
+ /* @__PURE__ */ jsxRuntime.jsx(
1096
1971
  ink.Box,
1097
1972
  {
1098
1973
  flexDirection: "column",
@@ -1101,11 +1976,139 @@ ${json}
1101
1976
  borderColor: "gray",
1102
1977
  paddingX: 1,
1103
1978
  paddingY: 0,
1104
- children: [
1979
+ children: aiActive ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1980
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginBottom: 1, justifyContent: "space-between", children: [
1981
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: "AI Assistant" }),
1982
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: aiState.status === "streaming" ? "(streaming...)" : aiState.status === "unconfigured" ? "(no provider)" : aiState.status === "error" ? "(error)" : "" })
1983
+ ] }),
1984
+ aiState.status === "unconfigured" ? /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "column", children: [
1985
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "No AI provider configured." }),
1986
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Set AI_PROVIDER and AI_MODEL env vars, or start Ollama locally." }),
1987
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Press 'a' to close this panel." })
1988
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1989
+ aiMessages.length === 0 && aiState.status !== "error" && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Ask a question about your telemetry data. Press Enter to send." }),
1990
+ aiMessages.slice(-10).map((msg, i) => /* @__PURE__ */ jsxRuntime.jsx(
1991
+ ink.Box,
1992
+ {
1993
+ flexDirection: "column",
1994
+ marginBottom: msg.role === "assistant" ? 1 : 0,
1995
+ children: /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: msg.role === "user" ? "cyan" : void 0, children: [
1996
+ msg.role === "user" ? "> " : "",
1997
+ msg.content.slice(0, 500),
1998
+ msg.content.length > 500 ? "..." : ""
1999
+ ] })
2000
+ },
2001
+ i
2002
+ )),
2003
+ aiState.status === "error" && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { color: "red", children: [
2004
+ "Error: ",
2005
+ aiState.message
2006
+ ] }),
2007
+ /* @__PURE__ */ jsxRuntime.jsxs(
2008
+ ink.Box,
2009
+ {
2010
+ marginTop: 1,
2011
+ borderStyle: "single",
2012
+ borderColor: "cyan",
2013
+ paddingX: 1,
2014
+ children: [
2015
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: "cyan", children: "> " }),
2016
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: aiInput || (aiInputMode ? "(type your question)" : "(press a to focus)") })
2017
+ ]
2018
+ }
2019
+ )
2020
+ ] })
2021
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1105
2022
  /* @__PURE__ */ jsxRuntime.jsx(ink.Box, { marginBottom: 1, children: /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: "Details" }) }),
1106
- viewMode === "errors" ? (() => {
1107
- const e = errorSummaries[selected] ?? null;
1108
- if (!e) return /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Select an error to view details." });
2023
+ drilldownTraceId != null && drilldownSelectedItem?.type === "span" && drilldownSelectedItem.span ? (() => {
2024
+ const span = drilldownSelectedItem.span;
2025
+ const { key: keyAttrs, rest: restAttrs } = keyAttrsAndRest(
2026
+ span.attributes
2027
+ );
2028
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2029
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
2030
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Name: " }),
2031
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: span.name })
2032
+ ] }),
2033
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
2034
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Status: " }),
2035
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: span.status === "ERROR" ? "red" : "green", children: span.status })
2036
+ ] }),
2037
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
2038
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Duration: " }),
2039
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: formatDurationMs(span.durationMs) })
2040
+ ] }),
2041
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
2042
+ "Trace: ",
2043
+ span.traceId
2044
+ ] }),
2045
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
2046
+ "Span: ",
2047
+ span.spanId
2048
+ ] }),
2049
+ span.parentSpanId && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
2050
+ "Parent: ",
2051
+ span.parentSpanId
2052
+ ] }),
2053
+ span.kind && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
2054
+ "Kind: ",
2055
+ span.kind
2056
+ ] }),
2057
+ keyAttrs.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginTop: 1, flexDirection: "column", children: [
2058
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: "Key attributes" }),
2059
+ keyAttrs.slice(0, 6).map(([k, v]) => /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
2060
+ truncate(k, 18),
2061
+ ": ",
2062
+ truncate(String(v), 28)
2063
+ ] }, k))
2064
+ ] }),
2065
+ restAttrs.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginTop: 1, flexDirection: "column", children: [
2066
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: "Attributes" }),
2067
+ restAttrs.slice(0, 8).map(([k, v]) => /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
2068
+ truncate(k, 18),
2069
+ ": ",
2070
+ truncate(String(v), 28)
2071
+ ] }, k))
2072
+ ] }),
2073
+ keyAttrs.length === 0 && restAttrs.length === 0 && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "(no attributes)" })
2074
+ ] });
2075
+ })() : drilldownTraceId != null && drilldownSelectedItem?.type === "log" && drilldownSelectedItem.log ? (() => {
2076
+ const log = drilldownSelectedItem.log;
2077
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2078
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
2079
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Level: " }),
2080
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: log.level.toUpperCase() })
2081
+ ] }),
2082
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
2083
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Time: " }),
2084
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: new Date(log.time).toISOString() })
2085
+ ] }),
2086
+ /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
2087
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Message: " }),
2088
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { children: log.message })
2089
+ ] }),
2090
+ log.traceId && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
2091
+ "Trace: ",
2092
+ log.traceId
2093
+ ] }),
2094
+ log.spanId && /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
2095
+ "Span: ",
2096
+ log.spanId
2097
+ ] }),
2098
+ log.attributes && Object.keys(log.attributes).length > 0 && /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginTop: 1, flexDirection: "column", children: [
2099
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: "Attributes" }),
2100
+ Object.entries(log.attributes).slice(0, 10).map(([k, v]) => /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
2101
+ truncate(k, 18),
2102
+ ": ",
2103
+ truncate(String(v), 40)
2104
+ ] }, k))
2105
+ ] })
2106
+ ] });
2107
+ })() : drilldownTraceId == null ? null : /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Select an item to view details." }),
2108
+ drilldownTraceId == null && (viewMode === "errors" ? (() => {
2109
+ const e = filteredErrorSummaries[selected] ?? null;
2110
+ if (!e)
2111
+ return /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Select an error to view details." });
1109
2112
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1110
2113
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
1111
2114
  /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Trace: " }),
@@ -1121,11 +2124,12 @@ ${json}
1121
2124
  "Errors: ",
1122
2125
  e.errorCount
1123
2126
  ] }),
1124
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Tip: switch to trace view and search for this trace ID." })
2127
+ /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Press T to jump to trace view for this trace." })
1125
2128
  ] });
1126
2129
  })() : viewMode === "service-summary" ? (() => {
1127
2130
  const svc = serviceStats[selected] ?? null;
1128
- if (!svc) return /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Select a service to view details." });
2131
+ if (!svc)
2132
+ return /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Select a service to view details." });
1129
2133
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1130
2134
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
1131
2135
  /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Service: " }),
@@ -1139,7 +2143,8 @@ ${json}
1139
2143
  " | Avg:",
1140
2144
  " ",
1141
2145
  formatDurationMs(svc.avgMs),
1142
- " | P95: ",
2146
+ " | P95:",
2147
+ " ",
1143
2148
  formatDurationMs(svc.p95Ms)
1144
2149
  ] }),
1145
2150
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginTop: 1, flexDirection: "column", children: [
@@ -1159,7 +2164,8 @@ ${json}
1159
2164
  /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: "Hot spans" }),
1160
2165
  selectedServiceHotSpans.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "(no spans)" }) : selectedServiceHotSpans.map((h) => /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1161
2166
  truncate(h.name, 20),
1162
- " p95 ",
2167
+ " p95",
2168
+ " ",
1163
2169
  formatDurationMs(h.p95Ms),
1164
2170
  " (",
1165
2171
  h.count,
@@ -1209,7 +2215,8 @@ ${json}
1209
2215
  return /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { dimColor: true, children: [
1210
2216
  "+",
1211
2217
  relMs,
1212
- "ms span ",
2218
+ "ms span",
2219
+ " ",
1213
2220
  truncate(item.span.name, 20)
1214
2221
  ] }, `span-${idx}`);
1215
2222
  }
@@ -1217,7 +2224,8 @@ ${json}
1217
2224
  return /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
1218
2225
  "+",
1219
2226
  relMs,
1220
- "ms log ",
2227
+ "ms log",
2228
+ " ",
1221
2229
  truncate(item.log.message, 24)
1222
2230
  ] }, `log-${idx}`);
1223
2231
  }
@@ -1232,16 +2240,25 @@ ${json}
1232
2240
  ] }),
1233
2241
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
1234
2242
  /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Status: " }),
1235
- /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: currentSpan.status === "ERROR" ? "red" : "green", children: currentSpan.status })
2243
+ /* @__PURE__ */ jsxRuntime.jsx(
2244
+ ink.Text,
2245
+ {
2246
+ color: currentSpan.status === "ERROR" ? "red" : "green",
2247
+ children: currentSpan.status
2248
+ }
2249
+ )
1236
2250
  ] }),
1237
2251
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
1238
2252
  /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Duration: " }),
1239
2253
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Text, { children: [
1240
2254
  formatDurationMs(currentSpan.durationMs),
1241
2255
  perSpanNameStats.byName.has(currentSpan.name) && (() => {
1242
- const p = perSpanNameStats.byName.get(currentSpan.name);
2256
+ const p = perSpanNameStats.byName.get(
2257
+ currentSpan.name
2258
+ );
1243
2259
  const ratio = p.avgMs > 0 ? currentSpan.durationMs / p.avgMs : 1;
1244
- if (ratio >= 1.5) return ` (${ratio.toFixed(1)}x avg)`;
2260
+ if (ratio >= 1.5)
2261
+ return ` (${ratio.toFixed(1)}x avg)`;
1245
2262
  return "";
1246
2263
  })()
1247
2264
  ] })
@@ -1284,10 +2301,12 @@ ${json}
1284
2301
  keyAttrs.length === 0 && restAttrs.length === 0 && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "(no attributes)" })
1285
2302
  ] });
1286
2303
  })(),
1287
- waterfallSpans.length > 0 && selectedTraceId != null && /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginTop: 1, flexDirection: "column", children: [
2304
+ waterfallSpans.length > 0 && drilldownTraceId != null && /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginTop: 1, flexDirection: "column", children: [
1288
2305
  /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { bold: true, children: "Waterfall" }),
1289
2306
  waterfallSpans.slice(0, 10).map((w) => {
1290
- const barLen = Math.round(w.span.durationMs / waterfallMaxMs * barWidth) || 1;
2307
+ const barLen = Math.round(
2308
+ w.span.durationMs / waterfallMaxMs * barWidth
2309
+ ) || 1;
1291
2310
  const bar = "\u2588".repeat(barLen);
1292
2311
  const indent = " ".repeat(w.depth);
1293
2312
  return /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { flexDirection: "row", children: [
@@ -1304,8 +2323,8 @@ ${json}
1304
2323
  ] }, w.span.spanId);
1305
2324
  })
1306
2325
  ] })
1307
- ] }) : /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Select a trace or span to view details." })
1308
- ]
2326
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { dimColor: true, children: "Select a trace or span to view details." }))
2327
+ ] })
1309
2328
  }
1310
2329
  )
1311
2330
  ] }),
@@ -1314,11 +2333,13 @@ ${json}
1314
2333
  stats.total,
1315
2334
  " | Span errors: ",
1316
2335
  stats.errors,
1317
- " | Logs: ",
2336
+ " | Logs:",
2337
+ " ",
1318
2338
  logStats.total,
1319
2339
  " | Log errors: ",
1320
2340
  logStats.errors,
1321
- " | Avg: ",
2341
+ " | Avg:",
2342
+ " ",
1322
2343
  formatDurationMs(stats.avg),
1323
2344
  " | P95: ",
1324
2345
  formatDurationMs(stats.p95)
@@ -1336,6 +2357,7 @@ function renderTerminal(options = {}, stream) {
1336
2357
  const showStats = options.showStats !== false;
1337
2358
  const maxSpans = options.maxSpans ?? 100;
1338
2359
  const colors = options.colors ?? Boolean(process.stdout.isTTY);
2360
+ const aiConfig = options.ai;
1339
2361
  const stdinOption = process.stdin.isTTY ? process.stdin : void 0;
1340
2362
  if (stream) {
1341
2363
  try {
@@ -1348,7 +2370,8 @@ function renderTerminal(options = {}, stream) {
1348
2370
  maxSpans,
1349
2371
  colors,
1350
2372
  stream,
1351
- logStream: getTerminalLogStream()
2373
+ logStream: getTerminalLogStream(),
2374
+ aiConfig
1352
2375
  }
1353
2376
  ),
1354
2377
  { stdin: stdinOption }
@@ -1464,7 +2487,8 @@ function toMs(unixNano) {
1464
2487
  }
1465
2488
  function mapStatus2(code) {
1466
2489
  const normalized = typeof code === "string" ? code.toUpperCase() : code;
1467
- if (normalized === 1 || normalized === "STATUS_CODE_OK" || normalized === "OK") return "OK";
2490
+ if (normalized === 1 || normalized === "STATUS_CODE_OK" || normalized === "OK")
2491
+ return "OK";
1468
2492
  if (normalized === 2 || normalized === "STATUS_CODE_ERROR" || normalized === "ERROR") {
1469
2493
  return "ERROR";
1470
2494
  }
@@ -1635,33 +2659,39 @@ function countOtlpMetrics(payload) {
1635
2659
 
1636
2660
  // src/cli.ts
1637
2661
  function printHelp() {
1638
- process.stdout.write(String.raw`autotel-terminal - Standalone OTLP receiver with terminal dashboard
2662
+ process.stdout.write(
2663
+ String.raw`autotel-terminal - Standalone OTLP receiver with terminal dashboard
1639
2664
 
1640
2665
  Usage: autotel-terminal [options]
1641
2666
 
1642
2667
  Options:
1643
- -p, --port <port> Port to listen on (default: 4319, env: AUTOTEL_TERMINAL_PORT)
1644
- -H, --host <host> Host to bind to (default: 127.0.0.1, env: AUTOTEL_TERMINAL_HOST)
1645
- -t, --title <title> Dashboard title (env: AUTOTEL_TERMINAL_TITLE)
1646
- -h, --help Show this help message
1647
- -v, --version Show version number
2668
+ -p, --port <port> Port to listen on (default: 4319, env: AUTOTEL_TERMINAL_PORT)
2669
+ -H, --host <host> Host to bind to (default: 127.0.0.1, env: AUTOTEL_TERMINAL_HOST)
2670
+ -t, --title <title> Dashboard title (env: AUTOTEL_TERMINAL_TITLE)
2671
+ -h, --help Show this help message
2672
+ -v, --version Show version number
2673
+
2674
+ AI Options:
2675
+ --ai-provider <provider> AI provider: ollama, openai, openai-compatible (env: AI_PROVIDER)
2676
+ --ai-model <model> AI model name (env: AI_MODEL)
2677
+ --ai-api-key <key> API key for cloud providers (env: AI_API_KEY)
2678
+ --ai-base-url <url> Custom AI endpoint URL (env: AI_BASE_URL)
2679
+
2680
+ Auto-detection: if Ollama is running locally, it is used automatically.
2681
+ If OPENAI_API_KEY is set, OpenAI is used. Press 'a' in the dashboard to toggle AI.
1648
2682
 
1649
2683
  Endpoints:
1650
- POST /v1/traces Receive OTLP JSON trace data
1651
- POST /v1/logs Receive OTLP JSON log data
1652
- POST /v1/metrics Receive OTLP JSON metric data (accepted and counted)
1653
- GET /healthz Health check
2684
+ POST /v1/traces Receive OTLP JSON trace data
2685
+ POST /v1/logs Receive OTLP JSON log data
2686
+ POST /v1/metrics Receive OTLP JSON metric data (accepted and counted)
2687
+ GET /healthz Health check
1654
2688
 
1655
2689
  Examples:
1656
2690
  npx autotel-terminal
1657
- npx autotel-terminal --port 4319
1658
- npx autotel-terminal -p 4319 -H 0.0.0.0
1659
-
1660
- Then in another terminal:
1661
- OTEL_EXPORTER_OTLP_PROTOCOL=http/json \
1662
- OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4319 \
1663
- node app.js
1664
- ` + "\n");
2691
+ npx autotel-terminal --ai-provider ollama --ai-model granite4
2692
+ AI_API_KEY=sk-... npx autotel-terminal --ai-provider openai --ai-model gpt-4o
2693
+ ` + "\n"
2694
+ );
1665
2695
  }
1666
2696
  function printVersion() {
1667
2697
  try {
@@ -1678,7 +2708,8 @@ function parseArgs(argv) {
1678
2708
  const options = {
1679
2709
  port: Number(process.env.AUTOTEL_TERMINAL_PORT || 4319),
1680
2710
  host: process.env.AUTOTEL_TERMINAL_HOST || "127.0.0.1",
1681
- title: process.env.AUTOTEL_TERMINAL_TITLE
2711
+ title: process.env.AUTOTEL_TERMINAL_TITLE,
2712
+ ai: {}
1682
2713
  };
1683
2714
  for (let i = 0; i < argv.length; i++) {
1684
2715
  const arg = argv[i];
@@ -1704,6 +2735,26 @@ function parseArgs(argv) {
1704
2735
  if ((arg === "--title" || arg === "-t") && next) {
1705
2736
  options.title = next;
1706
2737
  i++;
2738
+ continue;
2739
+ }
2740
+ if (arg === "--ai-provider" && next) {
2741
+ options.ai.provider = next;
2742
+ i++;
2743
+ continue;
2744
+ }
2745
+ if (arg === "--ai-model" && next) {
2746
+ options.ai.model = next;
2747
+ i++;
2748
+ continue;
2749
+ }
2750
+ if (arg === "--ai-api-key" && next) {
2751
+ options.ai.apiKey = next;
2752
+ i++;
2753
+ continue;
2754
+ }
2755
+ if (arg === "--ai-base-url" && next) {
2756
+ options.ai.baseUrl = next;
2757
+ i++;
1707
2758
  }
1708
2759
  }
1709
2760
  return options;
@@ -1718,7 +2769,8 @@ async function main() {
1718
2769
  const logStream = getTerminalLogStream();
1719
2770
  renderTerminal(
1720
2771
  {
1721
- title: options.title || `Autotel Terminal (${options.host}:${options.port})`
2772
+ title: options.title || `Autotel Terminal (${options.host}:${options.port})`,
2773
+ ai: options.ai
1722
2774
  },
1723
2775
  spanStream
1724
2776
  );