@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
package/AGENTS.md CHANGED
@@ -28,6 +28,11 @@
28
28
  - Build the web UI: `bun run web:build`
29
29
  - Dev the web UI (with hot reload): `bun run web:dev`
30
30
  - Typecheck: `bun run typecheck`
31
+ - Effect LSP diagnostics over the whole project: `bunx effect-language-service diagnostics --project tsconfig.json --format text`
32
+ - Effect LSP interactive setup wizard: `bunx effect-language-service setup`
33
+
34
+ ## Effect LSP
35
+ The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugins` entry. Editors that pick up the TypeScript workspace plugin (Zed, VSCode, Cursor, NVim via vtsls) will surface Effect-specific diagnostics, quick fixes, and refactors inline. In Zed this requires selecting the workspace TypeScript version — it does so automatically when `node_modules/typescript` is present.
31
36
 
32
37
  ## Verification
33
38
  - The built-in verification step is `bun run typecheck`.
@@ -146,7 +151,12 @@
146
151
  - `[` / `]`: switch services
147
152
  - `s`: cycle sort mode (recent → slowest → errors)
148
153
  - `t`: cycle theme (motel-default → tokyo-night → catppuccin)
149
- - `/`: enter filter mode (type to match on root operation name; `:error` restricts to failing traces)
154
+ - `/`: enter filter mode.
155
+ - **In the trace list (L0)** the input matches against the root operation name. Composable modifiers:
156
+ - `:error` — restrict to traces with at least one failed span (client-side)
157
+ - `:ai <query>` — FTS5-backed search against LLM prompt/response/tool content (`AI_FTS_KEYS`) across every span in the trace. Tokens are prefix-matched and implicitly AND'd. Debounced 250ms.
158
+ - Modifiers compose: `/ :ai rate limit :error`
159
+ - **In the waterfall (L1/L2)** the input runs a client-side substring match against each span's operation name and tag values. Non-matching spans are dimmed; the filter bar shows the live match count. `enter` commits (dim persists while you navigate); `esc` clears.
150
160
  - `f`: open attribute filter picker (browse span-attribute keys → values for the current service; `backspace` walks back to keys; `esc` in the trace list clears the active filter)
151
161
  - `a`: pause or resume auto-refresh
152
162
  - `r`: refresh now
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/motel",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "scripts": {
47
47
  "clear-motel-debug": "bun run skills/motel-debug/scripts/clear-motel-debug.ts",
48
- "dev": "bun run src/motel.ts tui",
48
+ "dev": "MOTEL_OTEL_ENABLED=true bun run src/motel.ts tui",
49
49
  "start": "bun run src/motel.ts tui",
50
50
  "daemon": "bun run src/motel.ts daemon",
51
51
  "status": "bun run src/motel.ts status",
@@ -67,10 +67,12 @@
67
67
  "log-stats": "bun run src/cli.ts log-stats",
68
68
  "web:dev": "cd web && npx vite",
69
69
  "web:build": "cd web && npx vite build",
70
+ "story:chat": "bun run src/storybook/aiChatStory.tsx",
70
71
  "typecheck": "tsc --noEmit",
71
72
  "prepublishOnly": "bun run web:build"
72
73
  },
73
74
  "devDependencies": {
75
+ "@effect/language-service": "^0.85.1",
74
76
  "@types/bun": "^1.3.12",
75
77
  "@types/react": "^19.2.14",
76
78
  "typescript": "^6.0.2"
@@ -78,7 +80,7 @@
78
80
  "dependencies": {
79
81
  "@effect/atom-react": "^4.0.0-beta.49",
80
82
  "@effect/opentelemetry": "^4.0.0-beta.49",
81
- "@effect/platform-bun": "^4.0.0-beta.49",
83
+ "@effect/platform-bun": "^4.0.0-beta.50",
82
84
  "@opentelemetry/api": "^1.9.0",
83
85
  "@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
84
86
  "@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
package/src/App.tsx CHANGED
@@ -8,19 +8,173 @@ import { useAppLayout } from "./ui/app/useAppLayout.ts"
8
8
  import { useTraceScreenData } from "./ui/app/useTraceScreenData.ts"
9
9
  import { TraceWorkspace } from "./ui/app/TraceWorkspace.tsx"
10
10
  import {
11
+ type AttrFacetState,
11
12
  attrPickerIndexAtom,
12
13
  attrPickerInputAtom,
13
14
  attrPickerModeAtom,
14
15
  attrFacetStateAtom,
16
+ chatDetailChunkIdAtom,
17
+ chatDetailScrollOffsetAtom,
15
18
  noticeAtom,
16
19
  persistSelectedTheme,
20
+ selectedAttrIndexAtom,
21
+ selectedChatChunkIdAtom,
17
22
  selectedThemeAtom,
23
+ waterfallFilterModeAtom,
24
+ waterfallFilterTextAtom,
18
25
  } from "./ui/state.ts"
26
+ import type { ThemeName } from "./ui/theme.ts"
19
27
  import { applyTheme, colors, SEPARATOR, themeLabel } from "./ui/theme.ts"
20
- import { getVisibleSpans } from "./ui/Waterfall.tsx"
21
28
  import { useKeyboardNav } from "./ui/useKeyboardNav.ts"
22
29
  import { AttrFilterModal } from "./ui/AttrFilterModal.tsx"
23
30
  import { useAttrFilterPicker } from "./ui/useAttrFilterPicker.ts"
31
+ import { getVisibleSpans } from "./ui/Waterfall.tsx"
32
+
33
+ const NOTICE_TIMEOUT_MS = 2500
34
+
35
+ const buildHeaderModel = ({
36
+ headerFooterWidth,
37
+ selectedTraceService,
38
+ activeAttrKey,
39
+ activeAttrValue,
40
+ autoRefresh,
41
+ fetchedAt,
42
+ status,
43
+ }: {
44
+ readonly headerFooterWidth: number
45
+ readonly selectedTraceService: string | null
46
+ readonly activeAttrKey: string | null
47
+ readonly activeAttrValue: string | null
48
+ readonly autoRefresh: boolean
49
+ readonly fetchedAt: Date | null
50
+ readonly status: string
51
+ }) => {
52
+ const serviceLabel = selectedTraceService ?? "none"
53
+ const autoLabel = autoRefresh ? "● live" : "○ paused"
54
+ const attrFilterLabel = activeAttrKey && activeAttrValue
55
+ ? ` [${activeAttrKey}=${activeAttrValue.length > 20 ? `${activeAttrValue.slice(0, 19)}…` : activeAttrValue}]`
56
+ : ""
57
+ const right = fetchedAt
58
+ ? `${autoLabel} ${formatTimestamp(fetchedAt)}`
59
+ : status === "loading"
60
+ ? "loading traces..."
61
+ : ""
62
+ const leftLength = "MOTEL".length + SEPARATOR.length + serviceLabel.length + attrFilterLabel.length
63
+ const gap = Math.max(2, headerFooterWidth - leftLength - right.length)
64
+
65
+ return {
66
+ serviceLabel,
67
+ attrFilterLabel,
68
+ right,
69
+ gap,
70
+ } as const
71
+ }
72
+
73
+ const AppHeader = ({
74
+ serviceLabel,
75
+ attrFilterLabel,
76
+ gap,
77
+ right,
78
+ }: {
79
+ readonly serviceLabel: string
80
+ readonly attrFilterLabel: string
81
+ readonly gap: number
82
+ readonly right: string
83
+ }) => (
84
+ <box paddingLeft={1} paddingRight={1} flexDirection="column">
85
+ <TextLine>
86
+ <span fg={colors.muted} attributes={TextAttributes.BOLD}>MOTEL</span>
87
+ <span fg={colors.separator}>{SEPARATOR}</span>
88
+ <span fg={colors.muted}>{serviceLabel}</span>
89
+ {attrFilterLabel ? <span fg={colors.accent} attributes={TextAttributes.BOLD}>{attrFilterLabel}</span> : null}
90
+ <span fg={colors.muted}>{" ".repeat(gap)}</span>
91
+ <span fg={colors.muted} attributes={TextAttributes.BOLD}>{right}</span>
92
+ </TextLine>
93
+ </box>
94
+ )
95
+
96
+ const AppFooter = ({
97
+ showSplit,
98
+ footerHeight,
99
+ contentWidth,
100
+ leftPaneWidth,
101
+ rightPaneWidth,
102
+ footerNotice,
103
+ spanNavActive,
104
+ detailView,
105
+ autoRefresh,
106
+ headerFooterWidth,
107
+ }: {
108
+ readonly showSplit: boolean
109
+ readonly footerHeight: number
110
+ readonly contentWidth: number
111
+ readonly leftPaneWidth: number
112
+ readonly rightPaneWidth: number
113
+ readonly footerNotice: string | null
114
+ readonly spanNavActive: boolean
115
+ readonly detailView: "waterfall" | "span-detail" | "service-logs"
116
+ readonly autoRefresh: boolean
117
+ readonly headerFooterWidth: number
118
+ }) => {
119
+ if (footerHeight <= 0) return null
120
+
121
+ return (
122
+ <>
123
+ {showSplit
124
+ ? <SplitDivider leftWidth={leftPaneWidth} junction={"┴"} rightWidth={rightPaneWidth} />
125
+ : <Divider width={contentWidth} />}
126
+ <box paddingLeft={1} paddingRight={1} flexDirection="column" height={footerHeight}>
127
+ {footerNotice ? (
128
+ <PlainLine text={footerNotice} fg={colors.count} />
129
+ ) : (
130
+ <FooterHints spanNavActive={spanNavActive} detailView={detailView} autoRefresh={autoRefresh} width={headerFooterWidth} />
131
+ )}
132
+ </box>
133
+ </>
134
+ )
135
+ }
136
+
137
+ const AppOverlays = ({
138
+ width,
139
+ height,
140
+ showHelp,
141
+ autoRefresh,
142
+ selectedTheme,
143
+ setShowHelp,
144
+ pickerMode,
145
+ pickerInput,
146
+ pickerIndex,
147
+ activeAttrKey,
148
+ attrFacets,
149
+ }: {
150
+ readonly width: number
151
+ readonly height: number
152
+ readonly showHelp: boolean
153
+ readonly autoRefresh: boolean
154
+ readonly selectedTheme: ThemeName
155
+ readonly setShowHelp: (value: boolean) => void
156
+ readonly pickerMode: "off" | "keys" | "values"
157
+ readonly pickerInput: string
158
+ readonly pickerIndex: number
159
+ readonly activeAttrKey: string | null
160
+ readonly attrFacets: AttrFacetState
161
+ }) => (
162
+ <>
163
+ {showHelp ? <HelpModal width={width} height={height} autoRefresh={autoRefresh} themeLabel={themeLabel(selectedTheme)} onClose={() => setShowHelp(false)} /> : null}
164
+ {pickerMode !== "off" ? (
165
+ <AttrFilterModal
166
+ width={width}
167
+ height={height}
168
+ mode={pickerMode}
169
+ input={pickerInput}
170
+ selectedIndex={pickerIndex}
171
+ selectedKey={activeAttrKey}
172
+ state={attrFacets}
173
+ onClose={() => { /* handled via keyboard */ }}
174
+ />
175
+ ) : null}
176
+ </>
177
+ )
24
178
 
25
179
  export const App = () => {
26
180
  const { width, height } = useTerminalDimensions()
@@ -52,11 +206,19 @@ export const App = () => {
52
206
  selectedTraceSummary,
53
207
  selectedTrace,
54
208
  filteredTraces,
209
+ aiCallDetailState,
210
+ aiChatChunks,
55
211
  } = useTraceScreenData()
56
212
  const [pickerMode] = useAtom(attrPickerModeAtom)
57
213
  const [pickerInput] = useAtom(attrPickerInputAtom)
58
214
  const [pickerIndex] = useAtom(attrPickerIndexAtom)
59
215
  const [attrFacets] = useAtom(attrFacetStateAtom)
216
+ const [waterfallFilterMode] = useAtom(waterfallFilterModeAtom)
217
+ const [waterfallFilterText] = useAtom(waterfallFilterTextAtom)
218
+ const [selectedAttrIndex] = useAtom(selectedAttrIndexAtom)
219
+ const [selectedChatChunkId, setSelectedChatChunkId] = useAtom(selectedChatChunkIdAtom)
220
+ const [chatDetailChunkId, setChatDetailChunkId] = useAtom(chatDetailChunkIdAtom)
221
+ const [chatDetailScrollOffset, setChatDetailScrollOffset] = useAtom(chatDetailScrollOffsetAtom)
60
222
  useAttrFilterPicker(activeAttrKey)
61
223
 
62
224
  const layout = useAppLayout({ width, height, notice, detailView, selectedSpanIndex })
@@ -86,7 +248,7 @@ export const App = () => {
86
248
  setNotice(message)
87
249
  noticeTimeoutRef.current = globalThis.setTimeout(() => {
88
250
  setNotice((current) => (current === message ? null : current))
89
- }, 2500)
251
+ }, NOTICE_TIMEOUT_MS)
90
252
  }
91
253
 
92
254
  useEffect(() => () => {
@@ -102,6 +264,7 @@ export const App = () => {
102
264
  const { spanNavActive } = useKeyboardNav({
103
265
  selectedTrace,
104
266
  filteredTraces,
267
+ aiChatChunks,
105
268
  isWideLayout,
106
269
  wideBodyLines,
107
270
  narrowBodyLines,
@@ -110,30 +273,41 @@ export const App = () => {
110
273
  flashNotice,
111
274
  })
112
275
 
113
- const headerServiceLabel = selectedTraceService ?? "none"
114
- const autoLabel = autoRefresh ? "● live" : "○ paused"
115
- const attrFilterLabel = activeAttrKey && activeAttrValue
116
- ? ` [${activeAttrKey}=${activeAttrValue.length > 20 ? `${activeAttrValue.slice(0, 19)}…` : activeAttrValue}]`
117
- : ""
118
- const headerRight = traceState.fetchedAt
119
- ? `${autoLabel} ${formatTimestamp(traceState.fetchedAt)}`
120
- : traceState.status === "loading"
121
- ? "loading traces..."
122
- : ""
123
- const headerLeftLen = "MOTEL".length + SEPARATOR.length + headerServiceLabel.length + attrFilterLabel.length
124
- const headerGap = Math.max(2, headerFooterWidth - headerLeftLen - headerRight.length)
125
- const visibleFooterNotice = footerNotice
276
+ const headerModel = buildHeaderModel({
277
+ headerFooterWidth,
278
+ selectedTraceService,
279
+ activeAttrKey,
280
+ activeAttrValue,
281
+ autoRefresh,
282
+ fetchedAt: traceState.fetchedAt,
283
+ status: traceState.status,
284
+ })
126
285
 
127
286
  const selectTraceById = useCallback((traceId: string) => {
128
287
  const index = traceState.data.findIndex((trace) => trace.traceId === traceId)
129
288
  if (index >= 0) setSelectedTraceIndex(index)
130
289
  }, [setSelectedTraceIndex, traceState.data])
131
290
 
291
+ const visibleSpans = useMemo(
292
+ () => selectedTrace ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds) : [],
293
+ [selectedTrace, collapsedSpanIds],
294
+ )
295
+
296
+ const openChatChunkDetail = useCallback((chunkId: string) => {
297
+ setSelectedChatChunkId(chunkId)
298
+ setChatDetailChunkId(chunkId)
299
+ setChatDetailScrollOffset(0)
300
+ }, [setSelectedChatChunkId, setChatDetailChunkId, setChatDetailScrollOffset])
301
+
302
+ const closeChatChunkDetail = useCallback(() => {
303
+ setChatDetailChunkId(null)
304
+ setChatDetailScrollOffset(0)
305
+ }, [setChatDetailChunkId, setChatDetailScrollOffset])
306
+
132
307
  const selectSpan = useCallback((index: number) => {
133
- if (!selectedTrace) return
134
- const visibleCount = getVisibleSpans(selectedTrace.spans, collapsedSpanIds).length
135
- setSelectedSpanIndex(Math.max(0, Math.min(index, visibleCount - 1)))
136
- }, [collapsedSpanIds, selectedTrace, setSelectedSpanIndex])
308
+ if (visibleSpans.length === 0) return
309
+ setSelectedSpanIndex(Math.max(0, Math.min(index, visibleSpans.length - 1)))
310
+ }, [setSelectedSpanIndex, visibleSpans])
137
311
 
138
312
  const traceListProps = useMemo(() => ({
139
313
  traces: filteredTraces,
@@ -150,27 +324,23 @@ export const App = () => {
150
324
  onSelectTrace: selectTraceById,
151
325
  } as const), [filteredTraces, selectedTraceSummary?.traceId, traceState.status, traceState.error, leftContentWidth, traceState.services, selectedTraceService, spanNavActive, filterText, traceSort, traceState.data.length, selectTraceById])
152
326
 
153
- const filteredSpans = selectedTrace ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds) : []
154
- const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
327
+ const selectedSpan = selectedSpanIndex !== null ? visibleSpans[selectedSpanIndex] ?? null : null
155
328
  const selectedSpanLogs = useMemo(
156
329
  () => selectedSpan ? logState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
157
330
  [selectedSpan, logState.data],
158
331
  )
159
332
 
160
- const showSplit = isWideLayout
333
+ // Top/bottom frame dividers only render junction glyphs (`┬` / `┴`)
334
+ // when there's actually a vertical SeparatorColumn in the workspace
335
+ // below/above them to meet. Service-logs view is wide but single-pane,
336
+ // so its frame dividers must be plain — otherwise the junction floats
337
+ // above an empty column and leaves a visible stale sliver when
338
+ // toggling tab back and forth with the trace view.
339
+ const showSplit = isWideLayout && detailView !== "service-logs"
161
340
 
162
341
  return (
163
342
  <box width={width ?? 100} height={height ?? 24} flexGrow={1} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)}>
164
- <box paddingLeft={1} paddingRight={1} flexDirection="column">
165
- <TextLine>
166
- <span fg={colors.muted} attributes={TextAttributes.BOLD}>MOTEL</span>
167
- <span fg={colors.separator}>{SEPARATOR}</span>
168
- <span fg={colors.muted}>{headerServiceLabel}</span>
169
- {attrFilterLabel ? <span fg={colors.accent} attributes={TextAttributes.BOLD}>{attrFilterLabel}</span> : null}
170
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
171
- <span fg={colors.muted} attributes={TextAttributes.BOLD}>{headerRight}</span>
172
- </TextLine>
173
- </box>
343
+ <AppHeader {...headerModel} />
174
344
  {showSplit
175
345
  ? <SplitDivider leftWidth={leftPaneWidth} junction={"┬"} rightWidth={rightPaneWidth} />
176
346
  : <Divider width={contentWidth} />}
@@ -179,6 +349,8 @@ export const App = () => {
179
349
  detailView={detailView}
180
350
  filterMode={filterMode}
181
351
  filterText={filterText}
352
+ waterfallFilterMode={waterfallFilterMode}
353
+ waterfallFilterText={waterfallFilterText}
182
354
  traceListProps={traceListProps}
183
355
  selectedTraceService={selectedTraceService}
184
356
  serviceLogState={serviceLogState}
@@ -193,35 +365,43 @@ export const App = () => {
193
365
  viewLevel={viewLevel}
194
366
  selectedSpan={selectedSpan}
195
367
  selectedSpanLogs={selectedSpanLogs}
368
+ selectedAttrIndex={selectedAttrIndex}
369
+ aiCallDetailState={aiCallDetailState}
370
+ aiChatChunks={aiChatChunks}
371
+ selectedChatChunkId={selectedChatChunkId}
372
+ onSelectChatChunk={setSelectedChatChunkId}
373
+ chatDetailChunkId={chatDetailChunkId}
374
+ onOpenChatChunkDetail={openChatChunkDetail}
375
+ onCloseChatChunkDetail={closeChatChunkDetail}
376
+ chatDetailScrollOffset={chatDetailScrollOffset}
377
+ onSetChatDetailScrollOffset={(updater) => setChatDetailScrollOffset(updater)}
196
378
  selectSpan={selectSpan}
197
379
  />
198
- {footerHeight > 0 ? (
199
- <>
200
- {showSplit
201
- ? <SplitDivider leftWidth={leftPaneWidth} junction={"┴"} rightWidth={rightPaneWidth} />
202
- : <Divider width={contentWidth} />}
203
- <box paddingLeft={1} paddingRight={1} flexDirection="column" height={footerHeight}>
204
- {visibleFooterNotice ? (
205
- <PlainLine text={visibleFooterNotice} fg={colors.count} />
206
- ) : (
207
- <FooterHints spanNavActive={spanNavActive} detailView={detailView} autoRefresh={autoRefresh} width={headerFooterWidth} />
208
- )}
209
- </box>
210
- </>
211
- ) : null}
212
- {showHelp ? <HelpModal width={width ?? 100} height={height ?? 24} autoRefresh={autoRefresh} themeLabel={themeLabel(selectedTheme)} onClose={() => setShowHelp(false)} /> : null}
213
- {pickerMode !== "off" ? (
214
- <AttrFilterModal
215
- width={width ?? 100}
216
- height={height ?? 24}
217
- mode={pickerMode}
218
- input={pickerInput}
219
- selectedIndex={pickerIndex}
220
- selectedKey={activeAttrKey}
221
- state={attrFacets}
222
- onClose={() => { /* handled via keyboard */ }}
223
- />
224
- ) : null}
380
+ <AppFooter
381
+ showSplit={showSplit}
382
+ footerHeight={footerHeight}
383
+ contentWidth={contentWidth}
384
+ leftPaneWidth={leftPaneWidth}
385
+ rightPaneWidth={rightPaneWidth}
386
+ footerNotice={footerNotice}
387
+ spanNavActive={spanNavActive}
388
+ detailView={detailView}
389
+ autoRefresh={autoRefresh}
390
+ headerFooterWidth={headerFooterWidth}
391
+ />
392
+ <AppOverlays
393
+ width={width ?? 100}
394
+ height={height ?? 24}
395
+ showHelp={showHelp}
396
+ autoRefresh={autoRefresh}
397
+ selectedTheme={selectedTheme}
398
+ setShowHelp={setShowHelp}
399
+ pickerMode={pickerMode}
400
+ pickerInput={pickerInput}
401
+ pickerIndex={pickerIndex}
402
+ activeAttrKey={activeAttrKey}
403
+ attrFacets={attrFacets}
404
+ />
225
405
  </box>
226
406
  )
227
407
  }
@@ -4,23 +4,70 @@ import * as fs from "node:fs"
4
4
  import * as os from "node:os"
5
5
  import * as path from "node:path"
6
6
  import { createDaemonManager } from "./daemon.js"
7
+ import { MOTEL_SERVICE_ID } from "./registry.js"
7
8
 
8
9
  const repoRoot = path.resolve(import.meta.dir, "..")
9
10
 
10
11
  const randomPort = () => 29000 + Math.floor(Math.random() * 2000)
11
12
 
12
- const makeHarness = () => {
13
+ interface Harness {
14
+ readonly runtimeDir: string
15
+ readonly port: number
16
+ readonly databasePath: string
17
+ readonly manager: ReturnType<typeof createDaemonManager>
18
+ }
19
+
20
+ const makeHarness = (): Harness => {
13
21
  const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-test-"))
22
+ const port = randomPort()
23
+ const databasePath = path.join(runtimeDir, "telemetry.sqlite")
14
24
  const manager = createDaemonManager({
15
25
  repoRoot,
16
26
  runtimeDir,
17
- databasePath: path.join(runtimeDir, "telemetry.sqlite"),
18
- port: randomPort(),
27
+ databasePath,
28
+ port,
19
29
  })
20
- return {
21
- runtimeDir,
22
- manager,
23
- }
30
+ return { runtimeDir, port, databasePath, manager }
31
+ }
32
+
33
+ /**
34
+ * Start a motel-shaped HTTP server on a test port that answers
35
+ * /api/health with an arbitrary delay. Used to simulate a real daemon
36
+ * that's alive + holding the port but currently slow — the exact
37
+ * scenario that makes `bun dev` fail with EADDRINUSE when the
38
+ * supervisor's health probe times out and it tries to spawn a
39
+ * duplicate. Returns a stop() that releases the port.
40
+ */
41
+ const startFakeDaemon = (opts: {
42
+ readonly port: number
43
+ readonly databasePath: string
44
+ readonly delayMs: number
45
+ }) => {
46
+ const startedAt = new Date().toISOString()
47
+ const server = Bun.serve({
48
+ port: opts.port,
49
+ hostname: "127.0.0.1",
50
+ async fetch(req) {
51
+ const url = new URL(req.url)
52
+ if (url.pathname !== "/api/health") {
53
+ return new Response("not found", { status: 404 })
54
+ }
55
+ if (opts.delayMs > 0) {
56
+ await new Promise((resolve) => setTimeout(resolve, opts.delayMs))
57
+ }
58
+ return Response.json({
59
+ ok: true,
60
+ service: MOTEL_SERVICE_ID,
61
+ databasePath: opts.databasePath,
62
+ pid: process.pid,
63
+ url: `http://127.0.0.1:${opts.port}`,
64
+ workdir: process.cwd(),
65
+ startedAt,
66
+ version: "0.0.0-test",
67
+ })
68
+ },
69
+ })
70
+ return { stop: () => server.stop(true) }
24
71
  }
