@kitlangton/motel 0.2.1 → 0.2.5

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.
@@ -0,0 +1,19 @@
1
+ const EPOCH_KEY = "__motelStartupEpoch"
2
+
3
+ type GlobalWithStartupEpoch = typeof globalThis & {
4
+ [EPOCH_KEY]?: number
5
+ }
6
+
7
+ const globalWithEpoch = globalThis as GlobalWithStartupEpoch
8
+
9
+ if (globalWithEpoch[EPOCH_KEY] === undefined) {
10
+ globalWithEpoch[EPOCH_KEY] = performance.now()
11
+ }
12
+
13
+ export const startupBenchEnabled = process.env.MOTEL_BENCH_STARTUP_PHASES === "1"
14
+
15
+ export const startupBenchMark = (phase: string) => {
16
+ if (!startupBenchEnabled) return
17
+ const elapsedMs = performance.now() - (globalWithEpoch[EPOCH_KEY] ?? performance.now())
18
+ process.stderr.write(`[motel-startup] ${phase} ${elapsedMs.toFixed(3)}ms\n`)
19
+ }
@@ -212,6 +212,7 @@ const StoryApp = () => {
212
212
  bodyLines={bodyLines}
213
213
  paneWidth={w}
214
214
  />
215
+ <Divider width={contentWidth} />
215
216
  <box paddingLeft={1} paddingRight={1} height={FOOTER_ROWS}>
216
217
  <TextLine>
217
218
  <span fg={colors.count} attributes={TextAttributes.BOLD}>1-9</span>
@@ -48,6 +48,16 @@ const rowTextColor = (chunk: Chunk | null, role: Role, selected: boolean): strin
48
48
  return colors.text
49
49
  }
50
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
+
51
61
  const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
52
62
 
53
63
  const chunkRows = (rows: readonly ChatListRow[]) => rows.filter((row) => row.kind === "chunk")
@@ -151,15 +161,16 @@ export const AiChatView = ({
151
161
  }) => {
152
162
  const rows = useMemo(() => buildChatListRows(chunks), [chunks])
153
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
+ )
154
169
  const [scrollOffset, setScrollOffset] = useState(0)
155
170
 
156
- const selectedChunk = useMemo(
157
- () => selectedChunkId ? chunks.find((chunk) => chunk.id === selectedChunkId) ?? null : null,
158
- [chunks, selectedChunkId],
159
- )
160
171
  const detailChunk = useMemo(
161
- () => detailChunkId ? chunks.find((chunk) => chunk.id === detailChunkId) ?? null : null,
162
- [chunks, detailChunkId],
172
+ () => detailChunkId ? chunkById.get(detailChunkId) ?? null : null,
173
+ [chunkById, detailChunkId],
163
174
  )
164
175
 
