@kitlangton/motel 0.1.3 → 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 (55) hide show
  1. package/AGENTS.md +11 -1
  2. package/package.json +5 -3
  3. package/src/App.tsx +239 -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 +62 -4
  8. package/src/httpApi.ts +4 -1
  9. package/src/localServer.ts +112 -121
  10. package/src/mcp.ts +172 -0
  11. package/src/motelClient.ts +166 -14
  12. package/src/registry.ts +26 -23
  13. package/src/runtime.ts +8 -2
  14. package/src/server.ts +10 -9
  15. package/src/services/AsyncIngest.ts +52 -0
  16. package/src/services/TelemetryStore.ts +285 -27
  17. package/src/services/TraceQueryService.ts +4 -2
  18. package/src/services/ingestRpc.ts +41 -0
  19. package/src/services/telemetryWorker.ts +62 -0
  20. package/src/storybook/aiChatStory.tsx +243 -0
  21. package/src/storybook/fixtures/errorState.ts +44 -0
  22. package/src/storybook/fixtures/imagePaste.ts +34 -0
  23. package/src/storybook/fixtures/index.ts +62 -0
  24. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  25. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  26. package/src/storybook/fixtures/short.ts +27 -0
  27. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  28. package/src/telemetry.test.ts +61 -0
  29. package/src/ui/AiChatView.tsx +292 -0
  30. package/src/ui/SpanContentView.tsx +181 -0
  31. package/src/ui/SpanDetail.tsx +98 -17
  32. package/src/ui/TraceDetailsPane.tsx +35 -3
  33. package/src/ui/Waterfall.tsx +94 -167
  34. package/src/ui/aiChatModel.test.ts +347 -0
  35. package/src/ui/aiChatModel.ts +736 -0
  36. package/src/ui/aiState.ts +71 -0
  37. package/src/ui/app/TraceWorkspace.tsx +295 -120
  38. package/src/ui/app/useAppLayout.ts +14 -11
  39. package/src/ui/app/useTraceScreenData.ts +191 -35
  40. package/src/ui/atoms.ts +131 -0
  41. package/src/ui/filterParser.test.ts +56 -0
  42. package/src/ui/filterParser.ts +45 -0
  43. package/src/ui/loaders.ts +120 -0
  44. package/src/ui/persistence.ts +41 -0
  45. package/src/ui/primitives.tsx +47 -21
  46. package/src/ui/state.ts +4 -169
  47. package/src/ui/useAttrFilterPicker.ts +63 -23
  48. package/src/ui/useKeyboardNav.ts +576 -300
  49. package/src/ui/waterfallFilter.test.ts +84 -0
  50. package/src/ui/waterfallFilter.ts +59 -0
  51. package/src/ui/waterfallModel.ts +130 -0
  52. package/src/ui/waterfallNav.test.ts +17 -1
  53. package/src/ui/waterfallNav.ts +1 -1
  54. package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
  55. package/web/dist/index.html +1 -1
