@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,198 @@
1
+ import { RGBA, TextAttributes } from "@opentui/core"
2
+ import { Children } from "react"
3
+ import { colors } from "./theme.ts"
4
+ import { fitCell, truncateText } from "./format.ts"
5
+ import type { DetailView } from "./atoms.ts"
6
+
7
+ export const BlankRow = () => <box height={1} />
8
+
9
+ export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: string; fg?: string; bold?: boolean }) => (
10
+ <box height={1}>
11
+ {bold ? (
12
+ <text wrapMode="none" truncate fg={fg} attributes={TextAttributes.BOLD}>
13
+ {text}
14
+ </text>
15
+ ) : (
16
+ <text wrapMode="none" truncate fg={fg}>
17
+ {text}
18
+ </text>
19
+ )}
20
+ </box>
21
+ )
22
+
23
+ const collapseFormattingWhitespace = (children: React.ReactNode) => Children.toArray(children).filter((child) => {
24
+ if (typeof child !== "string") return true
25
+ if (child.trim().length > 0) return true
26
+ // OpenTUI preserves JSX indentation/newline text nodes, so strip the
27
+ // formatting-only whitespace between inline spans while keeping any
28
+ // intentional in-band spaces that callers render explicitly.
29
+ return !/[\r\n\t]/.test(child)
30
+ })
31
+
32
+ export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => {
33
+ const inlineChildren = collapseFormattingWhitespace(children)
34
+
35
+ return (
36
+ <box height={1}>
37
+ {bg ? (
38
+ <text wrapMode="none" truncate fg={fg} bg={bg}>
39
+ {inlineChildren}
40
+ </text>
41
+ ) : (
42
+ <text wrapMode="none" truncate fg={fg}>
43
+ {inlineChildren}
44
+ </text>
45
+ )}
46
+ </box>
47
+ )
48
+ }
49
+
50
+ export const AlignedHeaderLine = ({ left, right, width, rightFg = colors.muted }: { left: string; right: string; width: number; rightFg?: string }) => {
51
+ const availableRightWidth = Math.max(8, width - left.length - 2)
52
+ const rightText = truncateText(right, availableRightWidth)
53
+ const gap = Math.max(2, width - left.length - rightText.length)
54
+
55
+ return (
56
+ <TextLine>
57
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{left}</span>
58
+ <span fg={colors.muted}>{" ".repeat(gap)}</span>
59
+ <span fg={rightFg}>{rightText}</span>
60
+ </TextLine>
61
+ )
62
+ }
63
+
64
+ export const Divider = ({ width }: { width: number }) => (
65
+ <PlainLine text={"\u2500".repeat(Math.max(1, width))} fg={colors.separator} />
66
+ )
67
+
68
+ /** Horizontal divider split into left ─ junction ─ right, using flex row so
69
+ * the junction character lands at exactly the same column as the SeparatorColumn. */
70
+ export const SplitDivider = ({ leftWidth, junction, rightWidth }: { leftWidth: number; junction: string; rightWidth: number }) => (
71
+ <box flexDirection="row" height={1}>
72
+ <box width={leftWidth}><text fg={colors.separator} wrapMode="none" truncate>{"\u2500".repeat(leftWidth)}</text></box>
73
+ <box width={1}><text fg={colors.separator}>{junction}</text></box>
74
+ <box width={rightWidth}><text fg={colors.separator} wrapMode="none" truncate>{"\u2500".repeat(rightWidth)}</text></box>
75
+ </box>
76
+ )
77
+
78
+ /** Row index → junction character override. Callers supply exactly the
79
+ * glyph they want at each row so the separator lines up with whatever
80
+ * divider geometry the neighboring panes happen to have:
81
+ * - `\u251c` (├) when only the right pane has a divider at that row
82
+ * - `\u2524` (┤) when only the left pane has a divider at that row
83
+ * - `\u253c` (┼) when both do
84
+ */
85
+ export const SeparatorColumn = ({ height, junctionChars }: { height: number; junctionChars?: ReadonlyMap<number, string> }) => {
86
+ const lines: string[] = []
87
+ for (let i = 0; i < Math.max(1, height); i++) {
88
+ lines.push(junctionChars?.get(i) ?? "\u2502")
89
+ }
90
+ return (
91
+ <box width={1} height={height} overflow="hidden">
92
+ <text fg={colors.separator}>{lines.join("\n")}</text>
93
+ </box>
94
+ )
95
+ }
96
+
97
+ export const FilterBar = ({ text, width }: { text: string; width: number }) => {
98
+ // Layout: "/<text>█ op name · :error · :ai <query>"
99
+ // Cursor block sits immediately after the typed text (no padding —
100
+ // fitCell would pad the empty string to fill the available width and
101
+ // push the cursor to the far right, which looked like a bug). The
102
+ // hint only renders while the input is empty so real typing never
103
+ // collides with it.
104
+ const hint = text.length === 0 ? " op name · :error · :ai <query>" : ""
105
+ const textMaxWidth = Math.max(1, width - 2 - hint.length)
106
+ const displayText = text.length > textMaxWidth ? text.slice(text.length - textMaxWidth) : text
107
+ return (
108
+ <TextLine fg={colors.accent}>
109
+ <span fg={colors.muted}>{"/"}</span>
110
+ <span fg={colors.text}>{displayText}</span>
111
+ <span fg={colors.accent}>{"\u2588"}</span>
112
+ {hint ? <span fg={colors.muted}>{hint}</span> : null}
113
+ </TextLine>
114
+ )
115
+ }
116
+
117
+ const FooterKey = ({ label }: { label: string }) => <span fg={colors.count} attributes={TextAttributes.BOLD}>{label}</span>
118
+
119
+ export const HelpModal = ({ width, height, autoRefresh, themeLabel, onClose }: { width: number; height: number; autoRefresh: boolean; themeLabel: string; onClose: () => void }) => {
120
+ const panelWidth = Math.min(76, Math.max(52, width - 10))
121
+ const left = Math.max(2, Math.floor((width - panelWidth) / 2))
122
+ const top = Math.max(1, Math.floor(height / 5))
123
+ const sectionGap = Math.max(1, panelWidth - 24)
124
+ const row = (key: string, desc: string) => (
125
+ <TextLine>
126
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>{key.padEnd(11)}</span>
127
+ <span fg={colors.muted}>{desc}</span>
128
+ </TextLine>
129
+ )
130
+
131
+ return (
132
+ <box position="absolute" zIndex={3000} left={0} top={0} width={width} height={height} backgroundColor={RGBA.fromInts(0, 0, 0, 110)} onMouseUp={onClose}>
133
+ <box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column" backgroundColor={RGBA.fromInts(20, 20, 28, 255)}>
134
+ <box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} flexDirection="column">
135
+ <TextLine>
136
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>Help</span>
137
+ <span fg={colors.muted}>{" ".repeat(sectionGap)}</span>
138
+ <span fg={colors.muted}>esc / enter / ? close</span>
139
+ </TextLine>
140
+ <BlankRow />
141
+ {row("j k ↑ ↓", "move selection")}
142
+ {row("enter", "select spans / open detail / jump from logs")}
143
+ {row("esc", "back out of detail or span selection")}
144
+ {row("t", `cycle theme (${themeLabel})`)}
145
+ {row("tab", "toggle service logs")}
146
+ {row("[ ]", "switch service")}
147
+ {row("/", "filter by root operation (\u2003:error, :ai <query>)")}
148
+ {row("f", "filter traces by span attribute")}
149
+ {row("s", "cycle sort mode")}
150
+ {row("a", `auto refresh ${autoRefresh ? "on" : "off"}`)}
151
+ {row("r", "refresh traces")}
152
+ {row("y", "copy selected trace/span ids")}
153
+ {row("o / O", "open trace / open web UI")}
154
+ {row("gg / G", "jump to first / last")}
155
+ {row("^d / ^u", "page down / up")}
156
+ {row("q", "quit")}
157
+ </box>
158
+ </box>
159
+ </box>
160
+ )
161
+ }
162
+
163
+ export const FooterHints = ({ spanNavActive, detailView, autoRefresh, width: _width }: { spanNavActive: boolean; detailView: DetailView; autoRefresh: boolean; width: number }) => {
164
+ const enterAction = detailView === "service-logs"
165
+ ? "trace"
166
+ : spanNavActive && detailView === "waterfall"
167
+ ? "detail"
168
+ : "spans"
169
+ const escAction = spanNavActive
170
+ ? (detailView === "span-detail" ? "back" : "traces")
171
+ : null
172
+ const items: Array<[string, string]> = [
173
+ ["j/k", "move"],
174
+ ["enter", enterAction],
175
+ ...(escAction ? [["esc", escAction] as [string, string]] : []),
176
+ ["t", "theme"],
177
+ ["tab", "logs"],
178
+ ["/", "filter"],
179
+ ["f", "attr"],
180
+ ["s", "sort"],
181
+ ["a", autoRefresh ? "live" : "paused"],
182
+ ["?", "help"],
183
+ ["q", "quit"],
184
+ ]
185
+ const renderItems = (items: ReadonlyArray<readonly [string, string]>) => (
186
+ items.flatMap(([key, label], index) => [
187
+ <FooterKey key={`${key}-key`} label={key} />,
188
+ <span key={`${key}-label`} fg={colors.muted}>{` ${label}`}</span>,
189
+ index < items.length - 1 ? <span key={`${key}-sep`} fg={colors.separator}>{" · "}</span> : null,
190
+ ])
191
+ )
192
+
193
+ return (
194
+ <TextLine>
195
+ {renderItems(items)}
196
+ </TextLine>
197
+ )
198
+ }
@@ -0,0 +1,6 @@
1
+ // Compatibility barrel retained for published deep imports. New modules
2
+ // should import from the focused state/cache modules directly.
3
+ export * from "./atoms.ts"
4
+ export * from "./persistence.ts"
5
+ export * from "./loaders.ts"
6
+ export * from "./aiState.ts"
@@ -0,0 +1,160 @@
1
+ export interface ThemeColors {
2
+ readonly screenBg: string
3
+ readonly text: string
4
+ readonly muted: string
5
+ readonly separator: string
6
+ readonly accent: string
7
+ readonly error: string
8
+ readonly selectedBg: string
9
+ readonly warning: string
10
+ readonly selectedText: string
11
+ readonly count: string
12
+ readonly passing: string
13
+ readonly defaultService: string
14
+ readonly footerBg: string
15
+ readonly treeLine: string
16
+ readonly previewKey: string
17
+ }
18
+
19
+ export interface ThemeWaterfallColors {
20
+ readonly bar: string
21
+ readonly barError: string
22
+ readonly barBg: string
23
+ readonly barLane: string
24
+ readonly barSelected: string
25
+ readonly barSelectedError: string
26
+ }
27
+
28
+ export interface ThemeDefinition {
29
+ readonly name: ThemeName
30
+ readonly label: string
31
+ readonly colors: ThemeColors
32
+ readonly waterfall: ThemeWaterfallColors
33
+ }
34
+
35
+ // motel-default palette derived in OKLCH. All "surface" tokens share hue
36
+ // 282 (twilight purple) at varying lightness so depth is communicated by
37
+ // lightness alone (footer < screen < selected < bar track). The amber
38
+ // accent (hue 73) sits almost complementary to the surfaces, giving the
39
+ // motel-sign neon maximum contrast without color clash.
40
+ const motelDefaultTheme: ThemeDefinition = {
41
+ name: "motel-default",
42
+ label: "Motel Default",
43
+ colors: {
44
+ screenBg: "#111120", // oklch(0.185 0.030 282)
45
+ text: "#eee5d6", // oklch(0.925 0.022 82) — warm cream
46
+ muted: "#9a9181", // oklch(0.660 0.025 82)
47
+ separator: "#686155", // oklch(0.495 0.020 81)
48
+ accent: "#f5a41a", // oklch(0.780 0.161 73) — motel neon
49
+ error: "#f97312", // oklch(0.705 0.187 48)
50
+ selectedBg: "#2b2c48", // oklch(0.305 0.050 282) — same hue as screen
51
+ warning: "#facc16", // oklch(0.861 0.173 92)
52
+ selectedText: "#f8fafc",
53
+ count: "#d7c5a1", // oklch(0.830 0.052 85)
54
+ passing: "#7ed5a4", // oklch(0.805 0.110 158)
55
+ defaultService: "#93c5fe", // oklch(0.810 0.096 252)
56
+ footerBg: "#04040e", // oklch(0.115 0.025 282) — deeper than screen
57
+ treeLine: "#48433b", // oklch(0.385 0.015 80)
58
+ previewKey: "#645d51", // oklch(0.480 0.020 80)
59
+ },
60
+ waterfall: {
61
+ bar: "#f5a41a", // = accent
62
+ barError: "#f97312", // = error
63
+ barBg: "#1f1f34", // oklch(0.250 0.040 282) — purple track (was warm)
64
+ barLane: "#3d3e5b", // oklch(0.375 0.050 282)
65
+ barSelected: "#f3c048", // oklch(0.832 0.145 85) — warmer amber
66
+ barSelectedError: "#ff8c42",
67
+ },
68
+ }
69
+
70
+ const tokyoNightTheme: ThemeDefinition = {
71
+ name: "tokyo-night",
72
+ label: "Tokyo Night",
73
+ colors: {
74
+ screenBg: "#1a1b26",
75
+ text: "#c0caf5",
76
+ muted: "#7a88b6",
77
+ separator: "#565f89",
78
+ accent: "#7aa2f7",
79
+ error: "#f7768e",
80
+ selectedBg: "#283457",
81
+ warning: "#e0af68",
82
+ selectedText: "#f8fbff",
83
+ count: "#bb9af7",
84
+ passing: "#9ece6a",
85
+ defaultService: "#73daca",
86
+ footerBg: "#000000",
87
+ treeLine: "#414868",
88
+ previewKey: "#6b739c",
89
+ },
90
+ waterfall: {
91
+ bar: "#7aa2f7",
92
+ barError: "#f7768e",
93
+ barBg: "#1f2335",
94
+ barLane: "#2a3050",
95
+ barSelected: "#bb9af7",
96
+ barSelectedError: "#ff9eaf",
97
+ },
98
+ }
99
+
100
+ const catppuccinTheme: ThemeDefinition = {
101
+ name: "catppuccin",
102
+ label: "Catppuccin Mocha",
103
+ colors: {
104
+ screenBg: "#11111b",
105
+ text: "#cdd6f4",
106
+ muted: "#a6adc8",
107
+ separator: "#6c7086",
108
+ accent: "#f5c2e7",
109
+ error: "#f38ba8",
110
+ selectedBg: "#313244",
111
+ warning: "#f9e2af",
112
+ selectedText: "#f5f7ff",
113
+ count: "#fab387",
114
+ passing: "#a6e3a1",
115
+ defaultService: "#89dceb",
116
+ footerBg: "#000000",
117
+ treeLine: "#585b70",
118
+ previewKey: "#9399b2",
119
+ },
120
+ waterfall: {
121
+ bar: "#f5c2e7",
122
+ barError: "#f38ba8",
123
+ barBg: "#1e1e2e",
124
+ barLane: "#313244",
125
+ barSelected: "#fab387",
126
+ barSelectedError: "#eba0ac",
127
+ },
128
+ }
129
+
130
+ export const themes = {
131
+ "motel-default": motelDefaultTheme,
132
+ "tokyo-night": tokyoNightTheme,
133
+ catppuccin: catppuccinTheme,
134
+ } as const
135
+
136
+ export type ThemeName = keyof typeof themes
137
+
138
+ export const defaultThemeName: ThemeName = "tokyo-night"
139
+
140
+ export const themeOrder: readonly ThemeName[] = ["tokyo-night", "catppuccin", "motel-default"]
141
+
142
+ export const colors: ThemeColors = { ...themes[defaultThemeName].colors }
143
+ export const waterfallColors: ThemeWaterfallColors = { ...themes[defaultThemeName].waterfall }
144
+
145
+ export const applyTheme = (name: ThemeName) => {
146
+ const theme = themes[name] ?? themes[defaultThemeName]
147
+ Object.assign(colors, theme.colors)
148
+ Object.assign(waterfallColors, theme.waterfall)
149
+ return theme
150
+ }
151
+
152
+ export const cycleThemeName = (current: ThemeName) => {
153
+ const nextIndex = (themeOrder.indexOf(current) + 1) % themeOrder.length
154
+ return themeOrder[nextIndex] ?? themeOrder[0]
155
+ }
156
+
157
+ export const themeLabel = (name: ThemeName) => themes[name]?.label ?? themes[defaultThemeName].label
158
+
159
+ export const SEPARATOR = " \u00b7 "
160
+ export const G_PREFIX_TIMEOUT_MS = 500
@@ -0,0 +1,126 @@
1
+ /**
2
+ * End-to-end reproducer for waterfall underfilling the trace-details pane.
3
+ *
4
+ * Strategy:
5
+ * 1. Seed a deterministic trace into a fresh SQLite database.
6
+ * 2. Launch the motel TUI under tuistory in narrow mode so trace details take
7
+ * the full screen width.
8
+ * 3. Drill into the trace details view.
9
+ * 4. Assert the root waterfall row reaches the right-side duration column
10
+ * instead of stopping several cells early.
11
+ */
12
+
13
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test"
14
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
15
+ import { tmpdir } from "node:os"
16
+ import { join } from "node:path"
17
+
18
+ const TUISTORY_BIN = "tuistory"
19
+ const SESSION = `motel-trace-width-${Date.now()}`
20
+
21
+ const hasTuistory = async () => {
22
+ try {
23
+ const proc = Bun.spawn({ cmd: ["which", TUISTORY_BIN], stdout: "pipe", stderr: "ignore" })
24
+ return (await proc.exited) === 0
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ const tui = async (args: readonly string[]) => {
31
+ const proc = Bun.spawn({ cmd: [TUISTORY_BIN, ...args], stdout: "pipe", stderr: "pipe" })
32
+ const [stdout, stderr] = await Promise.all([
33
+ new Response(proc.stdout).text(),
34
+ new Response(proc.stderr).text(),
35
+ ])
36
+ return { code: await proc.exited, stdout, stderr }
37
+ }
38
+
39
+ const snapshot = async () => (await tui(["snapshot", "--session", SESSION])).stdout
40
+
41
+ const press = async (...keys: string[]) => {
42
+ await tui(["press", "--session", SESSION, ...keys])
43
+ await Bun.sleep(120)
44
+ }
45
+
46
+ const dividerWidth = (snap: string) =>
47
+ snap.split("\n").find((line) => line.startsWith("─"))?.length ?? 0
48
+
49
+ const rootWaterfallRow = (snap: string) =>
50
+ snap.split("\n").find((line) => line.startsWith(" ▾ root.op") || line.startsWith(" ▸ root.op") || line.startsWith(" · root.op")) ?? null
51
+
52
+ describe("trace details waterfall width (end-to-end TUI)", () => {
53
+ const tempDir = mkdtempSync(join(tmpdir(), "motel-trace-width-"))
54
+ const dbPath = join(tempDir, "telemetry.sqlite")
55
+ const port = 45_000 + Math.floor(Math.random() * 5_000)
56
+ const daemonEnv = { MOTEL_RUNTIME_DIR: tempDir, MOTEL_OTEL_DB_PATH: dbPath, MOTEL_OTEL_BASE_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_QUERY_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_PORT: String(port) }
57
+ const lastServicePath = join(tempDir, "last-service.txt")
58
+ let canRun = false
59
+
60
+ beforeAll(async () => {
61
+ canRun = await hasTuistory()
62
+ if (!canRun) return
63
+
64
+ writeFileSync(lastServicePath, "waterfall-repro")
65
+
66
+ const seed = Bun.spawn({
67
+ cmd: ["bun", "run", "src/ui/waterfallNav.repro.seed.ts"],
68
+ cwd: process.cwd(),
69
+ env: {
70
+ ...process.env,
71
+ ...daemonEnv,
72
+ MOTEL_OTEL_DB_PATH: dbPath,
73
+ MOTEL_OTEL_ENABLED: "false",
74
+ },
75
+ stdout: "pipe",
76
+ stderr: "pipe",
77
+ })
78
+ const seedCode = await seed.exited
79
+ if (seedCode !== 0) {
80
+ const err = await new Response(seed.stderr).text()
81
+ throw new Error(`seed failed: ${err}`)
82
+ }
83
+
84
+ await tui(["close", "--session", SESSION])
85
+ const launch = await tui([
86
+ "launch",
87
+ "bun run src/index.tsx",
88
+ "--session", SESSION,
89
+ "--cols", "96",
90
+ "--rows", "40",
91
+ "--cwd", process.cwd(),
92
+ "--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
93
+ "--env", `MOTEL_RUNTIME_DIR=${tempDir}`,
94
+ "--env", `MOTEL_OTEL_BASE_URL=${daemonEnv.MOTEL_OTEL_BASE_URL}`,
95
+ "--env", `MOTEL_OTEL_QUERY_URL=${daemonEnv.MOTEL_OTEL_QUERY_URL}`,
96
+ "--env", `MOTEL_OTEL_PORT=${port}`,
97
+ "--env", "MOTEL_OTEL_ENABLED=false",
98
+ "--timeout", "15000",
99
+ ])
100
+ if (launch.code !== 0) throw new Error(`launch failed: ${launch.stderr}`)
101
+ await tui(["wait", "root.op", "--session", SESSION, "--timeout", "10000"])
102
+ await tui(["wait-idle", "--session", SESSION, "--timeout", "5000"])
103
+ }, 60_000)
104
+
105
+ afterAll(async () => {
106
+ if (canRun) {
107
+ await tui(["close", "--session", SESSION])
108
+ const stop = Bun.spawn({ cmd: ["bun", "run", "src/motel.ts", "stop"], cwd: process.cwd(), env: { ...process.env, ...daemonEnv }, stdout: "ignore", stderr: "ignore" })
109
+ await stop.exited
110
+ }
111
+ try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
112
+ })
113
+
114
+ it("fills the full-width trace details pane in narrow mode", async () => {
115
+ if (!canRun) return
116
+
117
+ await press("return")
118
+ const snap = await snapshot()
119
+ const divider = dividerWidth(snap)
120
+ const row = rootWaterfallRow(snap)
121
+
122
+ expect(divider).toBe(96)
123
+ expect(row).not.toBeNull()
124
+ expect(row!.length).toBeGreaterThanOrEqual(divider - 1)
125
+ }, 60_000)
126
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Seed script for traceSortNav.repro.test.ts. Invoked as a child process so
3
+ * `config.ts` picks up our test DB path at module-load time.
4
+ */
5
+
6
+ import { Effect } from "effect"
7
+ import { storeRuntime } from "../runtime.ts"
8
+ import { TelemetryStore } from "../services/TelemetryStore.ts"
9
+
10
+ const SERVICE_NAME = "sort-nav-repro"
11
+ const base = BigInt(Date.now()) * 1_000_000n
12
+ const ms = (n: number) => String(base + BigInt(n) * 1_000_000n)
13
+
14
+ // A is the oldest, E is the newest. Durations are arranged so the three
15
+ // sort orders we care about all differ:
16
+ // recent = [E, D, C, B, A]
17
+ // slowest = [D, B, E, A, C]
18
+ // errors = none set, falls back to recency
19
+ // Enough traces that the list scrolls in a typical 40-row terminal, and
20
+ // enough duration spread that `slowest` order is meaningfully different
21
+ // from `recent`.
22
+ const specs: ReadonlyArray<{ id: string; op: string; startMsAgo: number; durMs: number }> = [
23
+ { id: "a", op: "opA", startMsAgo: 1500, durMs: 10 },
24
+ { id: "b", op: "opB", startMsAgo: 1400, durMs: 50 },
25
+ { id: "c", op: "opC", startMsAgo: 1300, durMs: 5 },
26
+ { id: "d", op: "opD", startMsAgo: 1200, durMs: 100 },
27
+ { id: "e", op: "opE", startMsAgo: 1100, durMs: 25 },
28
+ { id: "f", op: "opF", startMsAgo: 1000, durMs: 75 },
29
+ { id: "g", op: "opG", startMsAgo: 900, durMs: 3 },
30
+ { id: "h", op: "opH", startMsAgo: 800, durMs: 200 },
31
+ { id: "i", op: "opI", startMsAgo: 700, durMs: 15 },
32
+ { id: "j", op: "opJ", startMsAgo: 600, durMs: 60 },
33
+ { id: "k", op: "opK", startMsAgo: 500, durMs: 1 },
34
+ { id: "l", op: "opL", startMsAgo: 400, durMs: 150 },
35
+ { id: "m", op: "opM", startMsAgo: 300, durMs: 40 },
36
+ { id: "n", op: "opN", startMsAgo: 200, durMs: 20 },
37
+ { id: "o", op: "opO", startMsAgo: 100, durMs: 7 },
38
+ ]
39
+
40
+ const resourceSpans = specs.map((spec) => ({
41
+ resource: { attributes: [{ key: "service.name", value: { stringValue: SERVICE_NAME } }] },
42
+ scopeSpans: [
43
+ {
44
+ scope: { name: "s" },
45
+ spans: [
46
+ {
47
+ traceId: spec.id.repeat(32),
48
+ spanId: spec.id.repeat(16),
49
+ name: spec.op,
50
+ kind: 1,
51
+ startTimeUnixNano: ms(-spec.startMsAgo),
52
+ endTimeUnixNano: ms(-spec.startMsAgo + spec.durMs),
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ }))
58
+
59
+ await storeRuntime.runPromise(
60
+ Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({ resourceSpans })),
61
+ )
62
+ process.exit(0)