@kitlangton/motel 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -146,7 +146,12 @@
146
146
  - `[` / `]`: switch services
147
147
  - `s`: cycle sort mode (recent → slowest → errors)
148
148
  - `t`: cycle theme (motel-default → tokyo-night → catppuccin)
149
- - `/`: enter filter mode (type to match on root operation name; `:error` restricts to failing traces)
149
+ - `/`: enter filter mode.
150
+ - **In the trace list (L0)** the input matches against the root operation name. Composable modifiers:
151
+ - `:error` — restrict to traces with at least one failed span (client-side)
152
+ - `:ai <query>` — FTS5-backed search against LLM prompt/response/tool content (`AI_FTS_KEYS`) across every span in the trace. Tokens are prefix-matched and implicitly AND'd. Debounced 250ms.
153
+ - Modifiers compose: `/ :ai rate limit :error`
154
+ - **In the waterfall (L1/L2)** the input runs a client-side substring match against each span's operation name and tag values. Non-matching spans are dimmed; the filter bar shows the live match count. `enter` commits (dim persists while you navigate); `esc` clears.
150
155
  - `f`: open attribute filter picker (browse span-attribute keys → values for the current service; `backspace` walks back to keys; `esc` in the trace list clears the active filter)
151
156
  - `a`: pause or resume auto-refresh
152
157
  - `r`: refresh now
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/motel",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  noticeAtom,
16
16
  persistSelectedTheme,
17
17
  selectedThemeAtom,
18
+ waterfallFilterModeAtom,
19
+ waterfallFilterTextAtom,
18
20
  } from "./ui/state.ts"
19
21
  import { applyTheme, colors, SEPARATOR, themeLabel } from "./ui/theme.ts"
20
22
  import { getVisibleSpans } from "./ui/Waterfall.tsx"