@@ -4,6 +4,50 @@ import { formatTimestamp, logSeverityColor, relevantLogAttributes, truncateText,
4
4
  import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
5
5
  import { colors, SEPARATOR } from "./theme.ts"
6
6
 
7
+ /**
8
+ * Inline-vs-stacked threshold for tag rendering.
9
+ *
10
+ * A tag renders **inline** (`key value` on one row) when:
11
+ * - the key is ≤ INLINE_KEY_MAX chars AND
12
+ * - the value fits in the remaining width on one line AND
13
+ * - the value contains no newlines.
14
+ *
15
+ * Otherwise it **stacks** — the key gets its own row (full, no truncation)
16
+ * and the value is wrapped below with a leading indent. Long LLM payloads
17
+ * (`ai.prompt.messages`, `gen_ai.completion`, etc.) always hit the stacked
18
+ * path, which is what makes them readable at a glance.
19
+ */
20
+ const INLINE_KEY_MAX = 24
21
+ /** Max wrapped rows we'll spend on a single stacked value's content. */
22
+ const VALUE_WRAP_MAX_LINES = 4
23
+ /** Leading indent for stacked values — subtle but visible. */
24
+ const STACK_INDENT = " "
25
+
26
+ interface TagRender {
27
+ readonly key: string
28
+ readonly value: string
29
+ readonly inline: boolean
30
+ readonly valueLines: readonly string[]
31
+ readonly rowCount: number
32
+ }
33
+
34
+ const planTag = (key: string, value: string, contentWidth: number, inlineKeyPad: number): TagRender => {
35
+ const hasNewline = value.includes("\n")
36
+ const inlineValueWidth = Math.max(1, contentWidth - inlineKeyPad - 2)
37
+ const canInline =
38
+ !hasNewline &&
39
+ key.length <= INLINE_KEY_MAX &&
40
+ value.length <= inlineValueWidth
41
+ if (canInline) {
42
+ return { key, value, inline: true, valueLines: [value], rowCount: 1 }
43
+ }
44
+ const wrapWidth = Math.max(16, contentWidth - STACK_INDENT.length)
45
+ const valueLines = wrapTextLines(value, wrapWidth, VALUE_WRAP_MAX_LINES)
46
+ // 1 row for the key + N rows for wrapped value (at least 1).
47
+ const rowCount = 1 + Math.max(1, valueLines.length)
48
+ return { key, value, inline: false, valueLines, rowCount }
49
+ }
50
+
7
51
  export const SpanDetailView = ({
8
52
  span,
9
53
  logs,
@@ -16,15 +60,41 @@ export const SpanDetailView = ({
16
60
  bodyLines: number
17
61
  }) => {
18
62
  const tagEntries = Object.entries(span.tags)
19
- const maxKeyLen = Math.min(28, tagEntries.reduce((max, [key]) => Math.max(max, key.length), 0))
63
+ // Column width for inline keys. We cap at INLINE_KEY_MAX so a single
64
+ // very-long key doesn't widen the column for everyone. Short keys still
65
+ // align against each other.
66
+ const inlineKeyPad = Math.min(
67
+ INLINE_KEY_MAX,
68
+ tagEntries.reduce((max, [key]) => (key.length <= INLINE_KEY_MAX ? Math.max(max, key.length) : max), 0),
69
+ )
70
+
20
71
  const maxLogLines = logs.length > 0 ? Math.min(4, Math.max(1, Math.floor(bodyLines * 0.3))) : 0
21
72
  const visibleLogs = logs.slice(0, maxLogLines)
22
73
  const visibleWarnings = span.warnings.slice(0, visibleLogs.length > 0 ? 1 : 2)
23
74
  const visibleEvents = span.events.slice(0, 2)
24
75
  const reservedForWarnings = visibleWarnings.length > 0 ? visibleWarnings.length + 2 : 0
25
76
  const reservedForEvents = visibleEvents.length > 0 ? visibleEvents.length + 2 : 0
26
- const reservedForLogs = visibleLogs.length > 0 ? visibleLogs.reduce((total, log) => total + 3 + Math.min(3, wrapTextLines(log.body, Math.max(16, contentWidth - 2), 3).length), 1) : 0
27
- const maxTagLines = Math.max(0, bodyLines - 4 - reservedForWarnings - reservedForEvents - reservedForLogs)
77
+ const reservedForLogs = visibleLogs.length > 0
78
+ ? visibleLogs.reduce((total, log) => total + 3 + Math.min(3, wrapTextLines(log.body, Math.max(16, contentWidth - 2), 3).length), 1)
79
+ : 0
80
+ // Budget for the TAGS section: total body minus header (TAGS row + blank)
81
+ // minus every other section's reservation. Each stacked tag spends more
82
+ // rows than an inline one so we plan the full visible set up-front rather
83
+ // than slicing by entry count.
84
+ const tagBudget = Math.max(0, bodyLines - 2 - reservedForWarnings - reservedForEvents - reservedForLogs)
85
+
86
+ const planned: TagRender[] = []
87
+ let rowsUsed = 0
88
+ let skipped = 0
89
+ for (const [key, value] of tagEntries) {
90
+ const plan = planTag(key, value, contentWidth, inlineKeyPad)
91
+ if (rowsUsed + plan.rowCount > tagBudget) {
92
+ skipped = tagEntries.length - planned.length
93
+ break
94
+ }
95
+ planned.push(plan)
96
+ rowsUsed += plan.rowCount
97
+ }
28
98
 
29
99
  // NOTE: op name, service, duration, lifecycle, status, and spanId are all
30
100
  // rendered by the enclosing SpanDetailPane header (rows 0..2). Starting
@@ -37,21 +107,32 @@ export const SpanDetailView = ({
37
107
  <TextLine>
38
108
  <span fg={colors.accent} attributes={TextAttributes.BOLD}>TAGS</span>
39
109
  </TextLine>
40
- {tagEntries.slice(0, maxTagLines).map(([key, value]) => {
41
- const keyStr = key.length > maxKeyLen ? `${key.slice(0, maxKeyLen - 1)}\u2026` : key.padEnd(maxKeyLen)
42
- const valMaxWidth = Math.max(8, contentWidth - maxKeyLen - 2)
43
- const valStr = value.length > valMaxWidth ? `${value.slice(0, valMaxWidth - 1)}\u2026` : value
44
-
45
- return (
46
- <TextLine key={key}>
47
- <span fg={colors.count}>{keyStr}</span>
48
- <span fg={colors.muted}> </span>
49
- <span fg={colors.text}>{valStr}</span>
110
+ {planned.map((tag) => tag.inline ? (
111
+ <TextLine key={tag.key}>
112
+ <span fg={colors.count}>{tag.key.padEnd(inlineKeyPad)}</span>
113
+ <span fg={colors.muted}> </span>
114
+ <span fg={colors.text}>{tag.value}</span>
115
+ </TextLine>
116
+ ) : (
117
+ <box key={tag.key} flexDirection="column">
118
+ <TextLine>
119
+ <span fg={colors.count}>{tag.key}</span>
50
120
  </TextLine>
51
- )
52
- })}
53
- {tagEntries.length > maxTagLines ? (
54
- <PlainLine text={` \u2026 ${tagEntries.length - maxTagLines} more`} fg={colors.muted} />
121
+ {tag.valueLines.length === 0 ? (
122
+ <TextLine>
123
+ <span fg={colors.muted}>{STACK_INDENT}</span>
124
+ <span fg={colors.muted}>(empty)</span>
125
+ </TextLine>
126
+ ) : tag.valueLines.map((line, index) => (
127
+ <TextLine key={index}>
128
+ <span fg={colors.muted}>{STACK_INDENT}</span>
129
+ <span fg={colors.text}>{line}</span>
130
+ </TextLine>
131
+ ))}
132
+ </box>
133
+ ))}
134
+ {skipped > 0 ? (
135
+ <PlainLine text={`${STACK_INDENT}\u2026 ${skipped} more`} fg={colors.muted} />
55
136
  ) : null}
56
137
  </>
57
138
  ) : (
@@ -1,8 +1,10 @@
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"
5
- import { getVisibleSpans, WaterfallTimeline } from "./Waterfall.tsx"
4
+ import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./primitives.tsx"
5
+ import { WaterfallTimeline } from "./Waterfall.tsx"
6
+ import { computeMatchingSpanIds } from "./waterfallFilter.ts"
7
+ import { getVisibleSpans } from "./waterfallModel.ts"
6
8
  import type { LoadStatus, LogState } from "./state.ts"
7
9
  import { colors, SEPARATOR } from "./theme.ts"
8
10
 
@@ -30,6 +32,8 @@ export const TraceDetailsPane = ({
30
32
  collapsedSpanIds,
31
33
  focused = false,
32
34
  onSelectSpan,
35
+ waterfallFilterMode,
36
+ waterfallFilterText,
33
37
  }: {
34
38
  trace: TraceItem | null
35
39
  traceSummary: TraceSummaryItem | null
@@ -43,6 +47,8 @@ export const TraceDetailsPane = ({
43
47
  collapsedSpanIds: ReadonlySet<string>
44
48
  focused?: boolean
45
49
  onSelectSpan: (index: number) => void
50
+ waterfallFilterMode: boolean
51
+ waterfallFilterText: string
46
52
  }) => {
47
53
  const filteredSpans = useMemo(
48
54
  () => trace ? getVisibleSpans(trace.spans, collapsedSpanIds) : [],
@@ -62,6 +68,15 @@ export const TraceDetailsPane = ({
62
68
  () => selectedSpan ? traceLogsState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
63
69
  [selectedSpan, traceLogsState.data],
64
70
  )
71
+ const matchingSpanIds = useMemo(
72
+ () => trace ? computeMatchingSpanIds(trace.spans, waterfallFilterText) : null,
73
+ [trace, waterfallFilterText],
74
+ )
75
+ const matchCount = matchingSpanIds?.size ?? 0
76
+ // Reserve 1 row for the filter bar when it's being shown so the
77
+ // waterfall doesn't spill into the footer.
78
+ const showFilterBar = waterfallFilterMode || waterfallFilterText.length > 0
79
+ const waterfallBodyLines = showFilterBar ? Math.max(1, bodyLines - 1) : bodyLines
65
80
 
66
81
  const traceMeta = trace ?? traceSummary
67
82
  const hasTraceSelection = traceSummary !== null
@@ -112,6 +127,22 @@ export const TraceDetailsPane = ({
112
127
  </TextLine>
113
128
  </box>
114
129
  <Divider width={paneWidth} />
130
+ {showFilterBar ? (
131
+ <box paddingLeft={1} paddingRight={1}>
132
+ {waterfallFilterMode ? (
133
+ <FilterBar text={waterfallFilterText} width={contentWidth} />
134
+ ) : (
135
+ <TextLine>
136
+ <span fg={colors.muted}>{"/"}</span>
137
+ <span fg={colors.text}>{waterfallFilterText}</span>
138
+ <span fg={colors.separator}>{SEPARATOR}</span>
139
+ <span fg={colors.count}>{matchCount} match{matchCount === 1 ? "" : "es"}</span>
140
+ <span fg={colors.separator}>{SEPARATOR}</span>
141
+ <span fg={colors.muted}>esc clear</span>
142
+ </TextLine>
143
+ )}
144
+ </box>
145
+ ) : null}
115
146
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
116
147
  <WaterfallTimeline
117
148
  trace={trace}
@@ -119,9 +150,10 @@ export const TraceDetailsPane = ({
119
150
  spanLogCounts={spanLogCounts}
120
151
  selectedSpanLogs={selectedSpanLogs}
121
152
  contentWidth={contentWidth}
122
- bodyLines={bodyLines}
153
+ bodyLines={waterfallBodyLines}
123
154
  selectedSpanIndex={selectedSpanIndex}
124
155
  collapsedSpanIds={collapsedSpanIds}
156
+ matchingSpanIds={matchingSpanIds}
125
157
  onSelectSpan={onSelectSpan}
126
158
  />
127
159
  </box>
@@ -1,81 +1,17 @@
1
1
  import { memo, useLayoutEffect, useRef, useState } from "react"
2
- import type { LogItem, TraceItem, TraceSpanItem } from "../domain.ts"
2
+ import { isAiSpan, type LogItem, type TraceItem, type TraceSpanItem } from "../domain.ts"
3
3
  import { formatDuration, lifecycleLabel, splitDuration, truncateText } from "./format.ts"
4
4
  import { BlankRow, TextLine } from "./primitives.tsx"
5
5
  import { colors, waterfallColors } from "./theme.ts"
6
-
7
- /** Filter spans to only those visible given a set of collapsed span IDs. */
8
- export const getVisibleSpans = (spans: readonly TraceSpanItem[], collapsedIds: ReadonlySet<string>): readonly TraceSpanItem[] => {
9
- if (collapsedIds.size === 0) return spans
10
- const result: TraceSpanItem[] = []
11
- let skipDepth = -1
12
- for (const span of spans) {
13
- if (skipDepth >= 0 && span.depth > skipDepth) continue
14
- skipDepth = -1
15
- result.push(span)
16
- if (collapsedIds.has(span.spanId)) {
17
- skipDepth = span.depth
18
- }
19
- }
20
- return result
21
- }
22
-
23
- /** Find the index of a span's parent in the visible list. */
24
- export const findParentIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
25
- const span = spans[index]
26
- if (!span || span.depth === 0) return null
27
- for (let i = index - 1; i >= 0; i--) {
28
- if (spans[i]!.depth < span.depth) return i
29
- }
30
- return null
31
- }
32
-
33
- /** Find the index of a span's first child in the visible list. */
34
- export const findFirstChildIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
35
- const span = spans[index]
36
- const next = spans[index + 1]
37
- if (span && next && next.depth > span.depth) return index + 1
38
- return null
39
- }
40
-
41
- const INTERESTING_TAGS = [
42
- "http.method", "http.url", "http.status_code", "http.route",
43
- "db.system", "db.statement", "db.name",
44
- "messaging.system", "messaging.destination",
45
- "error", "error.message",
46
- "net.peer.name", "net.peer.port",
47
- ] as const
48
-
49
- const buildTreePrefix = (spans: readonly TraceSpanItem[], index: number): string => {
50
- const span = spans[index]
51
- if (span.depth === 0) return ""
52
-
53
- const parts: string[] = []
54
-
55
- const isLastChild = (spanIndex: number, depth: number): boolean => {
56
- for (let i = spanIndex + 1; i < spans.length; i++) {
57
- if (spans[i].depth < depth) return true
58
- if (spans[i].depth === depth) return false
59
- }
60
- return true
61
- }
62
-
63
- parts.push(isLastChild(index, span.depth) ? "\u2514\u2500" : "\u251c\u2500")
64
-
65
- for (let d = span.depth - 1; d >= 1; d--) {
66
- let parentIndex = index
67
- for (let i = index - 1; i >= 0; i--) {
68
- if (spans[i].depth === d) {
69
- parentIndex = i
70
- break
71
- }
72
- if (spans[i].depth < d) break
73
- }
74
- parts.push(isLastChild(parentIndex, d) ? " " : "\u2502 ")
75
- }
76
-
77
- return parts.reverse().join("")
78
- }
6
+ export { getVisibleSpans } from "./waterfallModel.ts"
7
+ import {
8
+ buildTreePrefix,
9
+ findFirstChildIndex,
10
+ getWaterfallLayout,
11
+ getWaterfallSuffixMetrics,
12
+ type WaterfallSuffixMetrics,
13
+ spanPreviewEntries,
14
+ } from "./waterfallModel.ts"
79
15
 
80
16
  const PARTIAL_BLOCKS = ["", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589", "\u2588"] as const
81
17
  const ULTRA_SHORT_MARKERS = ["\u258f", "\u258e", "\u258d", "\u258c"] as const
@@ -180,66 +116,6 @@ const durationColor = (durationMs: number) => {
180
116
  return colors.muted
181
117
  }
182
118
 
183
- export const getWaterfallLayout = (contentWidth: number, suffixWidth: number) => {
184
- const labelMaxWidth = Math.min(Math.floor(contentWidth * 0.4), 32)
185
- // Two single-space gaps: one between label and bar, one between bar and suffix.
186
- const barWidth = Math.max(6, contentWidth - labelMaxWidth - suffixWidth - 2)
187
- return { labelMaxWidth, barWidth } as const
188
- }
189
-
190
- export type WaterfallSuffixMetrics = {
191
- readonly maxDurationWidth: number
192
- readonly suffixWidth: number
193
- }
194
-
195
- /**
196
- * Compute a shared suffix (duration) width from the visible viewport.
197
- * Reserving the width once keeps every row's duration right-aligned on the
198
- * same column regardless of per-row content. Log correlation lives in the
199
- * span detail pane, not the row suffix.
200
- */
201
- export const getWaterfallSuffixMetrics = (
202
- spans: readonly { readonly durationMs: number; readonly spanId: string }[],
203
- ): WaterfallSuffixMetrics => {
204
- let maxDurationWidth = 0
205
- for (const span of spans) {
206
- const d = formatDuration(Math.max(0, span.durationMs)).length
207
- if (d > maxDurationWidth) maxDurationWidth = d
208
- }
209
- return { maxDurationWidth, suffixWidth: maxDurationWidth }
210
- }
211
-
212
- // Retained for tests: per-row view of the shared layout.
213
- export const getWaterfallColumns = (
214
- contentWidth: number,
215
- metrics: WaterfallSuffixMetrics,
216
- ) => {
217
- const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
218
- return { labelMaxWidth, barWidth, suffixWidth: metrics.suffixWidth } as const
219
- }
220
-
221
- export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
222
- const entries = Object.entries(span.tags)
223
- const interesting = entries.filter(([key]) =>
224
- INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) || key.startsWith("error"),
225
- )
226
- const rest = entries.filter(([key]) =>
227
- !INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) && !key.startsWith("error") && !key.startsWith("otel.") && key !== "span.kind",
228
- )
229
- const tagResults: Array<{ key: string; value: string; isWarning?: boolean }> = []
230
- if (logs.length > 0) {
231
- tagResults.push({ key: "logs", value: `${logs.length} correlated` })
232
- tagResults.push({ key: "log", value: logs[0]!.body.replace(/\s+/g, " ") })
233
- }
234
-
235
- tagResults.push(...[...interesting, ...rest]
236
- .slice(0, maxEntries - span.warnings.length)
237
- .map(([key, value]) => ({ key, value })))
238
- for (const warning of span.warnings) {
239
- tagResults.push({ key: "warning", value: warning, isWarning: true })
240
- }
241
- return tagResults.slice(0, maxEntries)
242
- }
243
119
 
244
120
  const WaterfallRow = memo(({
245
121
  span,
@@ -251,6 +127,7 @@ const WaterfallRow = memo(({
251
127
  collapsed,
252
128
  hasChildSpans,
253
129
  suffixMetrics,
130
+ dimmed,
254
131
  onSelect,
255
132
  }: {
256
133
  span: TraceSpanItem
@@ -262,17 +139,37 @@ const WaterfallRow = memo(({
262
139
  collapsed: boolean
263
140
  hasChildSpans: boolean
264
141
  suffixMetrics: WaterfallSuffixMetrics
142
+ dimmed: boolean
265
143
  onSelect: () => void
266
144
  }) => {
267
145
  const prefix = buildTreePrefix(spans, index)
268
- // Match the trace list indicator: `!` on error, chevron on collapsible parents, `·` on leaves.
269
- const indicator = span.status === "error" ? "!" : hasChildSpans ? (collapsed ? "\u25b8" : "\u25be") : "\u00b7"
146
+ const isAi = isAiSpan(span.tags)
147
+ // Indicator column: `!` on error, chevron on collapsible parents,
148
+ // `✦` on AI leaves (LLM payloads detected — enter drills into a
149
+ // specialized chat view), `·` on other leaves. AI parents keep the
150
+ // chevron glyph so tree structure stays readable; the accent color
151
+ // (applied below) carries the "AI content lives here" signal.
152
+ const indicator = span.status === "error" ? "!"
153
+ : hasChildSpans ? (collapsed ? "\u25b8" : "\u25be")
154
+ : isAi ? "\u2726"
155
+ : "\u00b7"
270
156
  const opName = span.isRunning ? `${span.operationName} [${lifecycleLabel(span)}]` : span.operationName
271
157
 
272
158
  const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixMetrics.suffixWidth)
273
159
 
274
- const opMaxWidth = Math.max(4, labelMaxWidth - prefix.length - 2)
275
- const opTruncated = opName.length > opMaxWidth ? `${opName.slice(0, opMaxWidth - 1)}\u2026` : opName
160
+ // Op name budget = labelMaxWidth minus (prefix + indicator + 1 space).
161
+ // Never force a minimum: at very deep nesting or narrow widths the
162
+ // prefix + indicator may already fill the label column, in which
163
+ // case we render the op as an empty string (or a lone ellipsis) so
164
+ // the line stays within contentWidth. Previous code forced op to 4
165
+ // chars which could push total row width past the pane and make
166
+ // OpenTUI smear "..." across the right edge.
167
+ const opMaxWidth = Math.max(0, labelMaxWidth - prefix.length - 2)
168
+ const opTruncated = opMaxWidth === 0
169
+ ? ""
170
+ : opName.length > opMaxWidth
171
+ ? `${opName.slice(0, Math.max(0, opMaxWidth - 1))}\u2026`
172
+ : opName
276
173
  const labelLen = prefix.length + 2 + opTruncated.length
277
174
  const labelPad = " ".repeat(Math.max(0, labelMaxWidth - labelLen))
278
175
 
@@ -282,12 +179,28 @@ const WaterfallRow = memo(({
282
179
  const rowBg = selected ? colors.selectedBg : colors.screenBg
283
180
  const { segments } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor, rowBg)
284
181
  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
182
+ // Dimmed rows (non-matching under an active waterfall filter) collapse
183
+ // their palette to the muted separator color so matches stand out.
184
+ // Selection always wins the selected row keeps its full brightness
185
+ // so you can still see where the cursor is while scanning.
186
+ const treeColor = selected ? colors.separator : dimmed ? colors.separator : colors.treeLine
187
+ const indicatorColor = selected ? colors.selectedText
188
+ : dimmed ? colors.separator
189
+ : isError ? colors.error
190
+ // AI accent outranks parent/leaf color so both AI parents and AI
191
+ // leaves scan as "there's an LLM payload here" from across the
192
+ // waterfall. Error still wins because a failed AI span is first
193
+ // and foremost a failure.
194
+ : isAi ? colors.accent
195
+ : hasChildSpans ? colors.muted
196
+ : colors.passing
197
+ const opColor = selected ? colors.selectedText
198
+ : dimmed ? colors.separator
199
+ : span.isRunning ? colors.warning
200
+ : colors.text
201
+
202
+ const durationFg = selected ? colors.selectedText : dimmed ? colors.separator : durationColor(span.durationMs)
203
+ const unitFg = dimmed && !selected ? colors.separator : colors.muted
291
204
 
292
205
  // Split the duration so the unit (s/ms) renders dimmer than the number.
293
206
  const { number: durNumber, unit: durUnit } = splitDuration(Math.max(0, span.durationMs))
@@ -373,6 +286,7 @@ export const WaterfallTimeline = ({
373
286
  bodyLines,
374
287
  selectedSpanIndex,
375
288
  collapsedSpanIds,
289
+ matchingSpanIds,
376
290
  onSelectSpan,
377
291
  }: {
378
292
  trace: TraceItem
@@ -383,6 +297,11 @@ export const WaterfallTimeline = ({
383
297
  bodyLines: number
384
298
  selectedSpanIndex: number | null
385
299
  collapsedSpanIds: ReadonlySet<string>
300
+ /**
301
+ * When set, spans whose spanId is NOT in this set are dimmed. Null
302
+ * means no filter active — skip the per-row lookup entirely.
303
+ */
304
+ matchingSpanIds?: ReadonlySet<string> | null
386
305
  onSelectSpan: (index: number) => void
387
306
  }) => {
388
307
  const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
@@ -392,29 +311,37 @@ export const WaterfallTimeline = ({
392
311
  spanIndexById.set(trace.spans[i].spanId, i)
393
312
  }
394
313
 
395
- // Virtual windowing: only render visible rows, shift window only when
396
- // the selection would go out of view (no jerkiness).
314
+ // Virtual windowing: only render visible rows. We track scroll offset
315
+ // as state so the mouse wheel can scroll the window INDEPENDENTLY of
316
+ // the selected span (mirrors TraceList behavior). Selection still
317
+ // follows: if the user moves selection off-screen via j/k, we nudge
318
+ // the window to keep it visible — but wheel-scrolling never changes
319
+ // selection, only clicking a row does.
397
320
  const viewportSize = Math.max(1, bodyLines)
398
- const scrollOffsetRef = useRef(0)
321
+ const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
322
+ const [scrollOffset, setScrollOffset] = useState(0)
399
323
  const lastTraceIdRef = useRef<string | null>(null)
400
324
 
401
- // Reset scroll offset when the trace changes
325
+ // Reset scroll offset when the trace changes.
402
326
  if (trace.traceId !== lastTraceIdRef.current) {
403
- scrollOffsetRef.current = 0
327
+ setScrollOffset(0)
404
328
  lastTraceIdRef.current = trace.traceId
405
329
  }
406
330
 
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
331
+ // Auto-follow selection: only if the selected span would be hidden
332
+ // by the current window, shift just enough to bring it back. Runs in
333
+ // layout effect so the visible window is accurate on the same paint
334
+ // that the selection changed.
335
+ useLayoutEffect(() => {
336
+ if (selectedSpanIndex === null) return
337
+ setScrollOffset((current) => {
338
+ if (selectedSpanIndex < current) return selectedSpanIndex
339
+ if (selectedSpanIndex >= current + viewportSize) return selectedSpanIndex - viewportSize + 1
340
+ return current
341
+ })
342
+ }, [selectedSpanIndex, viewportSize])
343
+
344
+ const windowStart = Math.max(0, Math.min(scrollOffset, maxOffset))
418
345
  const windowSpans = filteredSpans.slice(windowStart, windowStart + viewportSize)
419
346
  const blankCount = Math.max(0, viewportSize - windowSpans.length)
420
347
 
@@ -422,19 +349,17 @@ export const WaterfallTimeline = ({
422
349
  // row's duration cell lines up on the same right-edge column.
423
350
  const suffixMetrics = getWaterfallSuffixMetrics(windowSpans)
424
351
 
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.
352
+ // Mouse wheel scrolls the window without touching selection matches
353
+ // the trace list, so the user can browse ahead of their cursor freely
354
+ // and click a row to commit. Delta is scaled 1:1 with opentui's wheel
355
+ // reporting (1 notch 3 rows on most terminals).
429
356
  const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
430
357
  const info = event.scroll
431
358
  if (!info || filteredSpans.length === 0) return
432
359
  const magnitude = Math.max(1, Math.round(info.delta))
433
360
  const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
434
361
  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)
362
+ setScrollOffset((current) => Math.max(0, Math.min(current + signed, maxOffset)))
438
363
  event.stopPropagation?.()
439
364
  }
440
365
 
@@ -443,6 +368,7 @@ export const WaterfallTimeline = ({
443
368
  {windowSpans.map((span, index) => {
444
369
  const actualIndex = windowStart + index
445
370
  const fullIndex = spanIndexById.get(span.spanId) ?? -1
371
+ const dimmed = matchingSpanIds != null && !matchingSpanIds.has(span.spanId)
446
372
  return (
447
373
  <WaterfallRow
448
374
  key={`${trace.traceId}-${span.spanId}`}
@@ -455,6 +381,7 @@ export const WaterfallTimeline = ({
455
381
  collapsed={collapsedSpanIds.has(span.spanId)}
456
382
  hasChildSpans={fullIndex >= 0 && findFirstChildIndex(trace.spans, fullIndex) !== null}
457
383
  suffixMetrics={suffixMetrics}
384
+ dimmed={dimmed}
458
385
  onSelect={() => onSelectSpan(actualIndex)}
459
386
  />
460
387
  )