165
176
  const selectedRowIndex = useMemo(
@@ -201,7 +212,7 @@ export const AiChatView = ({
201
212
  const maxOffset = Math.max(0, rows.length - bodyLines)
202
213
  const offset = clamp(scrollOffset, 0, maxOffset)
203
214
  const visible = rows.slice(offset, offset + bodyLines)
204
- const headerRight = `${operation} ${SEPARATOR} ${durationLabel} ${SEPARATOR} ${selectable.length > 0 ? `${Math.max(1, selectable.findIndex((row) => row.chunkId === selectedChunkId) + 1)}/${selectable.length}` : "0/0"}`
215
+ const headerRight = `${operation} ${SEPARATOR} ${durationLabel} ${SEPARATOR} ${selectable.length > 0 ? `${Math.max(1, selectedOrdinal + 1)}/${selectable.length}` : "0/0"}`
205
216
  const handleListWheel = (event: MouseScrollEvent) => {
206
217
  if (detailChunk) return
207
218
  const delta = scrollDelta(event)
@@ -257,18 +268,23 @@ export const AiChatView = ({
257
268
  </TextLine>
258
269
  )
259
270
  }
260
- const chunk = chunks.find((candidate) => candidate.id === row.chunkId) ?? null
271
+ const chunk = row.chunkId ? chunkById.get(row.chunkId) ?? null : null
261
272
  const isSelected = row.chunkId === selectedChunkId
262
273
  const prefix = rowPrefix(chunk)
263
274
  const meta = row.meta ?? ""
264
275
  const textWidth = Math.max(8, contentWidth - prefix.length - meta.length - 4)
265
276
  const display = truncateText(row.text, textWidth)
266
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
267
282
  return (
268
283
  <box key={`row-${offset + i}`} height={1} onMouseDown={() => { if (row.chunkId) onSelectChunk(row.chunkId) }}>
269
284
  <TextLine bg={isSelected ? colors.selectedBg : undefined}>
270
285
  <span fg={isSelected ? roleColor(row.role) : colors.separator}>{isSelected ? "▎" : " "}</span>
271
- <span fg={rowTextColor(chunk, row.role, isSelected)} attributes={isSelected ? TextAttributes.BOLD : undefined}>{prefix}{display}</span>
286
+ <span fg={headColor} attributes={isSelected ? TextAttributes.BOLD : undefined}>{`${prefix}${head}`}</span>
287
+ {tail ? <span fg={tailColor}>{` ${tail}`}</span> : null}
272
288
  {meta ? <><span fg={colors.muted}>{" ".repeat(gap)}</span><span fg={colors.muted}>{meta}</span></> : null}
273
289
  </TextLine>
274
290
  </box>
@@ -5,7 +5,7 @@ import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./pr
5
5
  import { WaterfallTimeline } from "./Waterfall.tsx"
6
6
  import { computeMatchingSpanIds } from "./waterfallFilter.ts"
7
7
  import { getVisibleSpans } from "./waterfallModel.ts"
8
- import type { LoadStatus, LogState } from "./state.ts"
8
+ import type { LoadStatus } from "./state.ts"
9
9
  import { colors, SEPARATOR } from "./theme.ts"
10
10
 
11
11
  /**
@@ -24,13 +24,12 @@ export const TraceDetailsPane = ({
24
24
  traceSummary,
25
25
  traceStatus,
26
26
  traceError,
27
- traceLogsState,
27
+ traceLogCount,
28
28
  contentWidth,
29
29
  bodyLines,
30
30
  paneWidth,
31
31
  selectedSpanIndex,
32
32
  collapsedSpanIds,
33
- focused = false,
34
33
  onSelectSpan,
35
34
  waterfallFilterMode,
36
35
  waterfallFilterText,
@@ -39,13 +38,12 @@ export const TraceDetailsPane = ({
39
38
  traceSummary: TraceSummaryItem | null
40
39
  traceStatus: LoadStatus
41
40
  traceError: string | null
42
- traceLogsState: LogState
41
+ traceLogCount: number
43
42
  contentWidth: number
44
43
  bodyLines: number
45
44
  paneWidth: number
46
45
  selectedSpanIndex: number | null
47
46
  collapsedSpanIds: ReadonlySet<string>
48
- focused?: boolean
49
47
  onSelectSpan: (index: number) => void
50
48
  waterfallFilterMode: boolean
51
49
  waterfallFilterText: string
@@ -54,20 +52,6 @@ export const TraceDetailsPane = ({
54
52
  () => trace ? getVisibleSpans(trace.spans, collapsedSpanIds) : [],
55
53
  [trace, collapsedSpanIds],
56
54
  )
57
- const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
58
- const traceLogCount = traceLogsState.data.length
59
- const spanLogCounts = useMemo(() => {
60
- const counts = new Map<string, number>()
61
- for (const log of traceLogsState.data) {
62
- if (!log.spanId) continue
63
- counts.set(log.spanId, (counts.get(log.spanId) ?? 0) + 1)
64
- }
65
- return counts
66
- }, [traceLogsState.data])
67
- const selectedSpanLogs = useMemo(
68
- () => selectedSpan ? traceLogsState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
69
- [selectedSpan, traceLogsState.data],
70
- )
71
55
  const matchingSpanIds = useMemo(
72
56
  () => trace ? computeMatchingSpanIds(trace.spans, waterfallFilterText) : null,
73
57
  [trace, waterfallFilterText],
@@ -144,14 +128,12 @@ export const TraceDetailsPane = ({
144
128
  </box>
145
129
  ) : null}
146
130
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
147
- <WaterfallTimeline
148
- trace={trace}
149
- filteredSpans={filteredSpans}
150
- spanLogCounts={spanLogCounts}
151
- selectedSpanLogs={selectedSpanLogs}
152
- contentWidth={contentWidth}
153
- bodyLines={waterfallBodyLines}
154
- selectedSpanIndex={selectedSpanIndex}
131
+ <WaterfallTimeline
132
+ trace={trace}
133
+ filteredSpans={filteredSpans}
134
+ contentWidth={contentWidth}
135
+ bodyLines={waterfallBodyLines}
136
+ selectedSpanIndex={selectedSpanIndex}
155
137
  collapsedSpanIds={collapsedSpanIds}
156
138
  matchingSpanIds={matchingSpanIds}
157
139
  onSelectSpan={onSelectSpan}
@@ -1,4 +1,4 @@
1
- import { memo, useLayoutEffect, useRef, useState } from "react"
1
+ import { memo, useLayoutEffect, useState } from "react"
2
2
  import { isAiSpan, type LogItem, type TraceItem, type TraceSpanItem } from "../domain.ts"
3
3
  import { formatDuration, lifecycleLabel, splitDuration, truncateText } from "./format.ts"
4
4
  import { BlankRow, TextLine } from "./primitives.tsx"
@@ -280,8 +280,6 @@ export const SpanPreview = ({
280
280
  export const WaterfallTimeline = ({
281
281
  trace,
282
282
  filteredSpans,
283
- spanLogCounts,
284
- selectedSpanLogs,
285
283
  contentWidth,
286
284
  bodyLines,
287
285
  selectedSpanIndex,
@@ -291,8 +289,6 @@ export const WaterfallTimeline = ({
291
289
  }: {
292
290
  trace: TraceItem
293
291
  filteredSpans: readonly TraceSpanItem[]
294
- spanLogCounts: ReadonlyMap<string, number>
295
- selectedSpanLogs: readonly LogItem[]
296
292
  contentWidth: number
297
293
  bodyLines: number
298
294
  selectedSpanIndex: number | null
@@ -320,13 +316,12 @@ export const WaterfallTimeline = ({
320
316
  const viewportSize = Math.max(1, bodyLines)
321
317
  const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
322
318
  const [scrollOffset, setScrollOffset] = useState(0)
323
- const lastTraceIdRef = useRef<string | null>(null)
324
319
 
325
- // Reset scroll offset when the trace changes.
326
- if (trace.traceId !== lastTraceIdRef.current) {
320
+ // Reset scroll offset when the trace changes. Keep this out of render so
321
+ // a trace switch doesn't force a render-phase state update on hot paths.
322
+ useLayoutEffect(() => {
327
323
  setScrollOffset(0)
328
- lastTraceIdRef.current = trace.traceId
329
- }
324
+ }, [trace.traceId])
330
325
 
331
326
  // Auto-follow selection: only if the selected span would be hidden
332
327
  // by the current window, shift just enough to bring it back. Runs in
@@ -328,6 +328,50 @@ describe("buildChatListRows", () => {
328
328
  expect(rows[0]!.text).toBe("hello there")
329
329
  expect(rows[1]!.text).toContain("bash")
330
330
  })
331
+
332
+ it("uses matching tool-call context for tool-result rows", () => {
333
+ const chunks = buildChunks(
334
+ makeDetail([
335
+ {
336
+ role: "assistant",
337
+ content: [
338
+ { type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git status --short --branch" } },
339
+ ],
340
+ },
341
+ {
342
+ role: "tool",
343
+ content: [
344
+ { type: "tool-result", toolCallId: "tc-1", toolName: "bash", output: { type: "text", value: "## dev...origin/dev [ahead 8, behind 11]\n M src/file.ts" } },
345
+ ],
346
+ },
347
+ ]),
348
+ )
349
+ const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
350
+ expect(rows[1]!.text).toContain("bash git status --short --branch")
351
+ expect(rows[1]!.meta).toContain("## dev...origin/dev")
352
+ })
353
+
354
+ it("shows read result rows with the originating file path inline", () => {
355
+ const chunks = buildChunks(
356
+ makeDetail([
357
+ {
358
+ role: "assistant",
359
+ content: [
360
+ { type: "tool-call", toolCallId: "tc-2", toolName: "read", input: { filePath: "/src/formatter.ts", offset: 40, limit: 80 } },
361
+ ],
362
+ },
363
+ {
364
+ role: "tool",
365
+ content: [
366
+ { type: "tool-result", toolCallId: "tc-2", toolName: "read", output: { type: "text", value: "1: export const x = 1" } },
367
+ ],
368
+ },
369
+ ]),
370
+ )
371
+ const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
372
+ expect(rows[1]!.text).toContain("read /src/formatter.ts @40 +80")
373
+ expect(rows[1]!.meta).toContain("1: export const x = 1")
374
+ })
331
375
  })
332
376
 
333
377
  describe("chunkDetailTitle + renderChunkDetailLines", () => {
@@ -479,6 +479,13 @@ const firstBodyLine = (body: string) => {
479
479
  return line.replace(/\s+/g, " ").trim()
480
480
  }
481
481
 
482
+ const stripTransportGlyph = (text: string) => text.replace(/^[→←]\s+/, "")
483
+
484
+ const toolRowPreview = (text: string, width = 40) => {
485
+ const compact = firstBodyLine(text)
486
+ return compact.length > 0 ? shorten(compact, width) : null
487
+ }
488
+
482
489
  /**
483
490
  * Stable list rows for the main chat pane. One role divider per turn,
484
491
  * one selectable row per chunk. Plain text chunks use their first body
@@ -487,6 +494,10 @@ const firstBodyLine = (body: string) => {
487
494
  */
488
495
  export const buildChatListRows = (chunks: readonly Chunk[]): readonly ChatListRow[] => {
489
496
  const rows: ChatListRow[] = []
497
+ const toolCallById = new Map<string, Chunk>()
498
+ for (const chunk of chunks) {
499
+ if (chunk.kind === "tool-call" && chunk.toolCallId) toolCallById.set(chunk.toolCallId, chunk)
500
+ }
490
501
  let prevRole: Role | null = null
491
502
  let prevMessageIndex = -1
492
503
 
@@ -522,7 +533,33 @@ export const buildChatListRows = (chunks: readonly Chunk[]): readonly ChatListRo
522
533
  // structured chunk headers here. Otherwise tool rows render as
523
534
  // `→ → bash ...` and results as `← ← read ...`.
524
535
  if (chunk.kind === "tool-call" || chunk.kind === "tool-result") {
525
- text = text.replace(/^[→←]\s+/, "")
536
+ text = stripTransportGlyph(text)
537
+ }
538
+
539
+ if (chunk.kind === "tool-call") {
540
+ const preview = toolRowPreview(chunk.body)
541
+ // Keep row text focused on the primary action (already encoded in
542
+ // `header`) and use the dim right column for "there is more here"
543
+ // metadata only when it adds signal. For JSON-heavy tool args this
544
+ // is usually just noise, so we currently leave meta alone.
545
+ if (preview && preview !== text) {
546
+ meta = meta ?? null
547
+ }
548
+ }
549
+
550
+ if (chunk.kind === "tool-result") {
551
+ const matchingCall = chunk.toolCallId ? toolCallById.get(chunk.toolCallId) ?? null : null
552
+ if (matchingCall) {
553
+ // Carry the originating call summary into the result row so the
554
+ // list can answer "result of what?" without opening the modal.
555
+ // Example: `← bash git status --short --branch`,
556
+ // `← read /src/formatter.ts @40 +80`.
557
+ text = stripTransportGlyph(matchingCall.header)
558
+ }
559
+ const preview = toolRowPreview(chunk.body)
560
+ if (preview) {
561
+ meta = chunk.headerMeta ? `${chunk.headerMeta} · ${preview}` : preview
562
+ }
526
563
  }
527
564
  if (chunk.kind === "system") {
528
565
  text = "prompt"
@@ -21,7 +21,7 @@ interface SharedTraceDetailsProps {
21
21
  readonly traceSummary: TraceSummaryItem | null
22
22
  readonly traceStatus: TraceDetailState["status"]
23
23
  readonly traceError: string | null
24
- readonly traceLogsState: LogState
24
+ readonly traceLogCount: number
25
25
  readonly selectedSpanIndex: number | null
26
26
  readonly collapsedSpanIds: ReadonlySet<string>
27
27
  readonly waterfallFilterMode: boolean
@@ -33,7 +33,6 @@ interface TraceDetailsSceneProps extends SharedTraceDetailsProps {
33
33
  readonly contentWidth: number
34
34
  readonly bodyLines: number
35
35
  readonly paneWidth: number
36
- readonly focused: boolean
37
36
  }
38
37
 
39
38
  const TraceDetailsScene = ({
@@ -41,13 +40,12 @@ const TraceDetailsScene = ({
41
40
  traceSummary,
42
41
  traceStatus,
43
42
  traceError,
44
- traceLogsState,
43
+ traceLogCount,
45
44
  contentWidth,
46
45
  bodyLines,
47
46
  paneWidth,
48
47
  selectedSpanIndex,
49
48
  collapsedSpanIds,
50
- focused,
51
49
  waterfallFilterMode,
52
50
  waterfallFilterText,
53
51
  onSelectSpan,
@@ -57,13 +55,12 @@ const TraceDetailsScene = ({
57
55
  traceSummary={traceSummary}
58
56
  traceStatus={traceStatus}
59
57
  traceError={traceError}
60
- traceLogsState={traceLogsState}
58
+ traceLogCount={traceLogCount}
61
59
  contentWidth={contentWidth}
62
60
  bodyLines={bodyLines}
63
61
  paneWidth={paneWidth}
64
62
  selectedSpanIndex={selectedSpanIndex}
65
63
  collapsedSpanIds={collapsedSpanIds}
66
- focused={focused}
67
64
  waterfallFilterMode={waterfallFilterMode}
68
65
  waterfallFilterText={waterfallFilterText}
69
66
  onSelectSpan={onSelectSpan}
@@ -292,7 +289,7 @@ export const TraceWorkspace = ({
292
289
  traceSummary: selectedTraceSummary,
293
290
  traceStatus: traceDetailState.status,
294
291
  traceError: traceDetailState.error,
295
- traceLogsState: logState,
292
+ traceLogCount: logState.data.length,
296
293
  selectedSpanIndex,
297
294
  collapsedSpanIds,
298
295
  waterfallFilterMode,
@@ -336,7 +333,6 @@ export const TraceWorkspace = ({
336
333
  contentWidth={rightContentWidth}
337
334
  bodyLines={wideBodyLines}
338
335
  paneWidth={rightPaneWidth}
339
- focused={false}
340
336
  />
341
337
  </box>
342
338
  </box>
@@ -352,7 +348,6 @@ export const TraceWorkspace = ({
352
348
  contentWidth={leftContentWidth}
353
349
  bodyLines={wideBodyLines}
354
350
  paneWidth={leftPaneWidth}
355
- focused={true}
356
351
  />
357
352
  </box>
358
353
  <SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
@@ -412,7 +407,6 @@ export const TraceWorkspace = ({
412
407
  contentWidth={rightContentWidth}
413
408
  bodyLines={narrowBodyLines}
414
409
  paneWidth={contentWidth}
415
- focused={false}
416
410
  />
417
411
  </>
418
412
  )
@@ -432,7 +426,6 @@ export const TraceWorkspace = ({
432
426
  contentWidth={rightContentWidth}
433
427
  bodyLines={narrowFullBodyLines}
434
428
  paneWidth={contentWidth}
435
- focused={true}
436
429
  />
437
430
  ) : (
438
431
  <SpanDrillInScene
@@ -13,6 +13,7 @@ import {
13
13
  detailViewAtom,
14
14
  ensureAiCallDetail,
15
15
  ensureTraceAttributeKeys,
16
+ ensureTraceAttributeValues,
16
17
  filterModeAtom,
17
18
  filterTextAtom,
18
19
  getCachedAiCallDetail,
@@ -21,7 +22,6 @@ import {
21
22
  initialServiceLogState,
22
23
  initialTraceDetailState,
23
24
  invalidateAiCallDetailCache,
24
- invalidateFacetCaches,
25
25
  loadFilteredTraceSummaries,
26
26
  loadRecentTraceSummaries,
27
27
  loadServiceLogs,
@@ -47,7 +47,7 @@ import {
47
47
  import { isAiSpan } from "../../domain.ts"
48
48
  import { buildChunks, type Chunk } from "../aiChatModel.ts"
49
49
  import { parseFilterText } from "../filterParser.ts"
50
- import { getVisibleSpans } from "../Waterfall.tsx"
50
+ import { getVisibleSpans } from "../waterfallModel.ts"
51
51
 
52
52
  const clampSelectionIndex = (index: number, length: number) => {
53
53
  if (length === 0) return 0
@@ -166,17 +166,25 @@ export const useTraceScreenData = () => {
166
166
  serviceLogCacheRef.current.clear()
167
167
  traceDetailInflightRef.current.clear()
168
168
  traceLogInflightRef.current.clear()
169
- invalidateFacetCaches()
170
169
  invalidateAiCallDetailCache()
171
170
  }, [refreshNonce])
172
171
 
173
172
  // Pre-warm the attribute picker facet keys for the currently-selected
174
- // service so pressing `f` feels instant. Fire-and-forget; errors are
175
- // surfaced when the user actually opens the picker.
173
+ // service so pressing `f` feels instant. Once keys are known, also
174
+ // prefetch the value lists for the first few visible keys so the common
175
+ // path of opening `f`, picking a top key, and reopening again stays
176
+ // near-instant. Fire-and-forget; errors are surfaced when the user
177
+ // actually opens the picker.
176
178
  useEffect(() => {
177
179
  if (!selectedTraceService) return
178
- void ensureTraceAttributeKeys(selectedTraceService).catch(() => {})
179
- }, [selectedTraceService, refreshNonce])
180
+ void ensureTraceAttributeKeys(selectedTraceService)
181
+ .then((entry) => Promise.allSettled(
182
+ entry.data
183
+ .slice(0, 6)
184
+ .map((row) => ensureTraceAttributeValues(selectedTraceService, row.value)),
185
+ ))
186
+ .catch(() => {})
187
+ }, [selectedTraceService])
180
188
 
181
189
  useEffect(() => {
182
190
  let cancelled = false
package/src/ui/atoms.ts CHANGED
@@ -87,7 +87,7 @@ export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.ke
87
87
  export const selectedAttrIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
88
88
  export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
89
89
  export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
90
- export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
90
+ export const autoRefreshAtom = Atom.make(true).pipe(Atom.keepAlive)
91
91
  export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
92
92
  export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
93
93
 
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs"
2
2
  import { dirname } from "node:path"
3
3
  import { config } from "../config.ts"
4
- import type { ThemeName } from "./theme.ts"
4
+ import { defaultThemeName, type ThemeName } from "./theme.ts"
5
5
 
6
6
  const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
7
7
 
@@ -26,9 +26,9 @@ const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
26
26
  export const readLastTheme = (): ThemeName => {
27
27
  try {
28
28
  const raw = readFileSync(lastThemePath, "utf-8").trim()
29
- return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : "motel-default"
29
+ return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : defaultThemeName
30
30
  } catch {
31
- return "motel-default"
31
+ return defaultThemeName
32
32
  }
33
33
  }
34
34
 
package/src/ui/theme.ts CHANGED
@@ -135,13 +135,15 @@ export const themes = {
135
135
 
136
136
  export type ThemeName = keyof typeof themes
137
137
 
138
- export const themeOrder: readonly ThemeName[] = ["motel-default", "tokyo-night", "catppuccin"]
138
+ export const defaultThemeName: ThemeName = "tokyo-night"
139
139
 
140
- export const colors: ThemeColors = { ...motelDefaultTheme.colors }
141
- export const waterfallColors: ThemeWaterfallColors = { ...motelDefaultTheme.waterfall }
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 }
142
144
 
143
145
  export const applyTheme = (name: ThemeName) => {
144
- const theme = themes[name] ?? motelDefaultTheme
146
+ const theme = themes[name] ?? themes[defaultThemeName]
145
147
  Object.assign(colors, theme.colors)
146
148
  Object.assign(waterfallColors, theme.waterfall)
147
149
  return theme
@@ -152,7 +154,7 @@ export const cycleThemeName = (current: ThemeName) => {
152
154
  return themeOrder[nextIndex] ?? themeOrder[0]
153
155
  }
154
156
 
155
- export const themeLabel = (name: ThemeName) => themes[name]?.label ?? motelDefaultTheme.label
157
+ export const themeLabel = (name: ThemeName) => themes[name]?.label ?? themes[defaultThemeName].label
156
158
 
157
159
  export const SEPARATOR = " \u00b7 "
158
160
  export const G_PREFIX_TIMEOUT_MS = 500
@@ -19,6 +19,9 @@ import {
19
19
  detailViewAtom,
20
20
  filterModeAtom,
21
21
  filterTextAtom,
22
+ getCachedFacetKeys,
23
+ getCachedFacetValues,
24
+ initialAttrFacetState,
22
25
  refreshNonceAtom,
23
26
  selectedAttrIndexAtom,
24
27
  selectedChatChunkIdAtom,
@@ -39,7 +42,7 @@ import { filterFacets } from "./AttrFilterModal.tsx"
39
42
  import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
40
43
  import { cycleThemeName, themeLabel } from "./theme.ts"
41
44
  import { computeMatchingSpanIds, findAdjacentMatch } from "./waterfallFilter.ts"
42
- import { getVisibleSpans } from "./Waterfall.tsx"
45
+ import { getVisibleSpans } from "./waterfallModel.ts"
43
46
  import { resolveCollapseStep } from "./waterfallNav.ts"
44
47
 
45
48
  /**
@@ -125,7 +128,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
125
128
  const [pickerMode, setPickerMode] = useAtom(attrPickerModeAtom)
126
129
  const [pickerInput, setPickerInput] = useAtom(attrPickerInputAtom)
127
130
  const [pickerIndex, setPickerIndex] = useAtom(attrPickerIndexAtom)
128
- const [attrFacets] = useAtom(attrFacetStateAtom)
131
+ const [attrFacets, setAttrFacets] = useAtom(attrFacetStateAtom)
129
132
  const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
130
133
  const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
131
134
  const [waterfallFilterMode, setWaterfallFilterMode] = useAtom(waterfallFilterModeAtom)
@@ -259,6 +262,26 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
259
262
  setPickerIndex(0)
260
263
  }
261
264
 
265
+ const hydrateCachedPickerKeys = (service: string | null) => {
266
+ if (!service) {
267
+ setAttrFacets(initialAttrFacetState)
268
+ return
269
+ }
270
+ const cached = getCachedFacetKeys(service)
271
+ if (!cached) return
272
+ setAttrFacets({ status: "ready", key: null, data: cached.data, error: null })
273
+ }
274
+
275
+ const hydrateCachedPickerValues = (service: string | null, key: string | null) => {
276
+ if (!service || !key) {
277
+ setAttrFacets(initialAttrFacetState)
278
+ return
279
+ }
280
+ const cached = getCachedFacetValues(service, key)
281
+ if (!cached) return
282
+ setAttrFacets({ status: "ready", key, data: cached.data, error: null })
283
+ }
284
+
262
285
  const closePicker = () => {
263
286
  setPickerMode("off")
264
287
  resetPicker()
@@ -587,6 +610,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
587
610
  const row = rows[clampedIndex]
588
611
  if (!row) return true
589
612
  if (s.pickerMode === "keys") {
613
+ hydrateCachedPickerValues(s.selectedTraceService, row.value)
590
614
  setActiveAttrKey(row.value)
591
615
  setPickerMode("values")
592
616
  resetPicker()
@@ -604,6 +628,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
604
628
  return true
605
629
  }
606
630
  if (s.pickerMode === "values") {
631
+ hydrateCachedPickerKeys(s.selectedTraceService)
607
632
  setPickerMode("keys")
608
633
  setActiveAttrKey(null)
609
634
  setPickerIndex(0)
@@ -857,6 +882,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
857
882
  return true
858
883
  }
859
884
  if ((key.name === "f" || key.name === "F") && !key.ctrl && !key.meta) {
885
+ hydrateCachedPickerKeys(s.selectedTraceService)
860
886
  setPickerMode("keys")
861
887
  resetPicker()
862
888
  setActiveAttrKey(null)