@kitlangton/motel 0.2.0 → 0.2.1

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 (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +5 -3
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +144 -7
  5. package/src/daemon.ts +113 -8
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +111 -121
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +52 -0
  15. package/src/services/TelemetryStore.ts +151 -26
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +243 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +292 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +2 -1
  32. package/src/ui/Waterfall.tsx +38 -138
  33. package/src/ui/aiChatModel.test.ts +347 -0
  34. package/src/ui/aiChatModel.ts +736 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +291 -120
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +173 -39
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +1 -1
@@ -1,18 +1,27 @@
1
1
  import { useAtom } from "@effect/atom-react"
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react"
3
3
  import { config } from "../../config.js"
4
- import type { LogItem, TraceItem } from "../../domain.ts"
4
+ import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
5
5
  import {
6
6
  activeAttrKeyAtom,
7
7
  activeAttrValueAtom,
8
+ aiCallDetailStateAtom,
8
9
  autoRefreshAtom,
10
+ chatDetailChunkIdAtom,
11
+ chatDetailScrollOffsetAtom,
9
12
  collapsedSpanIdsAtom,
10
13
  detailViewAtom,
14
+ ensureAiCallDetail,
15
+ ensureTraceAttributeKeys,
11
16
  filterModeAtom,
12
17
  filterTextAtom,
18
+ getCachedAiCallDetail,
19
+ initialAiCallDetailState,
13
20
  initialLogState,
14
21
  initialServiceLogState,
15
22
  initialTraceDetailState,
23
+ invalidateAiCallDetailCache,
24
+ invalidateFacetCaches,
16
25
  loadFilteredTraceSummaries,
17
26
  loadRecentTraceSummaries,
18
27
  loadServiceLogs,
@@ -22,6 +31,8 @@ import {
22
31
  logStateAtom,
23
32
  persistSelectedService,
24
33
  refreshNonceAtom,
34
+ selectedAttrIndexAtom,
35
+ selectedChatChunkIdAtom,
25
36
  selectedServiceLogIndexAtom,
26
37
  selectedSpanIndexAtom,
27
38
  selectedTraceIndexAtom,
@@ -29,12 +40,71 @@ import {
29
40
  serviceLogStateAtom,
30
41
  showHelpAtom,
31
42
  traceDetailStateAtom,
43
+ type TraceSortMode,
32
44
  traceSortAtom,
33
45
  traceStateAtom,
34
46
  } from "../state.ts"
47
+ import { isAiSpan } from "../../domain.ts"
48
+ import { buildChunks, type Chunk } from "../aiChatModel.ts"
35
49
  import { parseFilterText } from "../filterParser.ts"
36
50
  import { getVisibleSpans } from "../Waterfall.tsx"
37
51
 
52
+ const clampSelectionIndex = (index: number, length: number) => {
53
+ if (length === 0) return 0
54
+ return Math.max(0, Math.min(index, length - 1))
55
+ }
56
+
57
+ const resolveEffectiveService = (
58
+ services: readonly string[],
59
+ selectedTraceService: string | null,
60
+ ) => services.includes(selectedTraceService ?? "")
61
+ ? selectedTraceService
62
+ : selectedTraceService ?? services[0] ?? config.otel.serviceName
63
+
64
+ const loadTraceSummariesForService = (
65
+ serviceName: string | null,
66
+ filters: {
67
+ readonly activeAttrKey: string | null
68
+ readonly activeAttrValue: string | null
69
+ readonly debouncedAiText: string | null
70
+ },
71
+ ) => {
72
+ if (!serviceName) return Promise.resolve([] as readonly TraceSummaryItem[])
73
+ const hasAttrFilter = Boolean(filters.activeAttrKey && filters.activeAttrValue)
74
+ const hasAiFilter = Boolean(filters.debouncedAiText)
75
+ if (!hasAttrFilter && !hasAiFilter) return loadRecentTraceSummaries(serviceName)
76
+ return loadFilteredTraceSummaries(serviceName, {
77
+ attributeFilters: hasAttrFilter ? { [filters.activeAttrKey as string]: filters.activeAttrValue as string } : undefined,
78
+ aiText: hasAiFilter ? filters.debouncedAiText : null,
79
+ })
80
+ }
81
+
82
+ const applyClientTraceFilters = (
83
+ traces: readonly TraceSummaryItem[],
84
+ filterText: string,
85
+ parsedFilter: ReturnType<typeof parseFilterText>,
86
+ ) => filterText
87
+ ? traces.filter((trace) => {
88
+ if (parsedFilter.errorOnly && trace.errorCount === 0) return false
89
+ if (parsedFilter.operationNeedle && !trace.rootOperationName.toLowerCase().includes(parsedFilter.operationNeedle)) return false
90
+ return true
91
+ })
92
+ : traces
93
+
94
+ const sortTraceSummaries = (traces: readonly TraceSummaryItem[], traceSort: TraceSortMode) => {
95
+ if (traceSort === "recent") return traces
96
+ return [...traces].sort((a, b) => {
97
+ if (traceSort === "slowest") return b.durationMs - a.durationMs
98
+ if (traceSort === "errors") return b.errorCount - a.errorCount || b.startedAt.getTime() - a.startedAt.getTime()
99
+ return 0
100
+ })
101
+ }
102
+
103
+ const getSelectedVisibleSpan = (
104
+ spans: readonly TraceItem["spans"][number][],
105
+ selectedSpanIndex: number | null,
106
+ ) => selectedSpanIndex === null ? null : spans[selectedSpanIndex] ?? null
107
+
38
108
  export const useTraceScreenData = () => {
39
109
  const [traceState, setTraceState] = useAtom(traceStateAtom)
40
110
  const [traceDetailState, setTraceDetailState] = useAtom(traceDetailStateAtom)
@@ -45,6 +115,11 @@ export const useTraceScreenData = () => {
45
115
  const [selectedTraceService, setSelectedTraceService] = useAtom(selectedTraceServiceAtom)
46
116
  const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
47
117
  const [selectedSpanIndex, setSelectedSpanIndex] = useAtom(selectedSpanIndexAtom)
118
+ const [, setSelectedAttrIndex] = useAtom(selectedAttrIndexAtom)
119
+ const [, setChatDetailChunkId] = useAtom(chatDetailChunkIdAtom)
120
+ const [, setChatDetailScrollOffset] = useAtom(chatDetailScrollOffsetAtom)
121
+ const [selectedChatChunkId, setSelectedChatChunkId] = useAtom(selectedChatChunkIdAtom)
122
+ const [aiCallDetailState, setAiCallDetailState] = useAtom(aiCallDetailStateAtom)
48
123
  const [detailView, setDetailView] = useAtom(detailViewAtom)
49
124
  const [showHelp, setShowHelp] = useAtom(showHelpAtom)
50
125
  const [collapsedSpanIds, setCollapsedSpanIds] = useAtom(collapsedSpanIdsAtom)
@@ -91,8 +166,18 @@ export const useTraceScreenData = () => {
91
166
  serviceLogCacheRef.current.clear()
92
167
  traceDetailInflightRef.current.clear()
93
168
  traceLogInflightRef.current.clear()
169
+ invalidateFacetCaches()
170
+ invalidateAiCallDetailCache()
94
171
  }, [refreshNonce])
95
172
 
173
+ // Pre-warm the attribute picker facet keys for the currently-selected
174
+ // service so pressing `f` feels instant. Fire-and-forget; errors are
175
+ // surfaced when the user actually opens the picker.
176
+ useEffect(() => {
177
+ if (!selectedTraceService) return
178
+ void ensureTraceAttributeKeys(selectedTraceService).catch(() => {})
179
+ }, [selectedTraceService, refreshNonce])
180
+
96
181
  useEffect(() => {
97
182
  let cancelled = false
98
183
 
@@ -103,28 +188,17 @@ export const useTraceScreenData = () => {
103
188
  const services = await loadTraceServices()
104
189
  if (cancelled) return
105
190
 
106
- const effectiveService = services.includes(selectedTraceService ?? "")
107
- ? selectedTraceService
108
- : selectedTraceService ?? services[0] ?? config.otel.serviceName
191
+ const effectiveService = resolveEffectiveService(services, selectedTraceService)
109
192
 
110
193
  if (effectiveService !== selectedTraceService) {
111
194
  setSelectedTraceService(effectiveService)
112
195
  }
113
196
 
114
- // Branch on whether any server-side filter is active. `:ai`
115
- // (debouncedAiText) and the attr picker compose; either
116
- // alone also uses the filtered loader. Unfiltered falls
117
- // back to the fast recent-summaries path.
118
- const hasAttrFilter = Boolean(activeAttrKey && activeAttrValue)
119
- const hasAiFilter = Boolean(debouncedAiText)
120
- const traces = effectiveService
121
- ? (hasAttrFilter || hasAiFilter
122
- ? await loadFilteredTraceSummaries(effectiveService, {
123
- attributeFilters: hasAttrFilter ? { [activeAttrKey as string]: activeAttrValue as string } : undefined,
124
- aiText: hasAiFilter ? debouncedAiText : null,
125
- })
126
- : await loadRecentTraceSummaries(effectiveService))
127
- : []
197
+ const traces = await loadTraceSummariesForService(effectiveService, {
198
+ activeAttrKey,
199
+ activeAttrValue,
200
+ debouncedAiText,
201
+ })
128
202
  if (cancelled) return
129
203
 
130
204
  const prevTraceId = selectedTraceRef.current
@@ -151,14 +225,18 @@ export const useTraceScreenData = () => {
151
225
 
152
226
  useEffect(() => {
153
227
  setSelectedTraceIndex((current) => {
154
- if (traceState.data.length === 0) return 0
155
- return Math.max(0, Math.min(current, traceState.data.length - 1))
228
+ return clampSelectionIndex(current, traceState.data.length)
156
229
  })
157
230
  }, [traceState.data.length, setSelectedTraceIndex])
158
231
 
159
232
  const selectedTraceSummary = traceState.data[selectedTraceIndex] ?? null
160
233
  const selectedTraceId = selectedTraceSummary?.traceId ?? null
161
234
  const selectedTrace = traceDetailState.traceId === selectedTraceId ? traceDetailState.data : null
235
+ const selectedVisibleSpans = useMemo(
236
+ () => selectedTrace ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds) : [],
237
+ [selectedTrace, collapsedSpanIds],
238
+ )
239
+ const selectedVisibleSpan = getSelectedVisibleSpan(selectedVisibleSpans, selectedSpanIndex)
162
240
  selectedTraceRef.current = selectedTraceId
163
241
 
164
242
  const warmTraceDetail = useCallback((traceId: string, hydrateSelection: boolean) => {
@@ -295,6 +373,72 @@ export const useTraceScreenData = () => {
295
373
  setSelectedSpanIndex(null)
296
374
  }, [selectedTraceId, setCollapsedSpanIds, setSelectedSpanIndex])
297
375
 
376
+ // Reset the attribute cursor whenever the span selection moves. Without
377
+ // this, drilling from span A (with 34 tags) to span B (with 3 tags)
378
+ // would leave the cursor pointing past the end of B's tag list until
379
+ // the user hit `j`/`k` again.
380
+ useEffect(() => {
381
+ setSelectedAttrIndex(0)
382
+ setChatDetailChunkId(null)
383
+ setChatDetailScrollOffset(0)
384
+ // New span → drop chunk selection and any open detail modal.
385
+ // The effect below will re-select the first chunk once the
386
+ // detail loads.
387
+ setSelectedChatChunkId(null)
388
+ }, [selectedSpanIndex, selectedTraceId, setSelectedAttrIndex, setChatDetailChunkId, setChatDetailScrollOffset, setSelectedChatChunkId])
389
+
390
+ // Load the parsed AI call detail for the currently-selected span when
391
+ // it's an AI span and the user is drilled into L2. Cached module-level
392
+ // so re-entering the chat view for a span we already loaded is free.
393
+ const selectedSpanId = selectedVisibleSpan?.spanId ?? null
394
+ const shouldLoadAiDetail = detailView === "span-detail" && selectedVisibleSpan !== null && isAiSpan(selectedVisibleSpan.tags)
395
+
396
+ useEffect(() => {
397
+ if (!shouldLoadAiDetail || !selectedSpanId) {
398
+ setAiCallDetailState(initialAiCallDetailState)
399
+ return
400
+ }
401
+ const cached = getCachedAiCallDetail(selectedSpanId)
402
+ if (cached !== undefined) {
403
+ setAiCallDetailState({ status: "ready", spanId: selectedSpanId, data: cached, error: null })
404
+ return
405
+ }
406
+ setAiCallDetailState({ status: "loading", spanId: selectedSpanId, data: null, error: null })
407
+ let cancelled = false
408
+ ensureAiCallDetail(selectedSpanId)
409
+ .then((data) => {
410
+ if (cancelled) return
411
+ setAiCallDetailState({ status: "ready", spanId: selectedSpanId, data, error: null })
412
+ })
413
+ .catch((err) => {
414
+ if (cancelled) return
415
+ setAiCallDetailState({
416
+ status: "error",
417
+ spanId: selectedSpanId,
418
+ data: null,
419
+ error: err instanceof Error ? err.message : String(err),
420
+ })
421
+ })
422
+ return () => { cancelled = true }
423
+ }, [shouldLoadAiDetail, selectedSpanId, setAiCallDetailState])
424
+
425
+ // Chunk model — rebuilt whenever the detail payload changes.
426
+ // Width-independent, so this lives here instead of in the view.
427
+ const aiChatChunks = useMemo<readonly Chunk[]>(() => {
428
+ if (!shouldLoadAiDetail || !aiCallDetailState.data) return []
429
+ return buildChunks(aiCallDetailState.data)
430
+ }, [shouldLoadAiDetail, aiCallDetailState.data])
431
+
432
+ // Once chunks are available, pin selection to the first chunk unless
433
+ // the user has already chosen one. Also handles the "chunk list
434
+ // changed and the previous selection disappeared" case.
435
+ useEffect(() => {
436
+ if (aiChatChunks.length === 0) return
437
+ const stillValid = selectedChatChunkId !== null
438
+ && aiChatChunks.some((c) => c.id === selectedChatChunkId)
439
+ if (!stillValid) setSelectedChatChunkId(aiChatChunks[0]!.id)
440
+ }, [aiChatChunks, selectedChatChunkId, setSelectedChatChunkId])
441
+
298
442
  useEffect(() => {
299
443
  if (selectedSpanIndex === null) return
300
444
  if (!selectedTrace || selectedTrace.spans.length === 0) {
@@ -302,11 +446,11 @@ export const useTraceScreenData = () => {
302
446
  setDetailView("waterfall")
303
447
  return
304
448
  }
305
- const visibleCount = getVisibleSpans(selectedTrace.spans, collapsedSpanIds).length
449
+ const visibleCount = selectedVisibleSpans.length
306
450
  if (selectedSpanIndex >= visibleCount) {
307
451
  setSelectedSpanIndex(visibleCount - 1)
308
452
  }
309
- }, [selectedTrace, selectedSpanIndex, collapsedSpanIds, setDetailView, setSelectedSpanIndex])
453
+ }, [selectedTrace, selectedSpanIndex, selectedVisibleSpans.length, setDetailView, setSelectedSpanIndex])
310
454
 
311
455
  useEffect(() => {
312
456
  const traceId = selectedTraceId
@@ -375,8 +519,7 @@ export const useTraceScreenData = () => {
375
519
 
376
520
  useEffect(() => {
377
521
  setSelectedServiceLogIndex((current) => {
378
- if (serviceLogState.data.length === 0) return 0
379
- return Math.max(0, Math.min(current, serviceLogState.data.length - 1))
522
+ return clampSelectionIndex(current, serviceLogState.data.length)
380
523
  })
381
524
  }, [serviceLogState.data.length, setSelectedServiceLogIndex])
382
525
 
@@ -384,21 +527,10 @@ export const useTraceScreenData = () => {
384
527
  // against already-loaded summaries (no server round-trip). The `:ai`
385
528
  // query, by contrast, is applied server-side in the load effect
386
529
  // above so we don't need to re-filter it here.
387
- const preFilterTraces = filterText
388
- ? traceState.data.filter((trace) => {
389
- if (parsedFilter.errorOnly && trace.errorCount === 0) return false
390
- if (parsedFilter.operationNeedle && !trace.rootOperationName.toLowerCase().includes(parsedFilter.operationNeedle)) return false
391
- return true
392
- })
393
- : traceState.data
394
-
395
- const filteredTraces = traceSort === "recent"
396
- ? preFilterTraces
397
- : [...preFilterTraces].sort((a, b) => {
398
- if (traceSort === "slowest") return b.durationMs - a.durationMs
399
- if (traceSort === "errors") return b.errorCount - a.errorCount || b.startedAt.getTime() - a.startedAt.getTime()
400
- return 0
401
- })
530
+ const filteredTraces = useMemo(() => {
531
+ const preFiltered = applyClientTraceFilters(traceState.data, filterText, parsedFilter)
532
+ return sortTraceSummaries(preFiltered, traceSort)
533
+ }, [filterText, parsedFilter, traceSort, traceState.data])
402
534
 
403
535
  useEffect(() => {
404
536
  if (!selectedTraceId || filteredTraces.length === 0) return
@@ -440,5 +572,7 @@ export const useTraceScreenData = () => {
440
572
  selectedTrace,
441
573
  selectedTraceId,
442
574
  filteredTraces,
575
+ aiCallDetailState,
576
+ aiChatChunks,
443
577
  } as const
444
578
  }
@@ -0,0 +1,131 @@
1
+ import * as Atom from "effect/unstable/reactivity/Atom"
2
+ import { config } from "../config.ts"
3
+ import type { LogItem, TraceItem, TraceSummaryItem } from "../domain.ts"
4
+ import type { ThemeName } from "./theme.ts"
5
+ import { readLastService, readLastTheme } from "./persistence.ts"
6
+
7
+ export type LoadStatus = "loading" | "ready" | "error"
8
+ export type DetailView = "waterfall" | "span-detail" | "service-logs"
9
+
10
+ export interface TraceState {
11
+ readonly status: LoadStatus
12
+ readonly services: readonly string[]
13
+ readonly data: readonly TraceSummaryItem[]
14
+ readonly error: string | null
15
+ readonly fetchedAt: Date | null
16
+ }
17
+
18
+ export interface TraceDetailState {
19
+ readonly status: LoadStatus
20
+ readonly traceId: string | null
21
+ readonly data: TraceItem | null
22
+ readonly error: string | null
23
+ readonly fetchedAt: Date | null
24
+ }
25
+
26
+ export interface LogState {
27
+ readonly status: LoadStatus
28
+ readonly traceId: string | null
29
+ readonly data: readonly LogItem[]
30
+ readonly error: string | null
31
+ readonly fetchedAt: Date | null
32
+ }
33
+
34
+ export interface ServiceLogState {
35
+ readonly status: LoadStatus
36
+ readonly serviceName: string | null
37
+ readonly data: readonly LogItem[]
38
+ readonly error: string | null
39
+ readonly fetchedAt: Date | null
40
+ }
41
+
42
+ export const initialTraceState: TraceState = {
43
+ status: "loading",
44
+ services: [],
45
+ data: [],
46
+ error: null,
47
+ fetchedAt: null,
48
+ }
49
+
50
+ export const initialLogState: LogState = {
51
+ status: "ready",
52
+ traceId: null,
53
+ data: [],
54
+ error: null,
55
+ fetchedAt: null,
56
+ }
57
+
58
+ export const initialTraceDetailState: TraceDetailState = {
59
+ status: "ready",
60
+ traceId: null,
61
+ data: null,
62
+ error: null,
63
+ fetchedAt: null,
64
+ }
65
+
66
+ export const initialServiceLogState: ServiceLogState = {
67
+ status: "ready",
68
+ serviceName: null,
69
+ data: [],
70
+ error: null,
71
+ fetchedAt: null,
72
+ }
73
+
74
+ export const traceStateAtom = Atom.make(initialTraceState).pipe(Atom.keepAlive)
75
+ export const traceDetailStateAtom = Atom.make(initialTraceDetailState).pipe(Atom.keepAlive)
76
+ export const logStateAtom = Atom.make(initialLogState).pipe(Atom.keepAlive)
77
+ export const serviceLogStateAtom = Atom.make(initialServiceLogState).pipe(Atom.keepAlive)
78
+ export const selectedServiceLogIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
79
+ export const selectedTraceIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
80
+ export const selectedTraceServiceAtom = Atom.make<string | null>(readLastService() ?? config.otel.serviceName).pipe(Atom.keepAlive)
81
+ export const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
82
+ export const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
83
+ export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.keepAlive)
84
+ // Cursor inside the full-screen span content view (detailView === "span-detail").
85
+ // Tracks which span tag is currently selected for copy / drill-in. Reset to 0
86
+ // on each new span so the cursor doesn't point past a shorter tag list.
87
+ export const selectedAttrIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
88
+ export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
89
+ export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
90
+ export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
91
+ export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
92
+ export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
93
+
94
+ // Waterfall-scoped filter: the `/` key while drilled into a trace
95
+ // (viewLevel >= 1) opens this filter instead of the trace-list one.
96
+ // Purely client-side — dims spans whose operation name and attribute
97
+ // values don't contain the needle.
98
+ export const waterfallFilterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
99
+ export const waterfallFilterTextAtom = Atom.make("").pipe(Atom.keepAlive)
100
+
101
+ // Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
102
+ export type AttrPickerMode = "off" | "keys" | "values"
103
+ export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
104
+ export const attrPickerInputAtom = Atom.make("").pipe(Atom.keepAlive)
105
+ export const attrPickerIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
106
+
107
+ export interface AttrFacetState {
108
+ readonly status: LoadStatus
109
+ readonly key: string | null
110
+ readonly data: readonly { readonly value: string; readonly count: number }[]
111
+ readonly error: string | null
112
+ }
113
+
114
+ export const initialAttrFacetState: AttrFacetState = {
115
+ status: "ready",
116
+ key: null,
117
+ data: [],
118
+ error: null,
119
+ }
120
+
121
+ export const attrFacetStateAtom = Atom.make(initialAttrFacetState).pipe(Atom.keepAlive)
122
+
123
+ // Applied filter (drives trace list query)
124
+ export const activeAttrKeyAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
125
+ export const activeAttrValueAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
126
+
127
+ export const selectedThemeAtom = Atom.make<ThemeName>(readLastTheme()).pipe(Atom.keepAlive)
128
+
129
+ export type TraceSortMode = "recent" | "slowest" | "errors"
130
+ export const traceSortAtom = Atom.make<TraceSortMode>("recent").pipe(Atom.keepAlive)
131
+ export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<string>).pipe(Atom.keepAlive)
@@ -0,0 +1,120 @@
1
+ import { Effect } from "effect"
2
+ import { config } from "../config.ts"
3
+ import { queryRuntime } from "../runtime.ts"
4
+ import { LogQueryService } from "../services/LogQueryService.ts"
5
+ import { TraceQueryService } from "../services/TraceQueryService.ts"
6
+
7
+ export const loadTraceServices = () =>
8
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
9
+
10
+ export const loadRecentTraceSummaries = (serviceName: string) =>
11
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
12
+
13
+ /**
14
+ * Server-side trace summary search. Accepts any combination of:
15
+ *
16
+ * - `attributeFilters` — exact-match span attributes (from the `f` picker)
17
+ * - `aiText` — FTS5-backed search across LLM prompt/response
18
+ * content (AI_FTS_KEYS), from the `:ai <query>`
19
+ * modifier in the `/` filter
20
+ *
21
+ * Both filters compose: when both are set, a trace must match both. When
22
+ * neither is set, callers should prefer `loadRecentTraceSummaries` so
23
+ * the server can skip the search path entirely.
24
+ */
25
+ export const loadFilteredTraceSummaries = (
26
+ serviceName: string,
27
+ options: {
28
+ readonly attributeFilters?: Readonly<Record<string, string>>
29
+ readonly aiText?: string | null
30
+ },
31
+ ) =>
32
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({
33
+ serviceName,
34
+ attributeFilters: options.attributeFilters,
35
+ aiText: options.aiText ?? null,
36
+ limit: config.otel.traceFetchLimit,
37
+ })))
38
+
39
+ export const loadTraceAttributeKeys = (serviceName: string) =>
40
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
41
+
42
+ export const loadTraceAttributeValues = (serviceName: string, key: string) =>
43
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Facet cache (drives the `f` attribute filter picker)
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export interface FacetRow {
50
+ readonly value: string
51
+ readonly count: number
52
+ }
53
+
54
+ export interface FacetCacheEntry {
55
+ readonly data: readonly FacetRow[]
56
+ readonly fetchedAt: Date
57
+ }
58
+
59
+ const facetKeysCache = new Map<string, FacetCacheEntry>()
60
+ const facetValuesCache = new Map<string, FacetCacheEntry>()
61
+ const facetKeysInflight = new Map<string, Promise<FacetCacheEntry>>()
62
+ const facetValuesInflight = new Map<string, Promise<FacetCacheEntry>>()
63
+
64
+ const valuesKey = (service: string, key: string) => `${service}\u0000${key}`
65
+
66
+ export const getCachedFacetKeys = (service: string): FacetCacheEntry | null =>
67
+ facetKeysCache.get(service) ?? null
68
+
69
+ export const getCachedFacetValues = (service: string, key: string): FacetCacheEntry | null =>
70
+ facetValuesCache.get(valuesKey(service, key)) ?? null
71
+
72
+ export const ensureTraceAttributeKeys = (service: string): Promise<FacetCacheEntry> => {
73
+ const existing = facetKeysInflight.get(service)
74
+ if (existing) return existing
75
+ const request = loadTraceAttributeKeys(service)
76
+ .then((data) => {
77
+ const entry = { data, fetchedAt: new Date() } satisfies FacetCacheEntry
78
+ facetKeysCache.set(service, entry)
79
+ return entry
80
+ })
81
+ .finally(() => {
82
+ facetKeysInflight.delete(service)
83
+ })
84
+ facetKeysInflight.set(service, request)
85
+ return request
86
+ }
87
+
88
+ export const ensureTraceAttributeValues = (service: string, key: string): Promise<FacetCacheEntry> => {
89
+ const cacheKey = valuesKey(service, key)
90
+ const existing = facetValuesInflight.get(cacheKey)
91
+ if (existing) return existing
92
+ const request = loadTraceAttributeValues(service, key)
93
+ .then((data) => {
94
+ const entry = { data, fetchedAt: new Date() } satisfies FacetCacheEntry
95
+ facetValuesCache.set(cacheKey, entry)
96
+ return entry
97
+ })
98
+ .finally(() => {
99
+ facetValuesInflight.delete(cacheKey)
100
+ })
101
+ facetValuesInflight.set(cacheKey, request)
102
+ return request
103
+ }
104
+
105
+ /** Called from the refreshNonce effect alongside the trace / log cache clears. */
106
+ export const invalidateFacetCaches = () => {
107
+ facetKeysCache.clear()
108
+ facetValuesCache.clear()
109
+ facetKeysInflight.clear()
110
+ facetValuesInflight.clear()
111
+ }
112
+
113
+ export const loadTraceDetail = (traceId: string) =>
114
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
115
+
116
+ export const loadTraceLogs = (traceId: string) =>
117
+ queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
118
+
119
+ export const loadServiceLogs = (serviceName: string) =>
120
+ queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { dirname } from "node:path"
3
+ import { config } from "../config.ts"
4
+ import type { ThemeName } from "./theme.ts"
5
+
6
+ const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
7
+
8
+ export const readLastService = (): string | null => {
9
+ try {
10
+ return readFileSync(lastServicePath, "utf-8").trim() || null
11
+ } catch {
12
+ return null
13
+ }
14
+ }
15
+
16
+ let lastPersistedService = readLastService()
17
+
18
+ export const persistSelectedService = (service: string) => {
19
+ if (service === lastPersistedService) return
20
+ lastPersistedService = service
21
+ Bun.write(lastServicePath, service).catch(() => {})
22
+ }
23
+
24
+ const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
25
+
26
+ export const readLastTheme = (): ThemeName => {
27
+ try {
28
+ const raw = readFileSync(lastThemePath, "utf-8").trim()
29
+ return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : "motel-default"
30
+ } catch {
31
+ return "motel-default"
32
+ }
33
+ }
34
+
35
+ let lastPersistedTheme = readLastTheme()
36
+
37
+ export const persistSelectedTheme = (theme: ThemeName) => {
38
+ if (theme === lastPersistedTheme) return
39
+ lastPersistedTheme = theme
40
+ Bun.write(lastThemePath, theme).catch(() => {})
41
+ }
@@ -1,4 +1,5 @@
1
1
  import { RGBA, TextAttributes } from "@opentui/core"
2
+ import { Children } from "react"
2
3
  import { colors } from "./theme.ts"
3
4
  import { fitCell, truncateText } from "./format.ts"
4
5
  import type { DetailView } from "./state.ts"
@@ -19,19 +20,32 @@ export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: stri
19
20
  </box>
20
21
  )
21
22
 
22
- export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
23
- <box height={1}>
24
- {bg ? (
25
- <text wrapMode="none" truncate fg={fg} bg={bg}>
26
- {children}
27
- </text>
28
- ) : (
29
- <text wrapMode="none" truncate fg={fg}>
30
- {children}
31
- </text>
32
- )}
33
- </box>
34
- )
23
+ const collapseFormattingWhitespace = (children: React.ReactNode) => Children.toArray(children).filter((child) => {
24
+ if (typeof child !== "string") return true
25
+ if (child.trim().length > 0) return true
26
+ // OpenTUI preserves JSX indentation/newline text nodes, so strip the
27
+ // formatting-only whitespace between inline spans while keeping any
28
+ // intentional in-band spaces that callers render explicitly.
29
+ return !/[\r\n\t]/.test(child)
30
+ })
31
+
32
+ export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => {
33
+ const inlineChildren = collapseFormattingWhitespace(children)
34
+
35
+ return (
36
+ <box height={1}>
37
+ {bg ? (
38
+ <text wrapMode="none" truncate fg={fg} bg={bg}>
39
+ {inlineChildren}
40
+ </text>
41
+ ) : (
42
+ <text wrapMode="none" truncate fg={fg}>
43
+ {inlineChildren}
44
+ </text>
45
+ )}
46
+ </box>
47
+ )
48
+ }
35
49
 
36
50
  export const AlignedHeaderLine = ({ left, right, width, rightFg = colors.muted }: { left: string; right: string; width: number; rightFg?: string }) => {
37
51
  const availableRightWidth = Math.max(8, width - left.length - 2)