@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,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 "./aiState.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,120 @@
1
+ import { RGBA, TextAttributes } from "@opentui/core"
2
+ import { BlankRow, TextLine } from "./primitives.tsx"
3
+ import { colors } from "./theme.ts"
4
+ import { fitCell, truncateText } from "./format.ts"
5
+ import type { AttrFacetState, AttrPickerMode } from "./atoms.ts"
6
+
7
+ export interface AttrFilterModalProps {
8
+ readonly width: number
9
+ readonly height: number
10
+ readonly mode: Exclude<AttrPickerMode, "off">
11
+ readonly input: string
12
+ readonly selectedIndex: number
13
+ readonly selectedKey: string | null
14
+ readonly state: AttrFacetState
15
+ readonly onClose: () => void
16
+ }
17
+
18
+ // Filter + rank facet rows by the user's current input so typing is
19
+ // responsive even on large key sets. Values list skips this — attribute
20
+ // values are usually opaque ids that users paste in whole.
21
+ export const filterFacets = (
22
+ rows: readonly { readonly value: string; readonly count: number }[],
23
+ input: string,
24
+ ): readonly { readonly value: string; readonly count: number }[] => {
25
+ const needle = input.trim().toLowerCase()
26
+ if (!needle) return rows
27
+ return rows.filter((row) => row.value.toLowerCase().includes(needle))
28
+ }
29
+
30
+ export const AttrFilterModal = ({
31
+ width,
32
+ height,
33
+ mode,
34
+ input,
35
+ selectedIndex,
36
+ selectedKey,
37
+ state,
38
+ onClose,
39
+ }: AttrFilterModalProps) => {
40
+ const panelWidth = Math.min(92, Math.max(60, width - 10))
41
+ const left = Math.max(2, Math.floor((width - panelWidth) / 2))
42
+ const top = Math.max(1, Math.floor(height / 6))
43
+ const innerWidth = panelWidth - 4
44
+ const rows = filterFacets(state.data, input)
45
+ const clampedIndex = rows.length === 0 ? 0 : Math.max(0, Math.min(selectedIndex, rows.length - 1))
46
+ const visibleRowCount = Math.max(5, Math.min(18, height - top - 8))
47
+ const windowStart = Math.max(0, clampedIndex - Math.floor(visibleRowCount / 2))
48
+ const windowEnd = Math.min(rows.length, windowStart + visibleRowCount)
49
+ const windowed = rows.slice(windowStart, windowEnd)
50
+
51
+ const title = mode === "keys"
52
+ ? "Filter traces by attribute key"
53
+ : `Filter · ${truncateText(selectedKey ?? "", innerWidth - 14)}`
54
+
55
+ const hint = mode === "keys"
56
+ ? "type to narrow · ↑↓ move · enter select · esc cancel"
57
+ : "type to narrow · ↑↓ move · enter apply · backspace keys · esc cancel"
58
+
59
+ const countWidth = 7
60
+ const valueWidth = Math.max(10, innerWidth - countWidth - 1)
61
+
62
+ const renderRow = (row: { readonly value: string; readonly count: number }, isSelected: boolean) => {
63
+ const label = fitCell(row.value, valueWidth)
64
+ const count = String(row.count).padStart(countWidth - 1) + " "
65
+ if (isSelected) {
66
+ return (
67
+ <TextLine fg={colors.text} bg={colors.selectedBg}>
68
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{label}</span>
69
+ <span fg={colors.muted}>{" "}</span>
70
+ <span fg={colors.muted}>{count}</span>
71
+ </TextLine>
72
+ )
73
+ }
74
+ return (
75
+ <TextLine>
76
+ <span fg={colors.text}>{label}</span>
77
+ <span fg={colors.muted}>{" "}</span>
78
+ <span fg={colors.count}>{count}</span>
79
+ </TextLine>
80
+ )
81
+ }
82
+
83
+ return (
84
+ <box position="absolute" zIndex={3000} left={0} top={0} width={width} height={height} backgroundColor={RGBA.fromInts(0, 0, 0, 110)} onMouseUp={onClose}>
85
+ <box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column" backgroundColor={RGBA.fromInts(20, 20, 28, 255)}>
86
+ <box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} flexDirection="column">
87
+ <TextLine>
88
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>{truncateText(title, innerWidth)}</span>
89
+ </TextLine>
90
+ <TextLine>
91
+ <span fg={colors.muted}>{truncateText(hint, innerWidth)}</span>
92
+ </TextLine>
93
+ <BlankRow />
94
+ <TextLine fg={colors.accent}>
95
+ <span fg={colors.muted}>{"\u203a "}</span>
96
+ <span fg={colors.text}>{truncateText(input, Math.max(1, innerWidth - 4))}</span>
97
+ <span fg={colors.accent}>{"\u2588"}</span>
98
+ </TextLine>
99
+ <BlankRow />
100
+ {state.status === "loading" && rows.length === 0 ? (
101
+ <TextLine><span fg={colors.muted}>loading…</span></TextLine>
102
+ ) : state.error ? (
103
+ <TextLine><span fg={colors.error}>{truncateText(state.error, innerWidth)}</span></TextLine>
104
+ ) : rows.length === 0 ? (
105
+ <TextLine><span fg={colors.muted}>no matches</span></TextLine>
106
+ ) : (
107
+ windowed.map((row, i) => (
108
+ <box key={row.value} height={1}>
109
+ {renderRow(row, windowStart + i === clampedIndex)}
110
+ </box>
111
+ ))
112
+ )}
113
+ {rows.length > windowEnd ? (
114
+ <TextLine><span fg={colors.muted}>{`+${rows.length - windowEnd} more…`}</span></TextLine>
115
+ ) : null}
116
+ </box>
117
+ </box>
118
+ </box>
119
+ )
120
+ }
@@ -0,0 +1,115 @@
1
+ import type { ScrollBoxRenderable } from "@opentui/core"
2
+ import { useLayoutEffect, useRef } from "react"
3
+ import type { LogItem } from "../domain.ts"
4
+ import { fitCell, formatLogTimestamp, formatTimestamp, logHeadline, logSeverityColor, relevantLogAttributes, truncateText, wrapTextLines } from "./format.ts"
5
+ import { Divider, PlainLine, TextLine } from "./primitives.tsx"
6
+ import type { ServiceLogState } from "./atoms.ts"
7
+ import { colors, SEPARATOR } from "./theme.ts"
8
+
9
+ export const ServiceLogsView = ({
10
+ serviceName,
11
+ logsState,
12
+ selectedIndex,
13
+ onSelectLog,
14
+ contentWidth,
15
+ bodyLines,
16
+ }: {
17
+ serviceName: string | null
18
+ logsState: ServiceLogState
19
+ selectedIndex: number
20
+ onSelectLog: (index: number) => void
21
+ contentWidth: number
22
+ bodyLines: number
23
+ }) => {
24
+ const timeWidth = 8
25
+ const levelWidth = 5
26
+ const traceWidth = 8
27
+ const messageWidth = Math.max(16, contentWidth - timeWidth - levelWidth - traceWidth - 3)
28
+
29
+ // Hooks must be called unconditionally — keep them above the early
30
+ // returns. The data-dependent values below all tolerate a 0-length
31
+ // data array, so this stays correct in the empty/loading/error cases.
32
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null)
33
+ const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, logsState.data.length - 1))
34
+ useLayoutEffect(() => {
35
+ scrollRef.current?.scrollChildIntoView(`svc-log-${safeSelectedIndex}`)
36
+ }, [safeSelectedIndex])
37
+
38
+ if (logsState.status === "loading" && logsState.data.length === 0) {
39
+ return <PlainLine text="Loading recent service logs..." fg={colors.count} />
40
+ }
41
+
42
+ if (logsState.status === "error") {
43
+ return <PlainLine text={logsState.error ?? "Could not load logs."} fg={colors.error} />
44
+ }
45
+
46
+ if (logsState.data.length === 0) {
47
+ return <PlainLine text={`No logs captured yet for service ${serviceName ?? "unknown"}.`} fg={colors.muted} />
48
+ }
49
+
50
+ const selectedLog = logsState.data[safeSelectedIndex] ?? null
51
+ const detailWidth = Math.max(16, contentWidth - 2)
52
+ const detailBodyLines = selectedLog ? wrapTextLines(selectedLog.body, detailWidth, 6) : []
53
+ const detailAttributeLines = selectedLog ? relevantLogAttributes(selectedLog).slice(0, 3) : []
54
+ const detailHeight = selectedLog ? 3 + detailBodyLines.length + detailAttributeLines.length : 0
55
+ const listHeight = Math.max(4, bodyLines - detailHeight - 1)
56
+
57
+ return (
58
+ <box flexDirection="column">
59
+ {selectedLog ? (
60
+ <>
61
+ <TextLine>
62
+ <span fg={logSeverityColor(selectedLog.severityText)}>{selectedLog.severityText.toLowerCase()}</span>
63
+ <span fg={colors.separator}>{SEPARATOR}</span>
64
+ <span fg={colors.muted}>{formatLogTimestamp(selectedLog.timestamp)}</span>
65
+ <span fg={colors.separator}>{SEPARATOR}</span>
66
+ <span fg={colors.count}>{selectedLog.traceId ? selectedLog.traceId.slice(-8) : "no-trace"}</span>
67
+ </TextLine>
68
+ <TextLine>
69
+ <span fg={colors.defaultService}>{selectedLog.scopeName ?? selectedLog.serviceName}</span>
70
+ {selectedLog.spanId ? <><span fg={colors.separator}>{SEPARATOR}</span><span fg={colors.muted}>{selectedLog.spanId.slice(-8)}</span></> : null}
71
+ </TextLine>
72
+ {detailBodyLines.map((line, index) => (
73
+ <PlainLine key={`log-detail-${selectedLog.id}-${index}`} text={line} fg={colors.text} />
74
+ ))}
75
+ {detailAttributeLines.map(([key, value]) => (
76
+ <TextLine key={`log-attr-${selectedLog.id}-${key}`}>
77
+ <span fg={colors.count}>{truncateText(key, 18).padEnd(18, " ")}</span>
78
+ <span fg={colors.muted}> </span>
79
+ <span fg={colors.muted}>{truncateText(value, Math.max(12, detailWidth - 20))}</span>
80
+ </TextLine>
81
+ ))}
82
+ <Divider width={contentWidth} />
83
+ </>
84
+ ) : null}
85
+ <TextLine fg={colors.muted}>
86
+ <span>{fitCell("time", timeWidth)}</span>
87
+ <span> </span>
88
+ <span>{fitCell("lvl", levelWidth)}</span>
89
+ <span> </span>
90
+ <span>{fitCell("trace", traceWidth)}</span>
91
+ <span> </span>
92
+ <span>{fitCell("message", messageWidth)}</span>
93
+ </TextLine>
94
+ <scrollbox ref={scrollRef} height={listHeight} flexGrow={0}>
95
+ {logsState.data.map((log, index) => {
96
+ const selected = index === safeSelectedIndex
97
+ const trace = log.traceId ? log.traceId.slice(-8) : "-"
98
+ return (
99
+ <box id={`svc-log-${index}`} key={log.id} height={1} onMouseDown={() => onSelectLog(index)}>
100
+ <TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
101
+ <span fg={colors.muted}>{fitCell(formatTimestamp(log.timestamp), timeWidth)}</span>
102
+ <span> </span>
103
+ <span fg={logSeverityColor(log.severityText)}>{fitCell(log.severityText.toLowerCase(), levelWidth)}</span>
104
+ <span> </span>
105
+ <span fg={colors.count}>{fitCell(trace, traceWidth)}</span>
106
+ <span> </span>
107
+ <span fg={selected ? colors.selectedText : colors.text}>{fitCell(logHeadline(log.body), messageWidth)}</span>
108
+ </TextLine>
109
+ </box>
110
+ )
111
+ })}
112
+ </scrollbox>
113
+ </box>
114
+ )
115
+ }