@kitlangton/motel 0.2.0 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +7 -5
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +213 -6
  5. package/src/daemon.ts +174 -38
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +114 -128
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +68 -0
  15. package/src/services/TelemetryStore.ts +262 -119
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +244 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +308 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +11 -28
  32. package/src/ui/Waterfall.tsx +43 -148
  33. package/src/ui/aiChatModel.test.ts +391 -0
  34. package/src/ui/aiChatModel.ts +773 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +288 -124
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +174 -40
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +1 -1
@@ -158,6 +158,23 @@ describe("motel telemetry store", () => {
158
158
  { key: "ai.response.msToFinish", value: { stringValue: "20000" } },
159
159
  ],
160
160
  },
161
+ {
162
+ traceId: "trace-ai",
163
+ spanId: "ai-stream-1-do",
164
+ parentSpanId: "ai-stream-1",
165
+ name: "ai.streamText.doStream",
166
+ kind: 1,
167
+ startTimeUnixNano: String(nowNanos + 21n * oneSecond),
168
+ endTimeUnixNano: String(nowNanos + 39n * oneSecond),
169
+ attributes: [
170
+ { key: "ai.operationId", value: { stringValue: "ai.streamText.doStream" } },
171
+ { key: "ai.telemetry.functionId", value: { stringValue: "session.llm" } },
172
+ { key: "ai.model.provider", value: { stringValue: "openai.responses" } },
173
+ { key: "ai.model.id", value: { stringValue: "gpt-5.4" } },
174
+ { key: "ai.telemetry.metadata.sessionId", value: { stringValue: "ses_test123" } },
175
+ { key: "ai.prompt.messages", value: { stringValue: '[{"role":"user","content":"Tell me a joke about programming"}]' } },
176
+ ],
177
+ },
161
178
  {
162
179
  traceId: "trace-ai",
163
180
  spanId: "ai-tool-1",
@@ -617,6 +634,17 @@ describe("motel telemetry store", () => {
617
634
  expect(streamCall?.usage?.outputTokens).toBe(42)
618
635
  })
619
636
 
637
+ it("dedupes nested doStream spans from AI summaries", async () => {
638
+ const result = await storeRuntime.runPromise(
639
+ Effect.flatMap(TelemetryStore.asEffect(), (store) =>
640
+ store.searchAiCalls({ sessionId: "ses_test123" }),
641
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
642
+ )
643
+
644
+ expect(result.map((call) => call.spanId)).toEqual(["ai-stream-1"])
645
+ expect(result.map((call) => call.spanId)).not.toContain("ai-stream-1-do")
646
+ })
647
+
620
648
  it("filters AI calls by model", async () => {
621
649
  const result = await storeRuntime.runPromise(
622
650
  Effect.flatMap(TelemetryStore.asEffect(), (store) =>
@@ -0,0 +1,308 @@
1
+ import { RGBA, TextAttributes } from "@opentui/core"
2
+ import { useLayoutEffect, useMemo, useState } from "react"
3
+ import { isAiSpan, type TraceSpanItem } from "../domain.ts"
4
+ import {
5
+ buildChatListRows,
6
+ chunkDetailTitle,
7
+ renderChunkDetailLines,
8
+ type Chunk,
9
+ type ChatListRow,
10
+ type Role,
11
+ } from "./aiChatModel.ts"
12
+ import { formatDuration, truncateText } from "./format.ts"
13
+ import { AlignedHeaderLine, BlankRow, Divider, PlainLine, TextLine } from "./primitives.tsx"
14
+ import type { AiCallDetailState } from "./state.ts"
15
+ import { colors, SEPARATOR } from "./theme.ts"
16
+
17
+ export const AI_CHAT_HEADER_ROWS = 4
18
+
19
+ const roleColor = (role: Role): string => {
20
+ switch (role) {
21
+ case "user": return colors.accent
22
+ case "assistant": return colors.text
23
+ case "system": return colors.muted
24
+ case "tool": return colors.passing
25
+ case "response": return colors.accent
26
+ default: return colors.muted
27
+ }
28
+ }
29
+
30
+ const rowPrefix = (chunk: Chunk | null): string => {
31
+ if (!chunk) return " "
32
+ switch (chunk.kind) {
33
+ case "tool-call": return "→ "
34
+ case "tool-result": return "← "
35
+ case "reasoning": return "• "
36
+ case "response": return "↳ "
37
+ default: return ""
38
+ }
39
+ }
40
+
41
+ const rowTextColor = (chunk: Chunk | null, role: Role, selected: boolean): string => {
42
+ if (selected) return colors.selectedText
43
+ if (!chunk) return roleColor(role)
44
+ if (chunk.kind === "tool-call") return colors.count
45
+ if (chunk.kind === "tool-result") return colors.passing
46
+ if (chunk.kind === "reasoning") return colors.muted
47
+ if (chunk.kind === "system") return colors.muted
48
+ return colors.text
49
+ }
50
+
51
+ const splitToolRowText = (text: string): { readonly head: string; readonly tail: string | null } => {
52
+ const match = text.match(/\s{2,}/)
53
+ const sep = match?.index ?? -1
54
+ if (sep < 0) return { head: text, tail: null }
55
+ return {
56
+ head: text.slice(0, sep),
57
+ tail: text.slice(sep + match![0].length),
58
+ }
59
+ }
60
+
61
+ const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
62
+
63
+ const chunkRows = (rows: readonly ChatListRow[]) => rows.filter((row) => row.kind === "chunk")
64
+
65
+ interface MouseScrollEvent {
66
+ readonly scroll?: {
67
+ readonly direction: string
68
+ readonly delta: number
69
+ }
70
+ readonly stopPropagation?: () => void
71
+ }
72
+
73
+ const scrollDelta = (event: MouseScrollEvent): number => {
74
+ const info = event.scroll
75
+ if (!info) return 0
76
+ const magnitude = Math.max(1, Math.round(info.delta))
77
+ if (info.direction === "up") return -magnitude
78
+ if (info.direction === "down") return magnitude
79
+ return 0
80
+ }
81
+
82
+ const ChatDetailModal = ({
83
+ chunk,
84
+ scrollOffset,
85
+ onScrollOffset,
86
+ paneWidth,
87
+ paneHeight,
88
+ onClose,
89
+ }: {
90
+ readonly chunk: Chunk
91
+ readonly scrollOffset: number
92
+ readonly onScrollOffset: (updater: (current: number) => number) => void
93
+ readonly paneWidth: number
94
+ readonly paneHeight: number
95
+ readonly onClose: () => void
96
+ }) => {
97
+ const modalWidth = Math.min(Math.max(56, Math.floor(paneWidth * 0.8)), paneWidth - 4)
98
+ const modalHeight = Math.min(Math.max(12, Math.floor(paneHeight * 0.75)), paneHeight - 2)
99
+ const left = Math.max(2, Math.floor((paneWidth - modalWidth) / 2))
100
+ const top = Math.max(1, Math.floor((paneHeight - modalHeight) / 2))
101
+ const innerWidth = Math.max(16, modalWidth - 4)
102
+ const bodyLines = Math.max(4, modalHeight - 4)
103
+ const lines = renderChunkDetailLines(chunk, innerWidth)
104
+ const maxOffset = Math.max(0, lines.length - bodyLines)
105
+ const offset = clamp(scrollOffset, 0, maxOffset)
106
+ const visible = lines.slice(offset, offset + bodyLines)
107
+ const meta = chunk.headerMeta ?? `${lines.length} lines`
108
+ const handleWheel = (event: MouseScrollEvent) => {
109
+ const delta = scrollDelta(event)
110
+ if (delta === 0) return
111
+ onScrollOffset((current) => clamp(current + delta, 0, maxOffset))
112
+ event.stopPropagation?.()
113
+ }
114
+
115
+ return (
116
+ <box position="absolute" zIndex={3500} left={0} top={0} width={paneWidth} height={paneHeight} backgroundColor={RGBA.fromInts(0, 0, 0, 110)} onMouseUp={onClose}>
117
+ <box position="absolute" left={left} top={top} width={modalWidth} height={modalHeight} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)} onMouseScroll={handleWheel}>
118
+ <box paddingLeft={1} paddingRight={1}>
119
+ <AlignedHeaderLine left={chunkDetailTitle(chunk)} right={`${meta} ${SEPARATOR} esc close`} width={modalWidth - 2} rightFg={colors.count} />
120
+ </box>
121
+ <Divider width={modalWidth} />
122
+ <box flexDirection="column" paddingLeft={2} paddingRight={2}>
123
+ {visible.map((line, i) => (
124
+ <PlainLine key={`detail-${i + offset}`} text={line} fg={colors.text} />
125
+ ))}
126
+ {visible.length < bodyLines ? Array.from({ length: bodyLines - visible.length }, (_, i) => <BlankRow key={`detail-pad-${i}`} />) : null}
127
+ </box>
128
+ </box>
129
+ </box>
130
+ )
131
+ }
132
+
133
+ export const AiChatView = ({
134
+ span,
135
+ detailState,
136
+ chunks,
137
+ selectedChunkId,
138
+ onSelectChunk,
139
+ detailChunkId,
140
+ onOpenDetail,
141
+ onCloseDetail,
142
+ detailScrollOffset,
143
+ onSetDetailScrollOffset,
144
+ contentWidth,
145
+ bodyLines,
146
+ paneWidth,
147
+ }: {
148
+ readonly span: TraceSpanItem | null
149
+ readonly detailState: AiCallDetailState
150
+ readonly chunks: readonly Chunk[]
151
+ readonly selectedChunkId: string | null
152
+ readonly onSelectChunk: (chunkId: string) => void
153
+ readonly detailChunkId: string | null
154
+ readonly onOpenDetail: (chunkId: string) => void
155
+ readonly onCloseDetail: () => void
156
+ readonly detailScrollOffset: number
157
+ readonly onSetDetailScrollOffset: (updater: (current: number) => number) => void
158
+ readonly contentWidth: number
159
+ readonly bodyLines: number
160
+ readonly paneWidth: number
161
+ }) => {
162
+ const rows = useMemo(() => buildChatListRows(chunks), [chunks])
163
+ const selectable = useMemo(() => chunkRows(rows), [rows])
164
+ const chunkById = useMemo(() => new Map(chunks.map((chunk) => [chunk.id, chunk] as const)), [chunks])
165
+ const selectedOrdinal = useMemo(
166
+ () => selectedChunkId ? selectable.findIndex((row) => row.chunkId === selectedChunkId) : -1,
167
+ [selectable, selectedChunkId],
168
+ )
169
+ const [scrollOffset, setScrollOffset] = useState(0)
170
+
171
+ const detailChunk = useMemo(
172
+ () => detailChunkId ? chunkById.get(detailChunkId) ?? null : null,
173
+ [chunkById, detailChunkId],
174
+ )
175
+
176
+ const selectedRowIndex = useMemo(
177
+ () => selectedChunkId ? rows.findIndex((row) => row.kind === "chunk" && row.chunkId === selectedChunkId) : -1,
178
+ [rows, selectedChunkId],
179
+ )
180
+
181
+ useLayoutEffect(() => {
182
+ const maxOffset = Math.max(0, rows.length - bodyLines)
183
+ if (selectedRowIndex < 0) {
184
+ setScrollOffset((current) => clamp(current, 0, maxOffset))
185
+ return
186
+ }
187
+ setScrollOffset((current) => {
188
+ let next = clamp(current, 0, maxOffset)
189
+ if (selectedRowIndex < next) next = selectedRowIndex
190
+ else if (selectedRowIndex >= next + bodyLines) next = selectedRowIndex - bodyLines + 1
191
+ return clamp(next, 0, maxOffset)
192
+ })
193
+ }, [rows.length, bodyLines, selectedRowIndex])
194
+
195
+ if (!span || !isAiSpan(span.tags)) {
196
+ return (
197
+ <box flexDirection="column" width={paneWidth} height={bodyLines + AI_CHAT_HEADER_ROWS} overflow="hidden">
198
+ <box paddingLeft={1} paddingRight={1}>
199
+ <AlignedHeaderLine left="AI CHAT" right="not an ai span" width={contentWidth} rightFg={colors.muted} />
200
+ </box>
201
+ </box>
202
+ )
203
+ }
204
+
205
+ const detail = detailState.data
206
+ const model = detail?.model ?? span.tags["ai.model.id"] ?? "unknown model"
207
+ const provider = detail?.provider ?? span.tags["ai.model.provider"] ?? null
208
+ const operation = detail?.operation ?? span.operationName
209
+ const finishReason = detail?.finishReason ?? null
210
+ const usage = detail?.usage ?? null
211
+ const durationLabel = formatDuration(detail?.durationMs ?? span.durationMs)
212
+ const maxOffset = Math.max(0, rows.length - bodyLines)
213
+ const offset = clamp(scrollOffset, 0, maxOffset)
214
+ const visible = rows.slice(offset, offset + bodyLines)
215
+ const headerRight = `${operation} ${SEPARATOR} ${durationLabel} ${SEPARATOR} ${selectable.length > 0 ? `${Math.max(1, selectedOrdinal + 1)}/${selectable.length}` : "0/0"}`
216
+ const handleListWheel = (event: MouseScrollEvent) => {
217
+ if (detailChunk) return
218
+ const delta = scrollDelta(event)
219
+ if (delta === 0) return
220
+ setScrollOffset((current) => clamp(current + delta, 0, maxOffset))
221
+ event.stopPropagation?.()
222
+ }
223
+
224
+ return (
225
+ <box flexDirection="column" width={paneWidth} height={bodyLines + AI_CHAT_HEADER_ROWS} overflow="hidden">
226
+ <box paddingLeft={1} paddingRight={1}>
227
+ <AlignedHeaderLine left="AI CHAT" right={headerRight} width={contentWidth} rightFg={colors.count} />
228
+ </box>
229
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
230
+ <TextLine>
231
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{"✦ "}</span>
232
+ <span fg={colors.text}>{model}</span>
233
+ {provider ? <><span fg={colors.separator}>{SEPARATOR}</span><span fg={colors.muted}>{provider}</span></> : null}
234
+ {finishReason ? <><span fg={colors.separator}>{SEPARATOR}</span><span fg={colors.muted}>{`finish=${finishReason}`}</span></> : null}
235
+ </TextLine>
236
+ <TextLine>
237
+ {usage ? (
238
+ <>
239
+ <span fg={colors.muted}>{"tokens "}</span>
240
+ <span fg={colors.count}>{usage.inputTokens != null ? `in=${usage.inputTokens}` : ""}</span>
241
+ <span fg={colors.muted}>{usage.cachedInputTokens != null ? ` cached=${usage.cachedInputTokens}` : ""}</span>
242
+ <span fg={colors.count}>{usage.outputTokens != null ? ` out=${usage.outputTokens}` : ""}</span>
243
+ <span fg={colors.muted}>{usage.reasoningTokens != null ? ` reason=${usage.reasoningTokens}` : ""}</span>
244
+ </>
245
+ ) : (
246
+ <span fg={colors.muted}>{detail?.sessionId ? `session ${detail.sessionId}` : "no usage reported"}</span>
247
+ )}
248
+ </TextLine>
249
+ </box>
250
+ <Divider width={paneWidth} />
251
+ <box flexDirection="column" paddingLeft={1} paddingRight={1} onMouseScroll={handleListWheel}>
252
+ {detailState.status === "loading" && !detail ? (
253
+ <PlainLine text="loading chat transcript…" fg={colors.muted} />
254
+ ) : detailState.status === "error" ? (
255
+ <PlainLine text={detailState.error ?? "failed to load chat detail"} fg={colors.error} />
256
+ ) : rows.length === 0 ? (
257
+ <PlainLine text="no chat content parsed from this span" fg={colors.muted} />
258
+ ) : (
259
+ visible.map((row, i) => {
260
+ if (row.kind === "separator") {
261
+ return <BlankRow key={`row-${offset + i}`} />
262
+ }
263
+ if (row.kind === "role-divider") {
264
+ return (
265
+ <TextLine key={`row-${offset + i}`}>
266
+ <span fg={colors.separator}>{" "}</span>
267
+ <span fg={roleColor(row.role)} attributes={TextAttributes.BOLD}>{row.text}</span>
268
+ </TextLine>
269
+ )
270
+ }
271
+ const chunk = row.chunkId ? chunkById.get(row.chunkId) ?? null : null
272
+ const isSelected = row.chunkId === selectedChunkId
273
+ const prefix = rowPrefix(chunk)
274
+ const meta = row.meta ?? ""
275
+ const textWidth = Math.max(8, contentWidth - prefix.length - meta.length - 4)
276
+ const display = truncateText(row.text, textWidth)
277
+ const gap = Math.max(1, contentWidth - prefix.length - display.length - meta.length - 1)
278
+ const toolLike = chunk?.kind === "tool-call" || chunk?.kind === "tool-result"
279
+ const { head, tail } = toolLike ? splitToolRowText(display) : { head: display, tail: null }
280
+ const headColor = rowTextColor(chunk, row.role, isSelected)
281
+ const tailColor = isSelected ? colors.muted : colors.separator
282
+ return (
283
+ <box key={`row-${offset + i}`} height={1} onMouseDown={() => { if (row.chunkId) onSelectChunk(row.chunkId) }}>
284
+ <TextLine bg={isSelected ? colors.selectedBg : undefined}>
285
+ <span fg={isSelected ? roleColor(row.role) : colors.separator}>{isSelected ? "▎" : " "}</span>
286
+ <span fg={headColor} attributes={isSelected ? TextAttributes.BOLD : undefined}>{`${prefix}${head}`}</span>
287
+ {tail ? <span fg={tailColor}>{` ${tail}`}</span> : null}
288
+ {meta ? <><span fg={colors.muted}>{" ".repeat(gap)}</span><span fg={colors.muted}>{meta}</span></> : null}
289
+ </TextLine>
290
+ </box>
291
+ )
292
+ })
293
+ )}
294
+ {visible.length < bodyLines && rows.length > 0 ? Array.from({ length: bodyLines - visible.length }, (_, i) => <BlankRow key={`pad-${i}`} />) : null}
295
+ </box>
296
+ {detailChunk ? (
297
+ <ChatDetailModal
298
+ chunk={detailChunk}
299
+ scrollOffset={detailScrollOffset}
300
+ onScrollOffset={onSetDetailScrollOffset}
301
+ paneWidth={paneWidth}
302
+ paneHeight={bodyLines + AI_CHAT_HEADER_ROWS}
303
+ onClose={onCloseDetail}
304
+ />
305
+ ) : null}
306
+ </box>
307
+ )
308
+ }
@@ -0,0 +1,181 @@
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
+ if (!span) {
51
+ return (
52
+ <box flexDirection="column" width={paneWidth} height={bodyLines + SPAN_CONTENT_HEADER_ROWS} overflow="hidden">
53
+ <box paddingLeft={1} paddingRight={1}>
54
+ <AlignedHeaderLine left="SPAN CONTENT" right="no span selected" width={contentWidth} rightFg={colors.muted} />
55
+ </box>
56
+ <BlankRow />
57
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
58
+ <PlainLine text="Select a span in the waterfall to view its full content." fg={colors.muted} />
59
+ </box>
60
+ </box>
61
+ )
62
+ }
63
+
64
+ const entries = Object.entries(span.tags)
65
+ const selected = Math.max(0, Math.min(selectedAttrIndex, entries.length - 1))
66
+ const valueWrapWidth = Math.max(16, contentWidth - VALUE_INDENT.length - CURSOR_WIDTH)
67
+ const aiFlag = isAiSpan(span.tags)
68
+
69
+ // Block layout is memoised on entries identity — otherwise every j/k
70
+ // press would rewrap every attribute's value even though only the
71
+ // highlight moved.
72
+ const blocks = useMemo<readonly AttrBlock[]>(() => {
73
+ return entries.map(([key, value]) => {
74
+ // Word-wrap with a generous line cap — the view is scrollable so
75
+ // we can afford to show the whole value rather than forcing an
76
+ // ellipsis. 200 lines covers enormous LLM prompts without
77
+ // blowing up the render tree.
78
+ const valueLines = wrapTextLines(value, valueWrapWidth, 200)
79
+ return { key, valueLines, rowCount: 1 + Math.max(1, valueLines.length) }
80
+ })
81
+ }, [entries, valueWrapWidth])
82
+
83
+ // Viewport: pick the contiguous window of blocks that (a) fits inside
84
+ // bodyLines and (b) contains the selected block. We find the first
85
+ // start index whose accumulated row count from there through `selected`
86
+ // fits in the budget; that start is the window top. If the selected
87
+ // block alone is larger than bodyLines we still render it — oversize
88
+ // values just get cut at the pane edge, no ellipsis.
89
+ const fitsFrom = (start: number) => {
90
+ let rows = 0
91
+ for (let i = start; i <= selected; i++) {
92
+ rows += blocks[i]?.rowCount ?? 0
93
+ if (rows > bodyLines) return false
94
+ }
95
+ return true
96
+ }
97
+ let windowStart = 0
98
+ for (let i = 0; i <= selected; i++) {
99
+ if (fitsFrom(i)) {
100
+ windowStart = i
101
+ break
102
+ }
103
+ }
104
+ const visible: AttrBlock[] = []
105
+ let visibleRows = 0
106
+ for (let i = windowStart; i < blocks.length; i++) {
107
+ const block = blocks[i]!
108
+ if (visibleRows + block.rowCount > bodyLines && i > selected) break
109
+ visible.push(block)
110
+ visibleRows += block.rowCount
111
+ }
112
+ const firstVisibleIndex = windowStart
113
+
114
+ const headerStatus = `${span.status} ${SEPARATOR} ${formatDuration(span.durationMs)}`
115
+ const headerStatusColor = span.isRunning
116
+ ? colors.warning
117
+ : span.status === "error"
118
+ ? colors.error
119
+ : colors.passing
120
+
121
+ return (
122
+ <box flexDirection="column" width={paneWidth} height={bodyLines + SPAN_CONTENT_HEADER_ROWS} overflow="hidden">
123
+ <box paddingLeft={1} paddingRight={1}>
124
+ <AlignedHeaderLine left="SPAN CONTENT" right={headerStatus} width={contentWidth} rightFg={headerStatusColor} />
125
+ </box>
126
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
127
+ <TextLine>
128
+ {aiFlag ? <span fg={colors.accent}>{"\u2726 "}</span> : null}
129
+ <span fg={colors.text}>{span.operationName}</span>
130
+ </TextLine>
131
+ <TextLine>
132
+ <span fg={colors.defaultService}>{span.serviceName}</span>
133
+ <span fg={colors.separator}>{SEPARATOR}</span>
134
+ <span fg={colors.muted}>{span.scopeName ?? "no scope"}</span>
135
+ <span fg={colors.separator}>{SEPARATOR}</span>
136
+ <span fg={span.isRunning ? colors.warning : colors.muted}>{lifecycleLabel(span)}</span>
137
+ <span fg={colors.separator}>{SEPARATOR}</span>
138
+ <span fg={colors.muted}>{span.spanId.slice(0, 16)}</span>
139
+ <span fg={colors.separator}>{SEPARATOR}</span>
140
+ <span fg={colors.count}>{`${entries.length} tags`}</span>
141
+ <span fg={colors.separator}>{SEPARATOR}</span>
142
+ <span fg={colors.muted}>{`${selected + 1}/${entries.length}`}</span>
143
+ </TextLine>
144
+ </box>
145
+ <Divider width={paneWidth} />
146
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
147
+ {entries.length === 0 ? (
148
+ <PlainLine text="No tags on this span." fg={colors.muted} />
149
+ ) : (
150
+ visible.map((block, offset) => {
151
+ const index = firstVisibleIndex + offset
152
+ const isSelected = index === selected
153
+ const keyColor = isSelected ? colors.accent : colors.count
154
+ const cursor = isSelected ? "\u25b8 " : " "
155
+ return (
156
+ <box key={`${span.spanId}-${block.key}`} flexDirection="column">
157
+ <TextLine>
158
+ <span fg={isSelected ? colors.accent : colors.separator}>{cursor}</span>
159
+ <span fg={keyColor} attributes={isSelected ? TextAttributes.BOLD : undefined}>{block.key}</span>
160
+ </TextLine>
161
+ {block.valueLines.length === 0 ? (
162
+ <TextLine>
163
+ <span fg={colors.separator}>{VALUE_INDENT}</span>
164
+ <span fg={colors.muted}>(empty)</span>
165
+ </TextLine>
166
+ ) : (
167
+ block.valueLines.map((line, i) => (
168
+ <TextLine key={i}>
169
+ <span fg={colors.separator}>{VALUE_INDENT}</span>
170
+ <span fg={isSelected ? colors.text : colors.muted}>{line}</span>
171
+ </TextLine>
172
+ ))
173
+ )}
174
+ </box>
175
+ )
176
+ })
177
+ )}
178
+ </box>
179
+ </box>
180
+ )
181
+ }
@@ -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
  ) : (