@@ -57,6 +59,8 @@ export const App = () => {
57
59
  const [pickerInput] = useAtom(attrPickerInputAtom)
58
60
  const [pickerIndex] = useAtom(attrPickerIndexAtom)
59
61
  const [attrFacets] = useAtom(attrFacetStateAtom)
62
+ const [waterfallFilterMode] = useAtom(waterfallFilterModeAtom)
63
+ const [waterfallFilterText] = useAtom(waterfallFilterTextAtom)
60
64
  useAttrFilterPicker(activeAttrKey)
61
65
 
62
66
  const layout = useAppLayout({ width, height, notice, detailView, selectedSpanIndex })
@@ -179,6 +183,8 @@ export const App = () => {
179
183
  detailView={detailView}
180
184
  filterMode={filterMode}
181
185
  filterText={filterText}
186
+ waterfallFilterMode={waterfallFilterMode}
187
+ waterfallFilterText={waterfallFilterText}
182
188
  traceListProps={traceListProps}
183
189
  selectedTraceService={selectedTraceService}
184
190
  serviceLogState={serviceLogState}
package/src/domain.ts CHANGED
@@ -145,14 +145,56 @@ export const AI_ATTR_MAP = {
145
145
  responseTimestamp: "ai.response.timestamp",
146
146
  } as const
147
147
 
148
- /** Attribute keys to search across when using the `text` filter */
149
- export const AI_TEXT_SEARCH_KEYS = [
150
- "ai.prompt.messages",
148
+ /**
149
+ * Attribute keys that carry LLM prompt/response content and should be
150
+ * indexed in the span-attribute FTS table. These are the keys emitted by
151
+ * well-known LLM instrumentation conventions:
152
+ *
153
+ * - **Vercel AI SDK** (`ai.*`): rich, SDK-specific attributes captured by
154
+ * `experimental_telemetry` on `generateText` / `streamText` / `generateObject`.
155
+ * - **OpenTelemetry GenAI semantic conventions** (`gen_ai.*`): the
156
+ * cross-vendor standard. The singular `prompt`/`completion` attrs are
157
+ * deprecated in favor of event-based capture but are still emitted by
158
+ * most instrumentations, so we keep them.
159
+ * - **OpenInference** (`input.value` / `output.value`): Arize Phoenix /
160
+ * LangChain-style normalized input/output.
161
+ *
162
+ * Keys here trigger FTS indexing on insert via a trigger in TelemetryStore.
163
+ * Adding a key requires a one-time backfill; removing one leaves orphan
164
+ * FTS entries that get cleaned up on next retention pass.
165
+ */
166
+ export const AI_FTS_KEYS = [
167
+ // Vercel AI SDK
151
168
  "ai.prompt",
152
- "ai.response.text",
169
+ "ai.prompt.messages",
153
170
  "ai.prompt.tools",
171
+ "ai.prompt.toolChoice",
172
+ "ai.response.text",
173
+ "ai.response.toolCalls",
174
+ "ai.response.reasoning",
175
+ "ai.response.object",
176
+ "ai.toolCall.args",
177
+ "ai.toolCall.result",
178
+ // OpenTelemetry GenAI semantic conventions
179
+ "gen_ai.prompt",
180
+ "gen_ai.completion",
181
+ "gen_ai.input.messages",
182
+ "gen_ai.output.messages",
183
+ "gen_ai.system_instructions",
184
+ "gen_ai.tool.definitions",
185
+ "gen_ai.tool.message.content",
186
+ // OpenInference (Phoenix, LangChain, etc.)
187
+ "input.value",
188
+ "output.value",
154
189
  ] as const
155
190
 
191
+ /**
192
+ * Back-compat alias. The `text` filter on `/api/ai/calls` historically
193
+ * LIKE-searched these four keys; now FTS indexes the broader AI_FTS_KEYS
194
+ * set so the filter transparently covers more content.
195
+ */
196
+ export const AI_TEXT_SEARCH_KEYS = AI_FTS_KEYS
197
+
156
198
  const PREVIEW_LENGTH = 200
157
199
 
158
200
  export const truncatePreview = (value: string | null | undefined): string | null => {
package/src/httpApi.ts CHANGED
@@ -121,6 +121,9 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
121
121
  minDurationMs: Schema.optionalKey(Schema.Number).pipe(
122
122
  Schema.annotateKey({ description: "Only return traces slower than this threshold (milliseconds)" }),
123
123
  ),
124
+ aiText: Schema.optionalKey(Schema.String).pipe(
125
+ Schema.annotateKey({ description: "FTS match against AI prompt/response/tool content across all spans in the trace. Tokens are prefix-matched and implicitly AND'd." }),
126
+ ),
124
127
  lookback: LookbackParam,
125
128
  limit: LimitParam,
126
129
  cursor: CursorParam,
@@ -128,7 +131,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
128
131
  success: TraceSummaryList,
129
132
  })
130
133
  .annotate(OpenApi.Summary, "Search traces with filters")
131
- .annotate(OpenApi.Description, "Search compact trace summaries with filters. Use /api/traces/{traceId} for full details. Supports cursor pagination and attr.<key> filters in the query string."),
134
+ .annotate(OpenApi.Description, "Search compact trace summaries with filters. Use /api/traces/{traceId} for full details. Supports cursor pagination, attr.<key> filters in the query string, and aiText for full-text search across LLM prompt/response content."),
132
135
 
133
136
  HttpApiEndpoint.get("traceStats", "/api/traces/stats", {
134
137
  query: {
@@ -335,6 +335,7 @@ const TelemetryGroupLive = HttpApiBuilder.group(
335
335
  status: (url.searchParams.get("status") as "ok" | "error" | null) ?? null,
336
336
  minDurationMs: url.searchParams.get("minDurationMs") ? Number.parseFloat(url.searchParams.get("minDurationMs") ?? "") : null,
337
337
  attributeFilters,
338
+ aiText: url.searchParams.get("aiText"),
338
339
  limit: limit + 1,
339
340
  lookbackMinutes,
340
341
  cursorStartedAtMs: cursor?.kind === "trace" ? cursor.startedAt : undefined,
@@ -4,7 +4,7 @@ import { dirname } from "node:path"
4
4
  import { Clock, Effect, Layer, Schedule, Context } from "effect"
5
5
  import { config } from "../config.js"
6
6
  import type { AiCallDetail, AiCallSummary, FacetItem, LogItem, SpanItem, StatsItem, TraceItem, TraceSummaryItem, TraceSpanEvent, TraceSpanItem } from "../domain.js"
7
- import { AI_ATTR_MAP, AI_TEXT_SEARCH_KEYS, truncatePreview } from "../domain.js"
7
+ import { AI_ATTR_MAP, AI_FTS_KEYS, AI_TEXT_SEARCH_KEYS, truncatePreview } from "../domain.js"
8
8
  import { attributeMap, nanosToMilliseconds, parseAnyValue, spanKindLabel, spanStatusLabel, stringifyValue, type OtlpLogExportRequest, type OtlpTraceExportRequest } from "../otlp.js"
9
9
 
10
10
  interface SpanRow {
@@ -57,6 +57,13 @@ interface TraceSearch {
57
57
  readonly status?: "ok" | "error" | null
58
58
  readonly minDurationMs?: number | null
59
59
  readonly attributeFilters?: Readonly<Record<string, string>>
60
+ /**
61
+ * Full-text match against the AI prompt/response/tool attribute values
62
+ * on any span in the trace (see AI_FTS_KEYS). When set, traces are
63
+ * filtered to those containing at least one span whose indexed LLM
64
+ * content matches. Powered by span_attr_fts (FTS5).
65
+ */
66
+ readonly aiText?: string | null
60
67
  readonly lookbackMinutes?: number
61
68
  readonly limit?: number
62
69
  readonly cursorStartedAtMs?: number
@@ -571,6 +578,66 @@ export const TelemetryStoreLive = Layer.effect(
571
578
  // FTS is optional; queries will fall back to LIKE if unavailable.
572
579
  }
573
580
 
581
+ // External-content FTS5 over the subset of span_attributes.value rows
582
+ // whose key is in AI_FTS_KEYS (LLM prompts, responses, tool calls,
583
+ // etc.). External content means the inverted index is the only
584
+ // FTS storage — the value text itself continues to live once in
585
+ // span_attributes, not duplicated into the FTS table. On a 2 GB DB
586
+ // with 270 MB of prompt JSON this typically adds ~50-120 MB of
587
+ // index, turning a 500-800ms LIKE scan into a <50ms MATCH.
588
+ //
589
+ // Keys are inlined into the trigger DDL rather than looked up in a
590
+ // side table so the `WHEN` guard stays constant-cost (a subquery
591
+ // would run on every span_attributes insert — ~60/span).
592
+ let hasAttrFts = hasFts
593
+ if (hasFts) {
594
+ try {
595
+ const keyList = AI_FTS_KEYS.map((k) => `'${k.replace(/'/g, "''")}'`).join(", ")
596
+ db.exec(`
597
+ CREATE VIRTUAL TABLE IF NOT EXISTS span_attr_fts USING fts5(
598
+ value,
599
+ content='span_attributes',
600
+ content_rowid='rowid',
601
+ tokenize='unicode61 remove_diacritics 2'
602
+ );
603
+
604
+ -- Mirror inserts into FTS when the key carries LLM content.
605
+ -- NOTE: triggers MUST use fully-qualified name (new.rowid,
606
+ -- new.value) and emit rowid so external-content FTS can
607
+ -- fetch the value back via span_attributes.rowid.
608
+ CREATE TRIGGER IF NOT EXISTS span_attr_fts_ai AFTER INSERT ON span_attributes
609
+ WHEN new.key IN (${keyList})
610
+ BEGIN
611
+ INSERT INTO span_attr_fts(rowid, value) VALUES (new.rowid, new.value);
612
+ END;
613
+
614
+ -- Delete with the same guard so retention & re-ingest stay
615
+ -- in sync. External-content 'delete' command needs the
616
+ -- original value to remove from the inverted index.
617
+ CREATE TRIGGER IF NOT EXISTS span_attr_fts_ad AFTER DELETE ON span_attributes
618
+ WHEN old.key IN (${keyList})
619
+ BEGIN
620
+ INSERT INTO span_attr_fts(span_attr_fts, rowid, value)
621
+ VALUES ('delete', old.rowid, old.value);
622
+ END;
623
+
624
+ -- Handle in-place updates (rare; re-ingest usually goes
625
+ -- DELETE then INSERT but belt-and-braces).
626
+ CREATE TRIGGER IF NOT EXISTS span_attr_fts_au AFTER UPDATE ON span_attributes
627
+ WHEN old.key IN (${keyList}) OR new.key IN (${keyList})
628
+ BEGIN
629
+ INSERT INTO span_attr_fts(span_attr_fts, rowid, value)
630
+ VALUES ('delete', old.rowid, old.value);
631
+ INSERT INTO span_attr_fts(rowid, value)
632
+ SELECT new.rowid, new.value
633
+ WHERE new.key IN (${keyList});
634
+ END;
635
+ `)
636
+ } catch {
637
+ hasAttrFts = false
638
+ }
639
+ }
640
+
574
641
  try {
575
642
  db.exec(`ALTER TABLE trace_summaries ADD COLUMN active_span_count INTEGER NOT NULL DEFAULT 0`)
576
643
  } catch {
@@ -741,6 +808,37 @@ export const TelemetryStoreLive = Layer.effect(
741
808
  })
742
809
  yield* Effect.forkScoped(Effect.repeat(refreshPlannerStats, Schedule.spaced("15 minutes")))
743
810
 
811
+ // One-time backfill for existing DBs: if span_attr_fts is empty but
812
+ // span_attributes has rows with AI_FTS_KEYS, populate the index.
813
+ // Runs forked so server startup isn't blocked; queries hitting the
814
+ // FTS will just return empty until the fill lands. On a 2 GB DB with
815
+ // ~400 matching rows this takes ~3-8 seconds.
816
+ if (hasAttrFts) {
817
+ const backfillAttrFts = Effect.sync(() => {
818
+ try {
819
+ const ftsCount = (db.query(`SELECT COUNT(*) AS c FROM span_attr_fts`).get() as { c: number }).c
820
+ if (ftsCount > 0) return
821
+ const keyList = AI_FTS_KEYS.map((k) => `'${k.replace(/'/g, "''")}'`).join(", ")
822
+ const attrCount = (db.query(
823
+ `SELECT COUNT(*) AS c FROM span_attributes WHERE key IN (${keyList})`,
824
+ ).get() as { c: number }).c
825
+ if (attrCount === 0) return
826
+ // Single INSERT..SELECT is atomic and fast; FTS5 batches
827
+ // its internal segment writes. No transaction wrapper
828
+ // needed — it runs as one statement.
829
+ db.exec(`
830
+ INSERT INTO span_attr_fts(rowid, value)
831
+ SELECT rowid, value FROM span_attributes WHERE key IN (${keyList})
832
+ `)
833
+ } catch {
834
+ // Backfill failure is never fatal — new ingests still
835
+ // populate FTS via the trigger, and queries fall back to
836
+ // LIKE when FTS lookups return empty.
837
+ }
838
+ })
839
+ yield* Effect.forkScoped(backfillAttrFts)
840
+ }
841
+
744
842
  const ingestTraces = Effect.fn("motel/TelemetryStore.ingestTraces")(function* (payload: OtlpTraceExportRequest) {
745
843
  return yield* Effect.sync(() => {
746
844
  let insertedSpans = 0
@@ -965,6 +1063,25 @@ export const TelemetryStoreLive = Layer.effect(
965
1063
  params.push(...exactAttrMatch.params)
966
1064
  }
967
1065
 
1066
+ // `:ai <query>` — FTS match against LLM content keys. Joins
1067
+ // span_attr_fts back to span_attributes to collect trace_ids
1068
+ // whose spans carry matching prompt/response content. Falls
1069
+ // through to no-op when the query tokenizes empty (e.g. only
1070
+ // stopwords or operator-chars) so users don't get a silently
1071
+ // empty list.
1072
+ if (input.aiText) {
1073
+ const aiFtsQuery = toFtsMatchQuery(input.aiText)
1074
+ if (hasAttrFts && aiFtsQuery) {
1075
+ clauses.push(`trace_id IN (
1076
+ SELECT DISTINCT sa.trace_id
1077
+ FROM span_attr_fts fts
1078
+ JOIN span_attributes sa ON sa.rowid = fts.rowid
1079
+ WHERE fts.value MATCH ?
1080
+ )`)
1081
+ params.push(aiFtsQuery)
1082
+ }
1083
+ }
1084
+
968
1085
  const rows = db.query(`
969
1086
  SELECT trace_id, service_name, root_operation_name, started_at_ms, ended_at_ms, active_span_count, duration_ms, span_count, error_count
970
1087
  FROM trace_summaries
@@ -1624,11 +1741,27 @@ export const TelemetryStoreLive = Layer.effect(
1624
1741
  params.push(key, value)
1625
1742
  }
1626
1743
 
1627
- // Text search across prompt/response/tool attribute values
1744
+ // Text search across prompt/response/tool attribute values via
1745
+ // FTS5. Prefers the external-content span_attr_fts index when
1746
+ // available, falls back to case-insensitive LIKE so old DBs
1747
+ // without FTS still work. FTS turns ~500ms full scans of 3 MB
1748
+ // prompt JSON into <50ms MATCH lookups.
1628
1749
  if ("text" in input && input.text) {
1629
- const textKeys = AI_TEXT_SEARCH_KEYS.map(() => "?").join(", ")
1630
- clauses.push(`EXISTS (SELECT 1 FROM span_attributes WHERE span_attributes.trace_id = s.trace_id AND span_attributes.span_id = s.span_id AND key IN (${textKeys}) AND value LIKE ? COLLATE NOCASE)`)
1631
- params.push(...AI_TEXT_SEARCH_KEYS, `%${input.text}%`)
1750
+ const ftsQuery = toFtsMatchQuery(input.text)
1751
+ if (hasAttrFts && ftsQuery) {
1752
+ clauses.push(`EXISTS (
1753
+ SELECT 1 FROM span_attr_fts fts
1754
+ JOIN span_attributes sa ON sa.rowid = fts.rowid
1755
+ WHERE sa.trace_id = s.trace_id
1756
+ AND sa.span_id = s.span_id
1757
+ AND fts.value MATCH ?
1758
+ )`)
1759
+ params.push(ftsQuery)
1760
+ } else {
1761
+ const textKeys = AI_TEXT_SEARCH_KEYS.map(() => "?").join(", ")
1762
+ clauses.push(`EXISTS (SELECT 1 FROM span_attributes WHERE span_attributes.trace_id = s.trace_id AND span_attributes.span_id = s.span_id AND key IN (${textKeys}) AND value LIKE ? COLLATE NOCASE)`)
1763
+ params.push(...AI_TEXT_SEARCH_KEYS, `%${input.text}%`)
1764
+ }
1632
1765
  }
1633
1766
 
1634
1767
  return { clauses, params }
@@ -8,7 +8,7 @@ export class TraceQueryService extends Context.Service<
8
8
  readonly listServices: Effect.Effect<readonly string[], Error>
9
9
  readonly listRecentTraces: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly TraceItem[], Error>
10
10
  readonly listTraceSummaries: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly TraceSummaryItem[], Error>
11
- readonly searchTraceSummaries: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly TraceSummaryItem[], Error>
11
+ readonly searchTraceSummaries: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>>; readonly aiText?: string | null }) => Effect.Effect<readonly TraceSummaryItem[], Error>
12
12
  readonly listFacets: (input: { readonly type: "traces" | "logs"; readonly field: string; readonly serviceName?: string | null; readonly key?: string | null; readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly { readonly value: string; readonly count: number }[], Error>
13
13
  readonly searchTraces: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly TraceItem[], Error>
14
14
  readonly traceStats: (input: { readonly groupBy: string; readonly agg: "count" | "avg_duration" | "p95_duration" | "error_rate"; readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
@@ -651,6 +651,39 @@ describe("motel telemetry store", () => {
651
651
  expect(result[0]?.spanId).toBe("ai-stream-1")
652
652
  })
653
653
 
654
+ it("matches AI calls via words in the response text", async () => {
655
+ // Verifies FTS indexes ai.response.text, not just ai.prompt*. The
656
+ // seeded ai-stream-2 has response "Error: rate limited".
657
+ const result = await storeRuntime.runPromise(
658
+ Effect.flatMap(TelemetryStore.asEffect(), (store) =>
659
+ store.searchAiCalls({ text: "rate limited" }),
660
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
661
+ )
662
+ expect(result.map((r) => r.spanId)).toContain("ai-stream-2")
663
+ })
664
+
665
+ it("matches AI calls case-insensitively and with partial words", async () => {
666
+ // unicode61 tokenizer is case-insensitive by default; prefix `*`
667
+ // handles partial terms like `"PROG"` matching `"programming"`.
668
+ const result = await storeRuntime.runPromise(
669
+ Effect.flatMap(TelemetryStore.asEffect(), (store) =>
670
+ store.searchAiCalls({ text: "PROG" }),
671
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
672
+ )
673
+ expect(result.map((r) => r.spanId)).toContain("ai-stream-1")
674
+ })
675
+
676
+ it("ignores FTS special characters without syntax errors", async () => {
677
+ // FTS5 treats `"`, `*`, `-`, `:` as operators; toFtsQuery must
678
+ // strip them so raw user input never crashes the query.
679
+ const result = await storeRuntime.runPromise(
680
+ Effect.flatMap(TelemetryStore.asEffect(), (store) =>
681
+ store.searchAiCalls({ text: `"joke" - about:programming*` }),
682
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
683
+ )
684
+ expect(result.map((r) => r.spanId)).toContain("ai-stream-1")
685
+ })
686
+
654
687
  it("filters AI calls by operation type", async () => {
655
688
  const result = await storeRuntime.runPromise(
656
689
  Effect.flatMap(TelemetryStore.asEffect(), (store) =>
@@ -1,8 +1,9 @@
1
1
  import { useMemo } from "react"
2
2
  import type { TraceItem, TraceSummaryItem } from "../domain.ts"
3
3
  import { formatDuration, formatShortDate, formatTimestamp } from "./format.ts"
4
- import { AlignedHeaderLine, Divider, PlainLine, TextLine } from "./primitives.tsx"
4
+ import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./primitives.tsx"
5
5
  import { getVisibleSpans, WaterfallTimeline } from "./Waterfall.tsx"
6
+ import { computeMatchingSpanIds } from "./waterfallFilter.ts"
6
7
  import type { LoadStatus, LogState } from "./state.ts"
7
8
  import { colors, SEPARATOR } from "./theme.ts"
8
9
 
@@ -30,6 +31,8 @@ export const TraceDetailsPane = ({
30
31
  collapsedSpanIds,
31
32
  focused = false,
32
33
  onSelectSpan,
34
+ waterfallFilterMode,
35
+ waterfallFilterText,
33
36
  }: {
34
37
  trace: TraceItem | null
35
38
  traceSummary: TraceSummaryItem | null
@@ -43,6 +46,8 @@ export const TraceDetailsPane = ({
43
46
  collapsedSpanIds: ReadonlySet<string>
44
47
  focused?: boolean
45
48
  onSelectSpan: (index: number) => void
49
+ waterfallFilterMode: boolean
50
+ waterfallFilterText: string
46
51
  }) => {
47
52
  const filteredSpans = useMemo(
48
53
  () => trace ? getVisibleSpans(trace.spans, collapsedSpanIds) : [],
@@ -62,6 +67,15 @@ export const TraceDetailsPane = ({
62
67
  () => selectedSpan ? traceLogsState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
63
68
  [selectedSpan, traceLogsState.data],
64
69
  )
70
+ const matchingSpanIds = useMemo(
71
+ () => trace ? computeMatchingSpanIds(trace.spans, waterfallFilterText) : null,
72
+ [trace, waterfallFilterText],
73
+ )
74
+ const matchCount = matchingSpanIds?.size ?? 0
75
+ // Reserve 1 row for the filter bar when it's being shown so the
76
+ // waterfall doesn't spill into the footer.
77
+ const showFilterBar = waterfallFilterMode || waterfallFilterText.length > 0
78
+ const waterfallBodyLines = showFilterBar ? Math.max(1, bodyLines - 1) : bodyLines
65
79
 
66
80
  const traceMeta = trace ?? traceSummary
67
81
  const hasTraceSelection = traceSummary !== null
@@ -112,6 +126,22 @@ export const TraceDetailsPane = ({
112
126
  </TextLine>
113
127
  </box>
114
128
  <Divider width={paneWidth} />
129
+ {showFilterBar ? (
130
+ <box paddingLeft={1} paddingRight={1}>
131
+ {waterfallFilterMode ? (
132
+ <FilterBar text={waterfallFilterText} width={contentWidth} />
133
+ ) : (
134
+ <TextLine>
135
+ <span fg={colors.muted}>{"/"}</span>
136
+ <span fg={colors.text}>{waterfallFilterText}</span>
137
+ <span fg={colors.separator}>{SEPARATOR}</span>
138
+ <span fg={colors.count}>{matchCount} match{matchCount === 1 ? "" : "es"}</span>
139
+ <span fg={colors.separator}>{SEPARATOR}</span>
140
+ <span fg={colors.muted}>esc clear</span>
141
+ </TextLine>
142
+ )}
143
+ </box>
144
+ ) : null}
115
145
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
116
146
  <WaterfallTimeline
117
147
  trace={trace}
@@ -119,9 +149,10 @@ export const TraceDetailsPane = ({
119
149
  spanLogCounts={spanLogCounts}
120
150
  selectedSpanLogs={selectedSpanLogs}
121
151
  contentWidth={contentWidth}
122
- bodyLines={bodyLines}
152
+ bodyLines={waterfallBodyLines}
123
153
  selectedSpanIndex={selectedSpanIndex}
124
154
  collapsedSpanIds={collapsedSpanIds}
155
+ matchingSpanIds={matchingSpanIds}
125
156
  onSelectSpan={onSelectSpan}
126
157
  />
127
158
  </box>
@@ -251,6 +251,7 @@ const WaterfallRow = memo(({
251
251
  collapsed,
252
252
  hasChildSpans,
253
253
  suffixMetrics,
254
+ dimmed,
254
255
  onSelect,
255
256
  }: {
256
257
  span: TraceSpanItem
@@ -262,6 +263,7 @@ const WaterfallRow = memo(({
262
263
  collapsed: boolean
263
264
  hasChildSpans: boolean
264
265
  suffixMetrics: WaterfallSuffixMetrics
266
+ dimmed: boolean
265
267
  onSelect: () => void
266
268
  }) => {
267
269
  const prefix = buildTreePrefix(spans, index)
@@ -282,12 +284,23 @@ const WaterfallRow = memo(({
282
284
  const rowBg = selected ? colors.selectedBg : colors.screenBg
283
285
  const { segments } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor, rowBg)
284
286
  const bg = selected ? colors.selectedBg : undefined
285
- const treeColor = selected ? colors.separator : colors.treeLine
286
- const indicatorColor = isError ? colors.error : hasChildSpans ? (selected ? colors.selectedText : colors.muted) : colors.passing
287
- const opColor = selected ? colors.selectedText : span.isRunning ? colors.warning : colors.text
288
-
289
- const durationFg = durationColor(span.durationMs)
290
- const unitFg = colors.muted
287
+ // Dimmed rows (non-matching under an active waterfall filter) collapse
288
+ // their palette to the muted separator color so matches stand out.
289
+ // Selection always wins the selected row keeps its full brightness
290
+ // so you can still see where the cursor is while scanning.
291
+ const treeColor = selected ? colors.separator : dimmed ? colors.separator : colors.treeLine
292
+ const indicatorColor = selected ? colors.selectedText
293
+ : dimmed ? colors.separator
294
+ : isError ? colors.error
295
+ : hasChildSpans ? colors.muted
296
+ : colors.passing
297
+ const opColor = selected ? colors.selectedText
298
+ : dimmed ? colors.separator
299
+ : span.isRunning ? colors.warning
300
+ : colors.text
301
+
302
+ const durationFg = selected ? colors.selectedText : dimmed ? colors.separator : durationColor(span.durationMs)
303
+ const unitFg = dimmed && !selected ? colors.separator : colors.muted
291
304
 
292
305
  // Split the duration so the unit (s/ms) renders dimmer than the number.
293
306
  const { number: durNumber, unit: durUnit } = splitDuration(Math.max(0, span.durationMs))
@@ -373,6 +386,7 @@ export const WaterfallTimeline = ({
373
386
  bodyLines,
374
387
  selectedSpanIndex,
375
388
  collapsedSpanIds,
389
+ matchingSpanIds,
376
390
  onSelectSpan,
377
391
  }: {
378
392
  trace: TraceItem
@@ -383,6 +397,11 @@ export const WaterfallTimeline = ({
383
397
  bodyLines: number
384
398
  selectedSpanIndex: number | null
385
399
  collapsedSpanIds: ReadonlySet<string>
400
+ /**
401
+ * When set, spans whose spanId is NOT in this set are dimmed. Null
402
+ * means no filter active — skip the per-row lookup entirely.
403
+ */
404
+ matchingSpanIds?: ReadonlySet<string> | null
386
405
  onSelectSpan: (index: number) => void
387
406
  }) => {
388
407
  const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
@@ -392,29 +411,37 @@ export const WaterfallTimeline = ({
392
411
  spanIndexById.set(trace.spans[i].spanId, i)
393
412
  }
394
413
 
395
- // Virtual windowing: only render visible rows, shift window only when
396
- // the selection would go out of view (no jerkiness).
414
+ // Virtual windowing: only render visible rows. We track scroll offset
415
+ // as state so the mouse wheel can scroll the window INDEPENDENTLY of
416
+ // the selected span (mirrors TraceList behavior). Selection still
417
+ // follows: if the user moves selection off-screen via j/k, we nudge
418
+ // the window to keep it visible — but wheel-scrolling never changes
419
+ // selection, only clicking a row does.
397
420
  const viewportSize = Math.max(1, bodyLines)
398
- const scrollOffsetRef = useRef(0)
421
+ const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
422
+ const [scrollOffset, setScrollOffset] = useState(0)
399
423
  const lastTraceIdRef = useRef<string | null>(null)
400
424
 
401
- // Reset scroll offset when the trace changes
425
+ // Reset scroll offset when the trace changes.
402
426
  if (trace.traceId !== lastTraceIdRef.current) {
403
- scrollOffsetRef.current = 0
427
+ setScrollOffset(0)
404
428
  lastTraceIdRef.current = trace.traceId
405
429
  }
406
430
 
407
- // Only shift the window when the selection would be outside it
408
- if (selectedSpanIndex !== null) {
409
- if (selectedSpanIndex < scrollOffsetRef.current) {
410
- scrollOffsetRef.current = selectedSpanIndex
411
- } else if (selectedSpanIndex >= scrollOffsetRef.current + viewportSize) {
412
- scrollOffsetRef.current = selectedSpanIndex - viewportSize + 1
413
- }
414
- }
415
- scrollOffsetRef.current = Math.max(0, Math.min(scrollOffsetRef.current, Math.max(0, filteredSpans.length - viewportSize)))
416
-
417
- const windowStart = scrollOffsetRef.current
431
+ // Auto-follow selection: only if the selected span would be hidden
432
+ // by the current window, shift just enough to bring it back. Runs in
433
+ // layout effect so the visible window is accurate on the same paint
434
+ // that the selection changed.
435
+ useLayoutEffect(() => {
436
+ if (selectedSpanIndex === null) return
437
+ setScrollOffset((current) => {
438
+ if (selectedSpanIndex < current) return selectedSpanIndex
439
+ if (selectedSpanIndex >= current + viewportSize) return selectedSpanIndex - viewportSize + 1
440
+ return current
441
+ })
442
+ }, [selectedSpanIndex, viewportSize])
443
+
444
+ const windowStart = Math.max(0, Math.min(scrollOffset, maxOffset))
418
445
  const windowSpans = filteredSpans.slice(windowStart, windowStart + viewportSize)
419
446
  const blankCount = Math.max(0, viewportSize - windowSpans.length)
420
447
 
@@ -422,19 +449,17 @@ export const WaterfallTimeline = ({
422
449
  // row's duration cell lines up on the same right-edge column.
423
450
  const suffixMetrics = getWaterfallSuffixMetrics(windowSpans)
424
451
 
425
- // Mouse wheel moves the span selection by the scroll delta. The waterfall
426
- // uses virtual windowing (not a scrollbox) so native scroll does nothing;
427
- // we convert wheel events into selection moves, which the windowing code
428
- // already translates into visible-viewport shifts.
452
+ // Mouse wheel scrolls the window without touching selection matches
453
+ // the trace list, so the user can browse ahead of their cursor freely
454
+ // and click a row to commit. Delta is scaled 1:1 with opentui's wheel
455
+ // reporting (1 notch 3 rows on most terminals).
429
456
  const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
430
457
  const info = event.scroll
431
458
  if (!info || filteredSpans.length === 0) return
432
459
  const magnitude = Math.max(1, Math.round(info.delta))
433
460
  const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
434
461
  if (signed === 0) return
435
- const start = selectedSpanIndex ?? 0
436
- const next = Math.max(0, Math.min(start + signed, filteredSpans.length - 1))
437
- if (next !== selectedSpanIndex) onSelectSpan(next)
462
+ setScrollOffset((current) => Math.max(0, Math.min(current + signed, maxOffset)))
438
463
  event.stopPropagation?.()
439
464
  }
440
465
 
@@ -443,6 +468,7 @@ export const WaterfallTimeline = ({
443
468
  {windowSpans.map((span, index) => {
444
469
  const actualIndex = windowStart + index
445
470
  const fullIndex = spanIndexById.get(span.spanId) ?? -1
471
+ const dimmed = matchingSpanIds != null && !matchingSpanIds.has(span.spanId)
446
472
  return (
447
473
  <WaterfallRow
448
474
  key={`${trace.traceId}-${span.spanId}`}
@@ -455,6 +481,7 @@ export const WaterfallTimeline = ({
455
481
  collapsed={collapsedSpanIds.has(span.spanId)}
456
482
  hasChildSpans={fullIndex >= 0 && findFirstChildIndex(trace.spans, fullIndex) !== null}
457
483
  suffixMetrics={suffixMetrics}
484
+ dimmed={dimmed}
458
485
  onSelect={() => onSelectSpan(actualIndex)}
459
486
  />
460
487
  )