@aryasaatvik/motel 0.3.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.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
@@ -0,0 +1,184 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { useMemo } from "react"
3
+ import { isAiSpan, type TraceSpanItem } from "../domain.ts"
4
+ import { formatDuration, lifecycleLabel, wrapTextLines } from "./format.ts"
5
+ import { AlignedHeaderLine, BlankRow, Divider, PlainLine, TextLine } from "./primitives.tsx"
6
+ import { colors, SEPARATOR } from "./theme.ts"
7
+
8
+ /** Header above the attribute list: "SPAN CONTENT" + status/duration strip. */
9
+ export const SPAN_CONTENT_HEADER_ROWS = 4
10
+ /** Width of the indent used for wrapped lines of a stacked value. */
11
+ const VALUE_INDENT = " "
12
+ /** Width of the leading marker column that signals "selected" on the cursor row. */
13
+ const CURSOR_WIDTH = 2
14
+
15
+ /**
16
+ * Layout plan for a single attribute row. Each attribute renders as at least
17
+ * two rows (key line + one wrapped value line) plus however many extra wrap
18
+ * lines the value needs. We precompute row counts up front so the viewport
19
+ * (j/k scroll) can pick exactly `bodyLines` worth of content without
20
+ * measuring twice.
21
+ */
22
+ interface AttrBlock {
23
+ readonly key: string
24
+ readonly valueLines: readonly string[]
25
+ readonly rowCount: number
26
+ }
27
+
28
+ /**
29
+ * Full-screen span content view (level 2). Triggered by pressing enter on
30
+ * a span in the waterfall. Renders every tag stacked key-above-value with
31
+ * no truncation and word-wrapping to fit the viewport. The selected
32
+ * attribute is highlighted (accent color on the key, leading `▶`) and
33
+ * `y` copies its value. Step 3 will branch AI-flagged spans into a
34
+ * specialised chat-transcript view; this is the generic fallback that
35
+ * also serves as the baseline for any non-AI span.
36
+ */
37
+ export const SpanContentView = ({
38
+ span,
39
+ contentWidth,
40
+ bodyLines,
41
+ paneWidth,
42
+ selectedAttrIndex,
43
+ }: {
44
+ readonly span: TraceSpanItem | null
45
+ readonly contentWidth: number
46
+ readonly bodyLines: number
47
+ readonly paneWidth: number
48
+ readonly selectedAttrIndex: number
49
+ }) => {
50
+ const valueWrapWidth = Math.max(16, contentWidth - VALUE_INDENT.length - CURSOR_WIDTH)
51
+ const tags = span?.tags
52
+
53
+ // Block layout is memoised on tags identity — otherwise every j/k press
54
+ // would rewrap every attribute's value even though only the highlight
55
+ // moved. Hooks must be called unconditionally, so this lives above the
56
+ // `!span` early return and the memo handles the empty case itself.
57
+ const blocks = useMemo<readonly AttrBlock[]>(() => {
58
+ if (!tags) return []
59
+ return Object.entries(tags).map(([key, value]) => {
60
+ // Word-wrap with a generous line cap — the view is scrollable so
61
+ // we can afford to show the whole value rather than forcing an
62
+ // ellipsis. 200 lines covers enormous LLM prompts without
63
+ // blowing up the render tree.
64
+ const valueLines = wrapTextLines(value, valueWrapWidth, 200)
65
+ return { key, valueLines, rowCount: 1 + Math.max(1, valueLines.length) }
66
+ })
67
+ }, [tags, valueWrapWidth])
68
+
69
+ if (!span) {
70
+ return (
71
+ <box flexDirection="column" width={paneWidth} height={bodyLines + SPAN_CONTENT_HEADER_ROWS} overflow="hidden">
72
+ <box paddingLeft={1} paddingRight={1}>
73
+ <AlignedHeaderLine left="SPAN CONTENT" right="no span selected" width={contentWidth} rightFg={colors.muted} />
74
+ </box>
75
+ <BlankRow />
76
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
77
+ <PlainLine text="Select a span in the waterfall to view its full content." fg={colors.muted} />
78
+ </box>
79
+ </box>
80
+ )
81
+ }
82
+
83
+ const selected = Math.max(0, Math.min(selectedAttrIndex, blocks.length - 1))
84
+ const aiFlag = isAiSpan(span.tags)
85
+
86
+ // Viewport: pick the contiguous window of blocks that (a) fits inside
87
+ // bodyLines and (b) contains the selected block. We find the first
88
+ // start index whose accumulated row count from there through `selected`
89
+ // fits in the budget; that start is the window top. If the selected
90
+ // block alone is larger than bodyLines we still render it — oversize
91
+ // values just get cut at the pane edge, no ellipsis.
92
+ const fitsFrom = (start: number) => {
93
+ let rows = 0
94
+ for (let i = start; i <= selected; i++) {
95
+ rows += blocks[i]?.rowCount ?? 0
96
+ if (rows > bodyLines) return false
97
+ }
98
+ return true
99
+ }
100
+ let windowStart = 0
101
+ for (let i = 0; i <= selected; i++) {
102
+ if (fitsFrom(i)) {
103
+ windowStart = i
104
+ break
105
+ }
106
+ }
107
+ const visible: AttrBlock[] = []
108
+ let visibleRows = 0
109
+ for (let i = windowStart; i < blocks.length; i++) {
110
+ const block = blocks[i]!
111
+ if (visibleRows + block.rowCount > bodyLines && i > selected) break
112
+ visible.push(block)
113
+ visibleRows += block.rowCount
114
+ }
115
+ const firstVisibleIndex = windowStart
116
+
117
+ const headerStatus = `${span.status} ${SEPARATOR} ${formatDuration(span.durationMs)}`
118
+ const headerStatusColor = span.isRunning
119
+ ? colors.warning
120
+ : span.status === "error"
121
+ ? colors.error
122
+ : colors.passing
123
+
124
+ return (
125
+ <box flexDirection="column" width={paneWidth} height={bodyLines + SPAN_CONTENT_HEADER_ROWS} overflow="hidden">
126
+ <box paddingLeft={1} paddingRight={1}>
127
+ <AlignedHeaderLine left="SPAN CONTENT" right={headerStatus} width={contentWidth} rightFg={headerStatusColor} />
128
+ </box>
129
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
130
+ <TextLine>
131
+ {aiFlag ? <span fg={colors.accent}>{"\u2726 "}</span> : null}
132
+ <span fg={colors.text}>{span.operationName}</span>
133
+ </TextLine>
134
+ <TextLine>
135
+ <span fg={colors.defaultService}>{span.serviceName}</span>
136
+ <span fg={colors.separator}>{SEPARATOR}</span>
137
+ <span fg={colors.muted}>{span.scopeName ?? "no scope"}</span>
138
+ <span fg={colors.separator}>{SEPARATOR}</span>
139
+ <span fg={span.isRunning ? colors.warning : colors.muted}>{lifecycleLabel(span)}</span>
140
+ <span fg={colors.separator}>{SEPARATOR}</span>
141
+ <span fg={colors.muted}>{span.spanId.slice(0, 16)}</span>
142
+ <span fg={colors.separator}>{SEPARATOR}</span>
143
+ <span fg={colors.count}>{`${blocks.length} tags`}</span>
144
+ <span fg={colors.separator}>{SEPARATOR}</span>
145
+ <span fg={colors.muted}>{`${selected + 1}/${blocks.length}`}</span>
146
+ </TextLine>
147
+ </box>
148
+ <Divider width={paneWidth} />
149
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
150
+ {blocks.length === 0 ? (
151
+ <PlainLine text="No tags on this span." fg={colors.muted} />
152
+ ) : (
153
+ visible.map((block, offset) => {
154
+ const index = firstVisibleIndex + offset
155
+ const isSelected = index === selected
156
+ const keyColor = isSelected ? colors.accent : colors.count
157
+ const cursor = isSelected ? "\u25b8 " : " "
158
+ return (
159
+ <box key={`${span.spanId}-${block.key}`} flexDirection="column">
160
+ <TextLine>
161
+ <span fg={isSelected ? colors.accent : colors.separator}>{cursor}</span>
162
+ <span fg={keyColor} attributes={isSelected ? TextAttributes.BOLD : undefined}>{block.key}</span>
163
+ </TextLine>
164
+ {block.valueLines.length === 0 ? (
165
+ <TextLine>
166
+ <span fg={colors.separator}>{VALUE_INDENT}</span>
167
+ <span fg={colors.muted}>(empty)</span>
168
+ </TextLine>
169
+ ) : (
170
+ block.valueLines.map((line, i) => (
171
+ <TextLine key={`${block.key}-${i}`}>
172
+ <span fg={colors.separator}>{VALUE_INDENT}</span>
173
+ <span fg={isSelected ? colors.text : colors.muted}>{line}</span>
174
+ </TextLine>
175
+ ))
176
+ )}
177
+ </box>
178
+ )
179
+ })
180
+ )}
181
+ </box>
182
+ </box>
183
+ )
184
+ }
@@ -0,0 +1,215 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import type { LogItem, TraceSpanItem } from "../domain.ts"
3
+ import { formatTimestamp, logSeverityColor, relevantLogAttributes, truncateText, wrapTextLines } from "./format.ts"
4
+ import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
5
+ import { colors, SEPARATOR } from "./theme.ts"
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
+
51
+ export const SpanDetailView = ({
52
+ span,
53
+ logs,
54
+ contentWidth,
55
+ bodyLines,
56
+ }: {
57
+ span: TraceSpanItem
58
+ logs: readonly LogItem[]
59
+ contentWidth: number
60
+ bodyLines: number
61
+ }) => {
62
+ const tagEntries = Object.entries(span.tags)
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
+
71
+ const maxLogLines = logs.length > 0 ? Math.min(4, Math.max(1, Math.floor(bodyLines * 0.3))) : 0
72
+ const visibleLogs = logs.slice(0, maxLogLines)
73
+ const visibleWarnings = span.warnings.slice(0, visibleLogs.length > 0 ? 1 : 2)
74
+ const visibleEvents = span.events.slice(0, 2)
75
+ const reservedForWarnings = visibleWarnings.length > 0 ? visibleWarnings.length + 2 : 0
76
+ const reservedForEvents = visibleEvents.length > 0 ? visibleEvents.length + 2 : 0
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
+ }
98
+
99
+ // NOTE: op name, service, duration, lifecycle, status, and spanId are all
100
+ // rendered by the enclosing SpanDetailPane header (rows 0..2). Starting
101
+ // the body at TAGS avoids the visible duplication where the pane meta
102
+ // and the first two body lines mirrored each other.
103
+ return (
104
+ <box flexDirection="column">
105
+ {tagEntries.length > 0 ? (
106
+ <>
107
+ <TextLine>
108
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>TAGS</span>
109
+ </TextLine>
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>
120
+ </TextLine>
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} />
136
+ ) : null}
137
+ </>
138
+ ) : (
139
+ <PlainLine text="No tags on this span." fg={colors.muted} />
140
+ )}
141
+ {visibleWarnings.length > 0 ? (
142
+ <>
143
+ <BlankRow />
144
+ <TextLine>
145
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>WARNINGS</span>
146
+ </TextLine>
147
+ {visibleWarnings.map((warning, i) => (
148
+ <PlainLine key={i} text={warning} fg={colors.error} />
149
+ ))}
150
+ </>
151
+ ) : null}
152
+ {visibleEvents.length > 0 ? (
153
+ <>
154
+ <BlankRow />
155
+ <TextLine>
156
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>EVENTS</span>
157
+ </TextLine>
158
+ {visibleEvents.map((event, index) => {
159
+ const preview = Object.entries(event.attributes).slice(0, 1)
160
+ return (
161
+ <box key={`${event.name}-${index}`} flexDirection="column">
162
+ <TextLine>
163
+ <span fg={colors.muted}>{formatTimestamp(event.timestamp)}</span>
164
+ <span fg={colors.separator}>{SEPARATOR}</span>
165
+ <span fg={colors.text}>{event.name}</span>
166
+ </TextLine>
167
+ {preview.map(([key, value]) => (
168
+ <TextLine key={`${event.name}-${key}`}>
169
+ <span fg={colors.count}>{truncateText(key, 18).padEnd(18, " ")}</span>
170
+ <span fg={colors.muted}> </span>
171
+ <span fg={colors.muted}>{truncateText(value, Math.max(12, contentWidth - 20))}</span>
172
+ </TextLine>
173
+ ))}
174
+ </box>
175
+ )
176
+ })}
177
+ </>
178
+ ) : null}
179
+ {visibleLogs.length > 0 ? (
180
+ <>
181
+ <BlankRow />
182
+ <TextLine>
183
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>RELATED LOGS</span>
184
+ </TextLine>
185
+ {visibleLogs.map((log) => {
186
+ const logBodyLines = wrapTextLines(log.body, Math.max(16, contentWidth - 2), 3)
187
+ const attributePreview = relevantLogAttributes(log).slice(0, 1)
188
+
189
+ return (
190
+ <box key={log.id} flexDirection="column">
191
+ <TextLine>
192
+ <span fg={colors.muted}>{formatTimestamp(log.timestamp)}</span>
193
+ <span fg={colors.separator}>{SEPARATOR}</span>
194
+ <span fg={logSeverityColor(log.severityText)}>{log.severityText.toLowerCase()}</span>
195
+ <span fg={colors.separator}>{SEPARATOR}</span>
196
+ <span fg={colors.defaultService}>{log.scopeName ?? log.serviceName}</span>
197
+ </TextLine>
198
+ {logBodyLines.map((line, index) => (
199
+ <PlainLine key={`${log.id}-body-${index}`} text={line} fg={colors.text} />
200
+ ))}
201
+ {attributePreview.map(([key, value]) => (
202
+ <TextLine key={`${log.id}-${key}`}>
203
+ <span fg={colors.count}>{truncateText(key, 18).padEnd(18, " ")}</span>
204
+ <span fg={colors.muted}> </span>
205
+ <span fg={colors.muted}>{truncateText(value, Math.max(12, contentWidth - 20))}</span>
206
+ </TextLine>
207
+ ))}
208
+ </box>
209
+ )
210
+ })}
211
+ </>
212
+ ) : null}
213
+ </box>
214
+ )
215
+ }
@@ -0,0 +1,224 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import type { ReactNode } from "react"
3
+ import type { LogItem, TraceSpanItem } from "../domain.ts"
4
+ import { formatDuration, formatShortDate, formatTimestamp, lifecycleLabel, logSeverityColor, relevantLogAttributes, truncateText, wrapTextLines } from "./format.ts"
5
+ import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
6
+ import { colors, SEPARATOR } from "./theme.ts"
7
+
8
+ type Line = ReactNode
9
+
10
+ /**
11
+ * Full-pane span detail view. Shows everything we know about a span:
12
+ * - identifiers, timing, scope, kind, status
13
+ * - all tags
14
+ * - all events with all attributes
15
+ * - all warnings
16
+ * - all correlated logs with wrapped bodies and relevant attributes
17
+ *
18
+ * The view builds a flat array of rendered lines then slices it to `bodyLines`,
19
+ * honoring a scroll offset so `j/k` scrolling can be wired later.
20
+ */
21
+ export const SpanDetailFullView = ({
22
+ span,
23
+ logs,
24
+ contentWidth,
25
+ bodyLines,
26
+ scrollOffset = 0,
27
+ }: {
28
+ span: TraceSpanItem
29
+ logs: readonly LogItem[]
30
+ contentWidth: number
31
+ bodyLines: number
32
+ scrollOffset?: number
33
+ }) => {
34
+ const lines: Line[] = []
35
+ let key = 0
36
+ const push = (node: Line) => lines.push(<box key={key++}>{node}</box>)
37
+
38
+ // --- Header: operation + status line ---
39
+ push(
40
+ <TextLine>
41
+ <span fg={colors.text} attributes={TextAttributes.BOLD}>{span.operationName}</span>
42
+ </TextLine>,
43
+ )
44
+ push(
45
+ <TextLine>
46
+ <span fg={colors.defaultService}>{span.serviceName}</span>
47
+ <span fg={colors.separator}>{SEPARATOR}</span>
48
+ <span fg={colors.count}>{formatDuration(span.durationMs)}</span>
49
+ <span fg={colors.separator}>{SEPARATOR}</span>
50
+ <span fg={span.isRunning ? colors.warning : colors.muted}>{lifecycleLabel(span)}</span>
51
+ <span fg={colors.separator}>{SEPARATOR}</span>
52
+ <span fg={span.status === "error" ? colors.error : colors.passing}>{span.status}</span>
53
+ </TextLine>,
54
+ )
55
+ push(<BlankRow />)
56
+
57
+ // --- Metadata block ---
58
+ const metaKeyWidth = 10
59
+ const metaRow = (k: string, v: string, vFg: string = colors.text) => (
60
+ <TextLine>
61
+ <span fg={colors.count}>{k.padEnd(metaKeyWidth)}</span>
62
+ <span fg={colors.muted}> </span>
63
+ <span fg={vFg}>{truncateText(v, Math.max(8, contentWidth - metaKeyWidth - 1))}</span>
64
+ </TextLine>
65
+ )
66
+ push(<TextLine><span fg={colors.accent} attributes={TextAttributes.BOLD}>META</span></TextLine>)
67
+ push(metaRow("span id", span.spanId))
68
+ if (span.parentSpanId) push(metaRow("parent", span.parentSpanId))
69
+ if (span.kind) push(metaRow("kind", span.kind))
70
+ if (span.scopeName) push(metaRow("scope", span.scopeName))
71
+ push(metaRow("started", `${formatShortDate(span.startTime)} ${formatTimestamp(span.startTime)}`))
72
+ push(metaRow("depth", String(span.depth), colors.muted))
73
+
74
+ // --- Tags ---
75
+ const tagEntries = Object.entries(span.tags)
76
+ if (tagEntries.length > 0) {
77
+ push(<BlankRow />)
78
+ push(
79
+ <TextLine>
80
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>TAGS</span>
81
+ <span fg={colors.muted}> ({tagEntries.length})</span>
82
+ </TextLine>,
83
+ )
84
+ const maxKeyLen = Math.min(32, tagEntries.reduce((m, [k]) => Math.max(m, k.length), 0))
85
+ const valMaxWidth = Math.max(8, contentWidth - maxKeyLen - 2)
86
+ for (const [tagKey, value] of tagEntries) {
87
+ const keyStr = tagKey.length > maxKeyLen ? `${tagKey.slice(0, maxKeyLen - 1)}\u2026` : tagKey.padEnd(maxKeyLen)
88
+ const wrapped = wrapTextLines(value, valMaxWidth, 4)
89
+ wrapped.forEach((line, idx) => {
90
+ push(
91
+ <TextLine>
92
+ <span fg={colors.count}>{idx === 0 ? keyStr : " ".repeat(maxKeyLen)}</span>
93
+ <span fg={colors.muted}> </span>
94
+ <span fg={colors.text}>{line}</span>
95
+ </TextLine>,
96
+ )
97
+ })
98
+ }
99
+ }
100
+
101
+ // --- Warnings ---
102
+ if (span.warnings.length > 0) {
103
+ push(<BlankRow />)
104
+ push(
105
+ <TextLine>
106
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>WARNINGS</span>
107
+ <span fg={colors.muted}> ({span.warnings.length})</span>
108
+ </TextLine>,
109
+ )
110
+ for (const warning of span.warnings) {
111
+ for (const line of wrapTextLines(warning, Math.max(16, contentWidth - 2), 4)) {
112
+ push(<PlainLine text={line} fg={colors.error} />)
113
+ }
114
+ }
115
+ }
116
+
117
+ // --- Events ---
118
+ if (span.events.length > 0) {
119
+ push(<BlankRow />)
120
+ push(
121
+ <TextLine>
122
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>EVENTS</span>
123
+ <span fg={colors.muted}> ({span.events.length})</span>
124
+ </TextLine>,
125
+ )
126
+ for (const event of span.events) {
127
+ push(
128
+ <TextLine>
129
+ <span fg={colors.muted}>{formatTimestamp(event.timestamp)}</span>
130
+ <span fg={colors.separator}>{SEPARATOR}</span>
131
+ <span fg={colors.text}>{event.name}</span>
132
+ </TextLine>,
133
+ )
134
+ const attrs = Object.entries(event.attributes)
135
+ if (attrs.length > 0) {
136
+ const attrKeyWidth = Math.min(24, attrs.reduce((m, [k]) => Math.max(m, k.length), 0))
137
+ const attrValWidth = Math.max(8, contentWidth - attrKeyWidth - 4)
138
+ for (const [attrKey, attrVal] of attrs) {
139
+ const wrapped = wrapTextLines(attrVal, attrValWidth, 2)
140
+ wrapped.forEach((line, idx) => {
141
+ push(
142
+ <TextLine>
143
+ <span fg={colors.muted}> </span>
144
+ <span fg={colors.count}>
145
+ {idx === 0
146
+ ? (attrKey.length > attrKeyWidth ? `${attrKey.slice(0, attrKeyWidth - 1)}\u2026` : attrKey.padEnd(attrKeyWidth))
147
+ : " ".repeat(attrKeyWidth)}
148
+ </span>
149
+ <span fg={colors.muted}> </span>
150
+ <span fg={colors.muted}>{line}</span>
151
+ </TextLine>,
152
+ )
153
+ })
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ // --- Logs ---
160
+ if (logs.length > 0) {
161
+ push(<BlankRow />)
162
+ push(
163
+ <TextLine>
164
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>LOGS</span>
165
+ <span fg={colors.muted}> ({logs.length})</span>
166
+ </TextLine>,
167
+ )
168
+ for (const log of logs) {
169
+ push(
170
+ <TextLine>
171
+ <span fg={colors.muted}>{formatTimestamp(log.timestamp)}</span>
172
+ <span fg={colors.separator}>{SEPARATOR}</span>
173
+ <span fg={logSeverityColor(log.severityText)}>{log.severityText.toLowerCase()}</span>
174
+ <span fg={colors.separator}>{SEPARATOR}</span>
175
+ <span fg={colors.defaultService}>{log.scopeName ?? log.serviceName}</span>
176
+ </TextLine>,
177
+ )
178
+ for (const line of wrapTextLines(log.body, Math.max(16, contentWidth - 2), 8)) {
179
+ push(<PlainLine text={line} fg={colors.text} />)
180
+ }
181
+ const attrs = relevantLogAttributes(log)
182
+ if (attrs.length > 0) {
183
+ const attrKeyWidth = Math.min(22, attrs.reduce((m, [k]) => Math.max(m, k.length), 0))
184
+ const attrValWidth = Math.max(8, contentWidth - attrKeyWidth - 4)
185
+ for (const [attrKey, attrVal] of attrs) {
186
+ const wrapped = wrapTextLines(attrVal, attrValWidth, 2)
187
+ wrapped.forEach((line, idx) => {
188
+ push(
189
+ <TextLine>
190
+ <span fg={colors.muted}> </span>
191
+ <span fg={colors.count}>
192
+ {idx === 0
193
+ ? (attrKey.length > attrKeyWidth ? `${attrKey.slice(0, attrKeyWidth - 1)}\u2026` : attrKey.padEnd(attrKeyWidth))
194
+ : " ".repeat(attrKeyWidth)}
195
+ </span>
196
+ <span fg={colors.muted}> </span>
197
+ <span fg={colors.muted}>{line}</span>
198
+ </TextLine>,
199
+ )
200
+ })
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ // --- Slice by scroll ---
207
+ const totalLines = lines.length
208
+ const maxOffset = Math.max(0, totalLines - bodyLines)
209
+ const offset = Math.min(Math.max(0, scrollOffset), maxOffset)
210
+ const visible = lines.slice(offset, offset + bodyLines)
211
+
212
+ return (
213
+ <box flexDirection="column">
214
+ {visible}
215
+ {totalLines > bodyLines ? (
216
+ <TextLine>
217
+ <span fg={colors.muted}>
218
+ {`\u2014 ${offset + 1}\u2013${Math.min(offset + bodyLines, totalLines)} of ${totalLines} lines${offset < maxOffset ? " \u00b7 j/k to scroll" : ""}`}
219
+ </span>
220
+ </TextLine>
221
+ ) : null}
222
+ </box>
223
+ )
224
+ }