25
72
 
26
73
  const activeHarnesses: Array<ReturnType<typeof makeHarness>> = []
@@ -33,6 +80,96 @@ afterEach(async () => {
33
80
  })
34
81
 
35
82
  describe("daemon manager", () => {
83
+ test("warm-start via registry is fast even when HTTP health is slow", async () => {
84
+ // The failure mode we're preventing: a fully-healthy motel daemon
85
+ // is alive for our cwd, but its /api/health response queues
86
+ // behind heavy OTLP ingest traffic and takes >1s (seen on this
87
+ // machine: /api/health taking 4s under real load). With an
88
+ // HTTP-only probe the TUI would stall for seconds on every
89
+ // launch; the registry-based fast path should close in <100ms.
90
+ const harness = makeHarness()
91
+ activeHarnesses.push(harness)
92
+
93
+ // Scope the motel registry to the harness's runtime dir so we
94
+ // neither read nor pollute the user's real ~/.local/state/motel.
95
+ const registryRoot = path.join(harness.runtimeDir, "state")
96
+ const originalXdg = process.env.XDG_STATE_HOME
97
+ process.env.XDG_STATE_HOME = registryRoot
98
+ const registryInstancesDir = path.join(registryRoot, "motel", "instances")
99
+ fs.mkdirSync(registryInstancesDir, { recursive: true })
100
+
101
+ // Seed an entry that points at THIS test process. It's alive
102
+ // (we're executing), so isAlive(pid) will report true — the
103
+ // supervisor's fast path will adopt without ever issuing an
104
+ // HTTP request.
105
+ const entryPath = path.join(registryInstancesDir, `${process.pid}.json`)
106
+ fs.writeFileSync(entryPath, JSON.stringify({
107
+ pid: process.pid,
108
+ url: `http://127.0.0.1:${harness.port}`,
109
+ workdir: process.cwd(),
110
+ startedAt: new Date().toISOString(),
111
+ version: "0.0.0-test",
112
+ databasePath: harness.databasePath,
113
+ }), "utf8")
114
+
115
+ // Park a real-but-slow listener on the port. If the supervisor
116
+ // ever falls back to HTTP we'd wait out the 5s delay; a passing
117
+ // test proves the fast path took over.
118
+ const fake = startFakeDaemon({
119
+ port: harness.port,
120
+ databasePath: harness.databasePath,
121
+ delayMs: 5_000,
122
+ })
123
+
124
+ try {
125
+ const start = performance.now()
126
+ const status = await Effect.runPromise(harness.manager.ensure)
127
+ const elapsed = performance.now() - start
128
+ expect(status.running).toBe(true)
129
+ expect(status.managed).toBe(true)
130
+ expect(status.pid).toBe(process.pid)
131
+ // Generous — real-world is <10ms. Primarily guarding against
132
+ // a future regression that silently reintroduces an HTTP probe
133
+ // on the hot path.
134
+ expect(elapsed).toBeLessThan(500)
135
+ } finally {
136
+ fake.stop()
137
+ fs.rmSync(entryPath, { force: true })
138
+ if (originalXdg === undefined) delete process.env.XDG_STATE_HOME
139
+ else process.env.XDG_STATE_HOME = originalXdg
140
+ }
141
+ })
142
+
143
+ test("adopts a slow-to-respond healthy daemon instead of spawning a duplicate", async () => {
144
+ // Reproduces the `bun dev` EADDRINUSE flake. A real daemon is alive
145
+ // and holds the port, but its /api/health response takes longer
146
+ // than the supervisor's 750ms fetch timeout (e.g. the daemon is
147
+ // backfilling FTS or the SQLite writer lock is held). The buggy
148
+ // behaviour: supervisor thinks the port is free, spawns a fresh
149
+ // daemon child, the child tries to bind() → EADDRINUSE → child
150
+ // exits → supervisor throws "exited before becoming healthy".
151
+ //
152
+ // Correct behaviour: supervisor retries the health probe with a
153
+ // longer budget before declaring the port empty, finds the
154
+ // (slow) healthy motel on it, and adopts.
155
+ const harness = makeHarness()
156
+ activeHarnesses.push(harness)
157
+ const fake = startFakeDaemon({
158
+ port: harness.port,
159
+ databasePath: harness.databasePath,
160
+ delayMs: 1_500,
161
+ })
162
+ try {
163
+ const status = await Effect.runPromise(harness.manager.ensure)
164
+ expect(status.running).toBe(true)
165
+ expect(status.managed).toBe(true)
166
+ // PID belongs to the fake test server, not a newly-spawned daemon.
167
+ expect(status.pid).toBe(process.pid)
168
+ } finally {
169
+ fake.stop()
170
+ }
171
+ })
172
+
36
173
  test("starts once, reuses the same daemon, and stops cleanly", async () => {
37
174
  const harness = makeHarness()
38
175
  activeHarnesses.push(harness)