@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.
@@ -18,6 +18,8 @@ interface TraceWorkspaceProps {
18
18
  readonly detailView: DetailView
19
19
  readonly filterMode: boolean
20
20
  readonly filterText: string
21
+ readonly waterfallFilterMode: boolean
22
+ readonly waterfallFilterText: string
21
23
  readonly traceListProps: TraceListProps
22
24
  readonly selectedTraceService: string | null
23
25
  readonly serviceLogState: ServiceLogState
@@ -40,6 +42,8 @@ export const TraceWorkspace = ({
40
42
  detailView,
41
43
  filterMode,
42
44
  filterText,
45
+ waterfallFilterMode,
46
+ waterfallFilterText,
43
47
  traceListProps,
44
48
  selectedTraceService,
45
49
  serviceLogState,
@@ -132,7 +136,7 @@ export const TraceWorkspace = ({
132
136
  selectedSpanIndex={selectedSpanIndex}
133
137
  collapsedSpanIds={collapsedSpanIds}
134
138
  focused={false}
135
- onSelectSpan={selectSpan}
139
+ waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
136
140
  />
137
141
  </box>
138
142
  </box>
@@ -159,7 +163,7 @@ export const TraceWorkspace = ({
159
163
  selectedSpanIndex={selectedSpanIndex}
160
164
  collapsedSpanIds={collapsedSpanIds}
161
165
  focused={true}
162
- onSelectSpan={selectSpan}
166
+ waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
163
167
  />
164
168
  </box>
165
169
  </box>
@@ -182,7 +186,7 @@ export const TraceWorkspace = ({
182
186
  selectedSpanIndex={selectedSpanIndex}
183
187
  collapsedSpanIds={collapsedSpanIds}
184
188
  focused={false}
185
- onSelectSpan={selectSpan}
189
+ waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
186
190
  />
187
191
  </box>
188
192
  <SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
@@ -226,7 +230,7 @@ export const TraceWorkspace = ({
226
230
  selectedSpanIndex={selectedSpanIndex}
227
231
  collapsedSpanIds={collapsedSpanIds}
228
232
  focused={false}
229
- onSelectSpan={selectSpan}
233
+ waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
230
234
  />
231
235
  </>
232
236
  )
@@ -265,7 +269,7 @@ export const TraceWorkspace = ({
265
269
  selectedSpanIndex={selectedSpanIndex}
266
270
  collapsedSpanIds={collapsedSpanIds}
267
271
  focused={true}
268
- onSelectSpan={selectSpan}
272
+ waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
269
273
  />
270
274
  ) : (
271
275
  <SpanDetailPane
@@ -1,5 +1,5 @@
1
1
  import { useAtom } from "@effect/atom-react"
2
- import { useCallback, useEffect, useMemo, useRef } from "react"
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
3
3
  import { config } from "../../config.js"
4
4
  import type { LogItem, TraceItem } from "../../domain.ts"
5
5
  import {
@@ -32,6 +32,7 @@ import {
32
32
  traceSortAtom,
33
33
  traceStateAtom,
34
34
  } from "../state.ts"
35
+ import { parseFilterText } from "../filterParser.ts"
35
36
  import { getVisibleSpans } from "../Waterfall.tsx"
36
37
 
37
38
  export const useTraceScreenData = () => {
@@ -54,6 +55,17 @@ export const useTraceScreenData = () => {
54
55
  const [activeAttrValue] = useAtom(activeAttrValueAtom)
55
56
  const [traceSort] = useAtom(traceSortAtom)
56
57
 
58
+ // `:ai <query>` is parsed out of the filter text and debounced so
59
+ // typing doesn't hammer FTS. The other modifiers (:error, operation
60
+ // needle) stay client-side since we already have those on trace
61
+ // summaries. 250ms feels responsive without firing on every keystroke.
62
+ const parsedFilter = useMemo(() => parseFilterText(filterText), [filterText])
63
+ const [debouncedAiText, setDebouncedAiText] = useState<string | null>(parsedFilter.aiText)
64
+ useEffect(() => {
65
+ const handle = setTimeout(() => setDebouncedAiText(parsedFilter.aiText), 250)
66
+ return () => clearTimeout(handle)
67
+ }, [parsedFilter.aiText])
68
+
57
69
  const selectedTraceRef = useRef<string | null>(null)
58
70
  const cacheEpochRef = useRef(0)
59
71
  const traceDetailCacheRef = useRef(new Map<string, { data: TraceItem | null; fetchedAt: Date }>())
@@ -99,9 +111,18 @@ export const useTraceScreenData = () => {
99
111
  setSelectedTraceService(effectiveService)
100
112
  }
101
113
 
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)
102
120
  const traces = effectiveService
103
- ? (activeAttrKey && activeAttrValue
104
- ? await loadFilteredTraceSummaries(effectiveService, { [activeAttrKey]: activeAttrValue })
121
+ ? (hasAttrFilter || hasAiFilter
122
+ ? await loadFilteredTraceSummaries(effectiveService, {
123
+ attributeFilters: hasAttrFilter ? { [activeAttrKey as string]: activeAttrValue as string } : undefined,
124
+ aiText: hasAiFilter ? debouncedAiText : null,
125
+ })
105
126
  : await loadRecentTraceSummaries(effectiveService))
106
127
  : []
107
128
  if (cancelled) return
@@ -126,7 +147,7 @@ export const useTraceScreenData = () => {
126
147
  return () => {
127
148
  cancelled = true
128
149
  }
129
- }, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
150
+ }, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, debouncedAiText, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
130
151
 
131
152
  useEffect(() => {
132
153
  setSelectedTraceIndex((current) => {
@@ -359,13 +380,14 @@ export const useTraceScreenData = () => {
359
380
  })
360
381
  }, [serviceLogState.data.length, setSelectedServiceLogIndex])
361
382
 
383
+ // Client-side filters: `:error` + operation-name needle both run
384
+ // against already-loaded summaries (no server round-trip). The `:ai`
385
+ // query, by contrast, is applied server-side in the load effect
386
+ // above so we don't need to re-filter it here.
362
387
  const preFilterTraces = filterText
363
388
  ? traceState.data.filter((trace) => {
364
- const needle = filterText.toLowerCase()
365
- const errorOnly = needle.includes(":error")
366
- const textNeedle = needle.replace(":error", "").trim()
367
- if (errorOnly && trace.errorCount === 0) return false
368
- if (textNeedle && !trace.rootOperationName.toLowerCase().includes(textNeedle)) return false
389
+ if (parsedFilter.errorOnly && trace.errorCount === 0) return false
390
+ if (parsedFilter.operationNeedle && !trace.rootOperationName.toLowerCase().includes(parsedFilter.operationNeedle)) return false
369
391
  return true
370
392
  })
371
393
  : traceState.data
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { parseFilterText } from "./filterParser.ts"
3
+
4
+ describe("parseFilterText", () => {
5
+ it("returns empty state for empty input", () => {
6
+ expect(parseFilterText("")).toEqual({ aiText: null, errorOnly: false, operationNeedle: "" })
7
+ })
8
+
9
+ it("extracts a bare operation-name needle", () => {
10
+ expect(parseFilterText("streamText")).toEqual({
11
+ aiText: null, errorOnly: false, operationNeedle: "streamtext",
12
+ })
13
+ })
14
+
15
+ it("recognizes :error modifier", () => {
16
+ expect(parseFilterText(":error")).toEqual({
17
+ aiText: null, errorOnly: true, operationNeedle: "",
18
+ })
19
+ })
20
+
21
+ it("composes :error with an operation needle", () => {
22
+ expect(parseFilterText("llm :error")).toEqual({
23
+ aiText: null, errorOnly: true, operationNeedle: "llm",
24
+ })
25
+ })
26
+
27
+ it("extracts :ai query up to end of string", () => {
28
+ expect(parseFilterText(":ai rate limit")).toEqual({
29
+ aiText: "rate limit", errorOnly: false, operationNeedle: "",
30
+ })
31
+ })
32
+
33
+ it("extracts :ai query stopping at the next modifier", () => {
34
+ expect(parseFilterText(":ai tool_use :error")).toEqual({
35
+ aiText: "tool_use", errorOnly: true, operationNeedle: "",
36
+ })
37
+ })
38
+
39
+ it("composes operation needle + :ai + :error", () => {
40
+ expect(parseFilterText("stream :ai rate :error")).toEqual({
41
+ aiText: "rate", errorOnly: true, operationNeedle: "stream",
42
+ })
43
+ })
44
+
45
+ it("ignores :ai with empty query", () => {
46
+ expect(parseFilterText(":ai ")).toEqual({
47
+ aiText: null, errorOnly: false, operationNeedle: "",
48
+ })
49
+ })
50
+
51
+ it("is case-insensitive for modifiers", () => {
52
+ expect(parseFilterText(":AI foo :ERROR")).toEqual({
53
+ aiText: "foo", errorOnly: true, operationNeedle: "",
54
+ })
55
+ })
56
+ })
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Parses the `/` filter input in the trace list into its component
3
+ * modifiers. Supports composable tokens:
4
+ *
5
+ * - `:error` — restricts to traces with at least one failed span
6
+ * - `:ai <query...>` — FTS-backed search against LLM prompt/response
7
+ * content (AI_FTS_KEYS) across every span in the
8
+ * trace. The query runs up to the next `:modifier`
9
+ * or end of input, so `"/ :ai rate limit :error"`
10
+ * passes `"rate limit"` as the aiText and also
11
+ * sets errorOnly.
12
+ * - bare text — case-insensitive substring match against the
13
+ * trace's root operation name (client-side)
14
+ *
15
+ * Keep this a pure function: it's unit-tested separately and the React
16
+ * hook just calls it per render.
17
+ */
18
+ export interface ParsedFilter {
19
+ readonly aiText: string | null
20
+ readonly errorOnly: boolean
21
+ readonly operationNeedle: string
22
+ }
23
+
24
+ export const parseFilterText = (raw: string): ParsedFilter => {
25
+ const text = raw ?? ""
26
+
27
+ // `:ai <query>` — greedy up to the next `:` modifier or end. The
28
+ // leading `(^|\s)` guard prevents matching a stray `:ai` glued to
29
+ // non-space characters (shouldn't happen in practice but cheap).
30
+ const aiMatch = text.match(/(?:^|\s):ai\s+([^:]*?)(?=\s:|$)/i)
31
+ const aiText = aiMatch?.[1]?.trim() || null
32
+
33
+ const errorOnly = /(?:^|\s):error(?=\s|$)/i.test(text)
34
+
35
+ // Whatever remains after removing the recognized modifiers becomes the
36
+ // operation-name needle. Lowercased here so the call site can do a
37
+ // plain includes() without re-lowercasing.
38
+ const operationNeedle = text
39
+ .replace(/(?:^|\s):ai\s+[^:]*?(?=\s:|$)/i, " ")
40
+ .replace(/(?:^|\s):error(?=\s|$)/i, " ")
41
+ .trim()
42
+ .toLowerCase()
43
+
44
+ return { aiText, errorOnly, operationNeedle }
45
+ }
@@ -80,13 +80,25 @@ export const SeparatorColumn = ({ height, junctionChars }: { height: number; jun
80
80
  )
81
81
  }
82
82
 
83
- export const FilterBar = ({ text, width }: { text: string; width: number }) => (
84
- <TextLine fg={colors.accent}>
85
- <span fg={colors.muted}>{"/"}</span>
86
- <span fg={colors.text}>{fitCell(text, width - 2)}</span>
87
- <span fg={colors.accent}>{"\u2588"}</span>
88
- </TextLine>
89
- )
83
+ export const FilterBar = ({ text, width }: { text: string; width: number }) => {
84
+ // Layout: "/<text>█ op name · :error · :ai <query>"
85
+ // Cursor block sits immediately after the typed text (no padding —
86
+ // fitCell would pad the empty string to fill the available width and
87
+ // push the cursor to the far right, which looked like a bug). The
88
+ // hint only renders while the input is empty so real typing never
89
+ // collides with it.
90
+ const hint = text.length === 0 ? " op name · :error · :ai <query>" : ""
91
+ const textMaxWidth = Math.max(1, width - 2 - hint.length)
92
+ const displayText = text.length > textMaxWidth ? text.slice(text.length - textMaxWidth) : text
93
+ return (
94
+ <TextLine fg={colors.accent}>
95
+ <span fg={colors.muted}>{"/"}</span>
96
+ <span fg={colors.text}>{displayText}</span>
97
+ <span fg={colors.accent}>{"\u2588"}</span>
98
+ {hint ? <span fg={colors.muted}>{hint}</span> : null}
99
+ </TextLine>
100
+ )
101
+ }
90
102
 
91
103
  const FooterKey = ({ label }: { label: string }) => <span fg={colors.count} attributes={TextAttributes.BOLD}>{label}</span>
92
104
 
@@ -118,7 +130,7 @@ export const HelpModal = ({ width, height, autoRefresh, themeLabel, onClose }: {
118
130
  {row("t", `cycle theme (${themeLabel})`)}
119
131
  {row("tab", "toggle service logs")}
120
132
  {row("[ ]", "switch service")}
121
- {row("/", "filter by root operation")}
133
+ {row("/", "filter by root operation (\u2003:error, :ai <query>)")}
122
134
  {row("f", "filter traces by span attribute")}
123
135
  {row("s", "cycle sort mode")}
124
136
  {row("a", `auto refresh ${autoRefresh ? "on" : "off"}`)}
package/src/ui/state.ts CHANGED
@@ -106,6 +106,13 @@ export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
106
106
  export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
107
107
  export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
108
108
 
109
+ // Waterfall-scoped filter: the `/` key while drilled into a trace
110
+ // (viewLevel >= 1) opens this filter instead of the trace-list one.
111
+ // Purely client-side — dims spans whose operation name and attribute
112
+ // values don't contain the needle.
113
+ export const waterfallFilterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
114
+ export const waterfallFilterTextAtom = Atom.make("").pipe(Atom.keepAlive)
115
+
109
116
  // Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
110
117
  export type AttrPickerMode = "off" | "keys" | "values"
111
118
  export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
@@ -158,8 +165,31 @@ export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<s
158
165
 
159
166
  export const loadTraceServices = () => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
160
167
  export const loadRecentTraceSummaries = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
161
- export const loadFilteredTraceSummaries = (serviceName: string, attributeFilters: Readonly<Record<string, string>>) =>
162
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({ serviceName, attributeFilters, limit: config.otel.traceFetchLimit })))
168
+ /**
169
+ * Server-side trace summary search. Accepts any combination of:
170
+ *
171
+ * - `attributeFilters` — exact-match span attributes (from the `f` picker)
172
+ * - `aiText` — FTS5-backed search across LLM prompt/response
173
+ * content (AI_FTS_KEYS), from the `:ai <query>`
174
+ * modifier in the `/` filter
175
+ *
176
+ * Both filters compose: when both are set, a trace must match both. When
177
+ * neither is set, callers should prefer `loadRecentTraceSummaries` so
178
+ * the server can skip the search path entirely.
179
+ */
180
+ export const loadFilteredTraceSummaries = (
181
+ serviceName: string,
182
+ options: {
183
+ readonly attributeFilters?: Readonly<Record<string, string>>
184
+ readonly aiText?: string | null
185
+ },
186
+ ) =>
187
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({
188
+ serviceName,
189
+ attributeFilters: options.attributeFilters,
190
+ aiText: options.aiText ?? null,
191
+ limit: config.otel.traceFetchLimit,
192
+ })))
163
193
  export const loadTraceAttributeKeys = (serviceName: string) =>
164
194
  queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
165
195
  export const loadTraceAttributeValues = (serviceName: string, key: string) =>
@@ -27,11 +27,14 @@ import {
27
27
  traceSortAtom,
28
28
  type TraceSortMode,
29
29
  traceStateAtom,
30
+ waterfallFilterModeAtom,
31
+ waterfallFilterTextAtom,
30
32
  } from "./state.ts"
31
33
  import { filterFacets } from "./AttrFilterModal.tsx"
32
34
  import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
33
35
  import { cycleThemeName, themeLabel } from "./theme.ts"
34
36
  import { getVisibleSpans } from "./Waterfall.tsx"
37
+ import { computeMatchingSpanIds, findAdjacentMatch } from "./waterfallFilter.ts"
35
38
  import { resolveCollapseStep } from "./waterfallNav.ts"
36
39
 
37
40
  /**
@@ -53,6 +56,10 @@ const extractPrintable = (key: {
53
56
  readonly meta: boolean
54
57
  }): string | null => {
55
58
  if (key.ctrl || key.meta) return null
59
+ // Space arrives as `key.name === "space"` with a 1-char sequence. We
60
+ // handle it explicitly because the generic "length > 1" branch below
61
+ // only catches multi-char paste sequences, not a lone " ".
62
+ if (key.name === "space") return " "
56
63
  if (key.name.length === 1) return key.name
57
64
  const seq = key.sequence ?? ""
58
65
  // Only accept sequences that are pure printable text. Any escape or
@@ -105,6 +112,8 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
105
112
  const [attrFacets] = useAtom(attrFacetStateAtom)
106
113
  const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
107
114
  const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
115
+ const [waterfallFilterMode, setWaterfallFilterMode] = useAtom(waterfallFilterModeAtom)
116
+ const [waterfallFilterText, setWaterfallFilterText] = useAtom(waterfallFilterTextAtom)
108
117
 
109
118
  const pendingGRef = useRef(false)
110
119
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -151,12 +160,12 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
151
160
  }
152
161
  }, [renderer, setFilterText, setPickerInput, setPickerIndex])
153
162
 
154
- const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, ...params })
163
+ const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, waterfallFilterMode, waterfallFilterText, ...params })
155
164
  // Keep the keyboard handler's state mirror in sync before the next paint.
156
165
  // OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
157
166
  // rapid repeated keypresses can otherwise observe stale selection state.
158
167
  useLayoutEffect(() => {
159
- stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, ...params }
168
+ stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, waterfallFilterMode, waterfallFilterText, ...params }
160
169
  })
161
170
 
162
171
  const clearPendingG = () => {
@@ -446,6 +455,43 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
446
455
  }
447
456
  return
448
457
  }
458
+
459
+ // Waterfall filter mode: text-capture scoped to the current
460
+ // trace's spans.
461
+ // - enter → commit: close input but keep text so dimming persists
462
+ // while the user navigates. `/` can be pressed again
463
+ // to edit.
464
+ // - esc → cancel: clear text + exit input entirely.
465
+ // - ctrl-c → clear input if non-empty, otherwise exit.
466
+ if (s.waterfallFilterMode) {
467
+ if (key.name === "escape") {
468
+ setWaterfallFilterMode(false)
469
+ setWaterfallFilterText("")
470
+ return
471
+ }
472
+ if (key.ctrl && key.name === "c") {
473
+ if (s.waterfallFilterText.length > 0) {
474
+ setWaterfallFilterText("")
475
+ } else {
476
+ setWaterfallFilterMode(false)
477
+ }
478
+ return
479
+ }
480
+ if (key.name === "return" || key.name === "enter") {
481
+ setWaterfallFilterMode(false)
482
+ return
483
+ }
484
+ if (key.name === "backspace") {
485
+ setWaterfallFilterText((current) => current.slice(0, -1))
486
+ return
487
+ }
488
+ const printable = extractPrintable(key)
489
+ if (printable) {
490
+ setWaterfallFilterText((current) => current + printable)
491
+ return
492
+ }
493
+ return
494
+ }
449
495
  const plainG = key.name === "g" && !key.ctrl && !key.meta && !key.option && !key.shift
450
496
  const shiftedG = key.name === "g" && key.shift
451
497
  const questionMark = key.name === "?" || (key.name === "/" && key.shift)
@@ -524,6 +570,14 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
524
570
  setShowHelp(false)
525
571
  return
526
572
  }
573
+ // Committed waterfall filter outranks drill-back: hitting esc
574
+ // should clear the dim before jumping you out of the span
575
+ // detail pane. That keeps a single `esc` predictable whether
576
+ // the filter was applied by typing or left over from before.
577
+ if (s.waterfallFilterText.length > 0) {
578
+ setWaterfallFilterText("")
579
+ return
580
+ }
527
581
  if (s.detailView === "span-detail" || s.detailView === "service-logs") {
528
582
  setDetailView("waterfall")
529
583
  return
@@ -588,8 +642,42 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
588
642
  s.flashNotice(`Theme: ${themeLabel(nextTheme)}`)
589
643
  return
590
644
  }
645
+ // `n` / `N`: jump between matches of the committed waterfall filter.
646
+ // Only active when drilled into a trace AND the filter has text
647
+ // (committed or live — either way, there's a dim/highlight we can
648
+ // step through). Wraps at the ends like vim's /n. Plain `n` forward,
649
+ // shift-n (`N`) backward.
650
+ if ((key.name === "n" || key.name === "N") && !key.ctrl && !key.meta) {
651
+ const inWaterfall = s.detailView === "span-detail" || s.selectedSpanIndex !== null
652
+ if (inWaterfall && s.waterfallFilterText.length > 0 && s.selectedTrace) {
653
+ const visibleSpans = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds)
654
+ const matchingIds = computeMatchingSpanIds(visibleSpans, s.waterfallFilterText)
655
+ if (matchingIds && matchingIds.size > 0) {
656
+ const direction = key.name === "N" ? -1 : 1
657
+ const next = findAdjacentMatch(visibleSpans, matchingIds, s.selectedSpanIndex, direction)
658
+ if (next !== null) setSelectedSpanIndex(next)
659
+ else s.flashNotice("No matches")
660
+ } else {
661
+ s.flashNotice("No matches")
662
+ }
663
+ return
664
+ }
665
+ // Fall through when not in a trace detail view — reserves `n`
666
+ // for other future bindings without shadowing them globally.
667
+ }
668
+
591
669
  if (key.name === "/" && !key.shift) {
592
- setFilterMode(true)
670
+ // When drilled into a trace (viewLevel >= 1 — waterfall or
671
+ // span detail is the dominant pane), `/` opens a filter scoped
672
+ // to the current trace's spans instead of the trace list.
673
+ // Drill level here is inferred from selectedSpanIndex/detailView
674
+ // the same way useAppLayout does it.
675
+ const inWaterfall = s.detailView === "span-detail" || s.selectedSpanIndex !== null
676
+ if (inWaterfall) {
677
+ setWaterfallFilterMode(true)
678
+ } else {
679
+ setFilterMode(true)
680
+ }
593
681
  return
594
682
  }
595
683
  if ((key.name === "f" || key.name === "F") && !key.ctrl && !key.meta) {
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import type { TraceSpanItem } from "../domain.ts"
3
+ import { computeMatchingSpanIds, findAdjacentMatch, spanMatchesFilter } from "./waterfallFilter.ts"
4
+
5
+ const span = (spanId: string, operationName: string, tags: Record<string, string> = {}): TraceSpanItem => ({
6
+ spanId,
7
+ parentSpanId: null,
8
+ serviceName: "test",
9
+ scopeName: null,
10
+ kind: null,
11
+ operationName,
12
+ startTime: new Date(0),
13
+ isRunning: false,
14
+ durationMs: 1,
15
+ status: "ok",
16
+ depth: 0,
17
+ tags,
18
+ warnings: [],
19
+ events: [],
20
+ })
21
+
22
+ describe("spanMatchesFilter", () => {
23
+ it("matches operation name case-insensitively", () => {
24
+ expect(spanMatchesFilter(span("a", "ai.StreamText"), "stream")).toBe(true)
25
+ expect(spanMatchesFilter(span("a", "ai.StreamText"), "nope")).toBe(false)
26
+ })
27
+
28
+ it("matches tag values but not keys", () => {
29
+ expect(spanMatchesFilter(span("a", "op", { "ai.model.id": "claude" }), "claude")).toBe(true)
30
+ // Key-only match should not count, otherwise searching "ai" dims nothing.
31
+ expect(spanMatchesFilter(span("a", "op", { "ai.model.id": "claude" }), "model.id")).toBe(false)
32
+ })
33
+
34
+ it("returns true when the needle is empty", () => {
35
+ expect(spanMatchesFilter(span("a", "op"), "")).toBe(true)
36
+ })
37
+ })
38
+
39
+ describe("computeMatchingSpanIds", () => {
40
+ it("returns null for empty/whitespace filter", () => {
41
+ expect(computeMatchingSpanIds([span("a", "op")], "")).toBeNull()
42
+ expect(computeMatchingSpanIds([span("a", "op")], " ")).toBeNull()
43
+ })
44
+
45
+ it("returns only matching span ids", () => {
46
+ const spans = [span("a", "ai.streamText"), span("b", "Agent.get"), span("c", "ai.toolCall")]
47
+ const ids = computeMatchingSpanIds(spans, "ai")
48
+ expect(ids).not.toBeNull()
49
+ expect(Array.from(ids!)).toEqual(["a", "c"])
50
+ })
51
+ })
52
+
53
+ describe("findAdjacentMatch", () => {
54
+ const spans = [span("a", "one"), span("b", "two"), span("c", "three"), span("d", "four")]
55
+ const matches = new Set(["b", "d"])
56
+
57
+ it("finds next from current selection", () => {
58
+ expect(findAdjacentMatch(spans, matches, 0, 1)).toBe(1) // a -> b
59
+ expect(findAdjacentMatch(spans, matches, 1, 1)).toBe(3) // b -> d
60
+ })
61
+
62
+ it("wraps forward past the end", () => {
63
+ expect(findAdjacentMatch(spans, matches, 3, 1)).toBe(1) // d -> b (wrap)
64
+ })
65
+
66
+ it("finds previous from current selection", () => {
67
+ expect(findAdjacentMatch(spans, matches, 3, -1)).toBe(1) // d -> b
68
+ expect(findAdjacentMatch(spans, matches, 1, -1)).toBe(3) // b -> d (wrap)
69
+ })
70
+
71
+ it("starts from beginning/end when nothing is selected", () => {
72
+ expect(findAdjacentMatch(spans, matches, null, 1)).toBe(1) // forward → first match
73
+ expect(findAdjacentMatch(spans, matches, null, -1)).toBe(3) // backward → last match
74
+ })
75
+
76
+ it("returns null when there are no matches", () => {
77
+ expect(findAdjacentMatch(spans, new Set(), 0, 1)).toBeNull()
78
+ })
79
+
80
+ it("handles a single match by returning it regardless of direction", () => {
81
+ expect(findAdjacentMatch(spans, new Set(["c"]), 0, 1)).toBe(2)
82
+ expect(findAdjacentMatch(spans, new Set(["c"]), 0, -1)).toBe(2)
83
+ })
84
+ })
@@ -0,0 +1,59 @@
1
+ import type { TraceSpanItem } from "../domain.ts"
2
+
3
+ /**
4
+ * Case-insensitive substring match against a span's operation name and
5
+ * any of its tag values. Keys aren't checked — they're dotted
6
+ * identifiers the user never types, so matching them just creates false
7
+ * positives on bare tokens like "ai" or "response".
8
+ */
9
+ export const spanMatchesFilter = (span: TraceSpanItem, needle: string): boolean => {
10
+ if (!needle) return true
11
+ if (span.operationName.toLowerCase().includes(needle)) return true
12
+ for (const value of Object.values(span.tags)) {
13
+ if (typeof value === "string" && value.toLowerCase().includes(needle)) return true
14
+ }
15
+ return false
16
+ }
17
+
18
+ /**
19
+ * Compute the set of span IDs that match the given filter text. Returns
20
+ * null when the filter is empty so callers can skip dimming entirely
21
+ * (hot path during waterfall scrolling).
22
+ */
23
+ export const computeMatchingSpanIds = (
24
+ spans: readonly TraceSpanItem[],
25
+ filterText: string,
26
+ ): ReadonlySet<string> | null => {
27
+ const needle = filterText.trim().toLowerCase()
28
+ if (!needle) return null
29
+ const matches = new Set<string>()
30
+ for (const span of spans) {
31
+ if (spanMatchesFilter(span, needle)) matches.add(span.spanId)
32
+ }
33
+ return matches
34
+ }
35
+
36
+ /**
37
+ * Find the next (direction=1) or previous (direction=-1) matching span
38
+ * index in `filteredSpans` relative to `currentIndex`. Wraps around the
39
+ * ends. Returns `null` when the list has no matches at all. When
40
+ * `currentIndex` is null (nothing selected), starts from either end
41
+ * based on direction.
42
+ */
43
+ export const findAdjacentMatch = (
44
+ filteredSpans: readonly TraceSpanItem[],
45
+ matchingSpanIds: ReadonlySet<string>,
46
+ currentIndex: number | null,
47
+ direction: 1 | -1,
48
+ ): number | null => {
49
+ if (filteredSpans.length === 0 || matchingSpanIds.size === 0) return null
50
+ const start = currentIndex ?? (direction === 1 ? -1 : filteredSpans.length)
51
+ const n = filteredSpans.length
52
+ // Walk the ring exactly once so we wrap but never loop forever.
53
+ for (let step = 1; step <= n; step++) {
54
+ const idx = ((start + direction * step) % n + n) % n
55
+ const span = filteredSpans[idx]
56
+ if (span && matchingSpanIds.has(span.spanId)) return idx
57
+ }
58
+ return null
59
+ }