@kitlangton/motel 0.1.2 → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  import { useAtom } from "@effect/atom-react"
2
2
  import { useKeyboard, useRenderer } from "@opentui/react"
3
- import { useLayoutEffect, useRef } from "react"
3
+ import { useEffect, useLayoutEffect, useRef } from "react"
4
4
  import type { TraceItem, TraceSummaryItem } from "../domain.ts"
5
5
  import { otelServerInstructions } from "../instructions.ts"
6
6
  import { copyToClipboard, traceUiUrl, webUiUrl } from "./format.ts"
@@ -27,13 +27,47 @@ import {
27
27
  traceSortAtom,
28
28
  type TraceSortMode,
29
29
  traceStateAtom,
30
+ waterfallFilterModeAtom,
31
+ waterfallFilterTextAtom,
30
32
  } from "./state.ts"
31
33
  import { filterFacets } from "./AttrFilterModal.tsx"
32
34
  import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
33
35
  import { cycleThemeName, themeLabel } from "./theme.ts"
34
36
  import { getVisibleSpans } from "./Waterfall.tsx"
37
+ import { computeMatchingSpanIds, findAdjacentMatch } from "./waterfallFilter.ts"
35
38
  import { resolveCollapseStep } from "./waterfallNav.ts"
36
39
 
40
+ /**
41
+ * Pull a printable string out of a key event. Handles two cases:
42
+ *
43
+ * 1. A plain printable key (1 char) — returns the char.
44
+ * 2. A multi-char sequence that arrived as one event (common when the
45
+ * terminal has bracketed paste disabled but the user pasted quickly and
46
+ * opentui's parser returned the whole buffer as one key). Returns the
47
+ * sanitised sequence with control bytes stripped.
48
+ *
49
+ * Returns `null` for non-printable events (function keys, modifiers, etc.)
50
+ * so callers can skip them.
51
+ */
52
+ const extractPrintable = (key: {
53
+ readonly name: string
54
+ readonly sequence?: string
55
+ readonly ctrl: boolean
56
+ readonly meta: boolean
57
+ }): string | null => {
58
+ if (key.ctrl || key.meta) return null
59
+ // Space arrives as `key.name === "space"` with a 1-char sequence. We
60
+ // handle it explicitly because the generic "length > 1" branch below
61
+ // only catches multi-char paste sequences, not a lone " ".
62
+ if (key.name === "space") return " "
63
+ if (key.name.length === 1) return key.name
64
+ const seq = key.sequence ?? ""
65
+ // Only accept sequences that are pure printable text. Any escape or
66
+ // control byte means this was a function / navigation key.
67
+ if (seq.length > 1 && !/[\x00-\x1f\x7f]/.test(seq)) return seq
68
+ return null
69
+ }
70
+
37
71
  interface KeyboardNavParams {
38
72
  selectedTrace: TraceItem | null
39
73
  filteredTraces: readonly TraceSummaryItem[]
@@ -78,6 +112,8 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
78
112
  const [attrFacets] = useAtom(attrFacetStateAtom)
79
113
  const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
80
114
  const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
115
+ const [waterfallFilterMode, setWaterfallFilterMode] = useAtom(waterfallFilterModeAtom)
116
+ const [waterfallFilterText, setWaterfallFilterText] = useAtom(waterfallFilterTextAtom)
81
117
 
82
118
  const pendingGRef = useRef(false)
83
119
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -86,12 +122,50 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
86
122
  const spanNavActive = detailView !== "service-logs" && selectedSpanIndex !== null
87
123
  const serviceLogNavActive = detailView === "service-logs"
88
124
 
89
- const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, ...params })
125
+ // Bracketed paste: when the terminal has bracketed paste enabled, opentui
126
+ // surfaces the full pasted text as a single "paste" event on keyInput.
127
+ // Route it into whichever input is currently open. We also enable the
128
+ // mode ourselves (`\x1b[?2004h`) in case the host terminal didn't — it's
129
+ // a no-op on terminals that already had it on.
130
+ useEffect(() => {
131
+ const keyInput = (renderer as unknown as { keyInput?: { on: (event: string, handler: (e: unknown) => void) => void; off: (event: string, handler: (e: unknown) => void) => void } }).keyInput
132
+ if (!keyInput) return
133
+ try {
134
+ process.stdout.write("\x1b[?2004h")
135
+ } catch {
136
+ // Best effort — some test environments don't have a real TTY.
137
+ }
138
+ const handler = (event: unknown) => {
139
+ const bytes = (event as { bytes?: Uint8Array }).bytes
140
+ if (!bytes || bytes.length === 0) return
141
+ const text = Buffer.from(bytes).toString("utf8").replace(/[\x00-\x1f\x7f]+/g, (match) => match === "\n" ? " " : "")
142
+ if (!text) return
143
+ const s = stateRef.current
144
+ if (s.pickerMode !== "off") {
145
+ setPickerInput((current) => current + text)
146
+ setPickerIndex(0)
147
+ return
148
+ }
149
+ if (s.filterMode) {
150
+ setFilterText((current) => current + text)
151
+ return
152
+ }
153
+ }
154
+ keyInput.on("paste", handler)
155
+ return () => {
156
+ keyInput.off("paste", handler)
157
+ try {
158
+ process.stdout.write("\x1b[?2004l")
159
+ } catch {}
160
+ }
161
+ }, [renderer, setFilterText, setPickerInput, setPickerIndex])
162
+
163
+ const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, waterfallFilterMode, waterfallFilterText, ...params })
90
164
  // Keep the keyboard handler's state mirror in sync before the next paint.
91
165
  // OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
92
166
  // rapid repeated keypresses can otherwise observe stale selection state.
93
167
  useLayoutEffect(() => {
94
- stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, ...params }
168
+ stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, waterfallFilterMode, waterfallFilterText, ...params }
95
169
  })
96
170
 
97
171
  const clearPendingG = () => {
@@ -285,6 +359,17 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
285
359
  setPickerIndex(0)
286
360
  return
287
361
  }
362
+ // Ctrl-C: clear input, or close the picker if already empty.
363
+ if (key.ctrl && key.name === "c") {
364
+ if (s.pickerInput.length > 0) {
365
+ setPickerInput("")
366
+ setPickerIndex(0)
367
+ } else {
368
+ setPickerMode("off")
369
+ setPickerIndex(0)
370
+ }
371
+ return
372
+ }
288
373
  if (key.name === "up" || (key.ctrl && key.name === "p")) { move(-1); return }
289
374
  if (key.name === "down" || (key.ctrl && key.name === "n")) { move(1); return }
290
375
  if (key.name === "pageup") { move(-10); return }
@@ -323,8 +408,14 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
323
408
  }
324
409
  return
325
410
  }
326
- if (key.name.length === 1 && !key.ctrl && !key.meta) {
327
- setPickerInput(s.pickerInput + key.name)
411
+ // Prefer key.sequence over key.name so multi-char paste events that
412
+ // slip through as a single raw sequence still get inserted in full.
413
+ const printable = extractPrintable(key)
414
+ if (printable) {
415
+ // Functional setState — multiple key events in the same tick would
416
+ // otherwise all read a stale stateRef.current.pickerInput and
417
+ // clobber each other, losing all but the last char of a paste.
418
+ setPickerInput((current) => current + printable)
328
419
  setPickerIndex(0)
329
420
  return
330
421
  }
@@ -338,17 +429,65 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
338
429
  setFilterText("")
339
430
  return
340
431
  }
432
+ // Ctrl-C: clear the input, or exit filter mode if already empty.
433
+ if (key.ctrl && key.name === "c") {
434
+ if (s.filterText.length > 0) {
435
+ setFilterText("")
436
+ } else {
437
+ setFilterMode(false)
438
+ }
439
+ return
440
+ }
341
441
  if (key.name === "return" || key.name === "enter") {
342
442
  setFilterMode(false)
343
443
  return
344
444
  }
345
445
  if (key.name === "backspace") {
346
- setFilterText(s.filterText.slice(0, -1))
446
+ setFilterText((current) => current.slice(0, -1))
347
447
  return
348
448
  }
349
- // Single printable character
350
- if (key.name.length === 1 && !key.ctrl && !key.meta) {
351
- setFilterText(s.filterText + key.name)
449
+ const printable = extractPrintable(key)
450
+ if (printable) {
451
+ // Functional setState so rapid keystrokes / pastes don't clobber
452
+ // each other via a stale stateRef.current.filterText closure.
453
+ setFilterText((current) => current + printable)
454
+ return
455
+ }
456
+ return
457
+ }
458
+
459
+ // Waterfall filter mode: text-capture scoped to the current
460
+ // trace's spans.
461
+ // - enter → commit: close input but keep text so dimming persists
462
+ // while the user navigates. `/` can be pressed again
463
+ // to edit.
464
+ // - esc → cancel: clear text + exit input entirely.
465
+ // - ctrl-c → clear input if non-empty, otherwise exit.
466
+ if (s.waterfallFilterMode) {
467
+ if (key.name === "escape") {
468
+ setWaterfallFilterMode(false)
469
+ setWaterfallFilterText("")
470
+ return
471
+ }
472
+ if (key.ctrl && key.name === "c") {
473
+ if (s.waterfallFilterText.length > 0) {
474
+ setWaterfallFilterText("")
475
+ } else {
476
+ setWaterfallFilterMode(false)
477
+ }
478
+ return
479
+ }
480
+ if (key.name === "return" || key.name === "enter") {
481
+ setWaterfallFilterMode(false)
482
+ return
483
+ }
484
+ if (key.name === "backspace") {
485
+ setWaterfallFilterText((current) => current.slice(0, -1))
486
+ return
487
+ }
488
+ const printable = extractPrintable(key)
489
+ if (printable) {
490
+ setWaterfallFilterText((current) => current + printable)
352
491
  return
353
492
  }
354
493
  return
@@ -431,6 +570,14 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
431
570
  setShowHelp(false)
432
571
  return
433
572
  }
573
+ // Committed waterfall filter outranks drill-back: hitting esc
574
+ // should clear the dim before jumping you out of the span
575
+ // detail pane. That keeps a single `esc` predictable whether
576
+ // the filter was applied by typing or left over from before.
577
+ if (s.waterfallFilterText.length > 0) {
578
+ setWaterfallFilterText("")
579
+ return
580
+ }
434
581
  if (s.detailView === "span-detail" || s.detailView === "service-logs") {
435
582
  setDetailView("waterfall")
436
583
  return
@@ -495,8 +642,42 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
495
642
  s.flashNotice(`Theme: ${themeLabel(nextTheme)}`)
496
643
  return
497
644
  }
645
+ // `n` / `N`: jump between matches of the committed waterfall filter.
646
+ // Only active when drilled into a trace AND the filter has text
647
+ // (committed or live — either way, there's a dim/highlight we can
648
+ // step through). Wraps at the ends like vim's /n. Plain `n` forward,
649
+ // shift-n (`N`) backward.
650
+ if ((key.name === "n" || key.name === "N") && !key.ctrl && !key.meta) {
651
+ const inWaterfall = s.detailView === "span-detail" || s.selectedSpanIndex !== null
652
+ if (inWaterfall && s.waterfallFilterText.length > 0 && s.selectedTrace) {
653
+ const visibleSpans = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds)
654
+ const matchingIds = computeMatchingSpanIds(visibleSpans, s.waterfallFilterText)
655
+ if (matchingIds && matchingIds.size > 0) {
656
+ const direction = key.name === "N" ? -1 : 1
657
+ const next = findAdjacentMatch(visibleSpans, matchingIds, s.selectedSpanIndex, direction)
658
+ if (next !== null) setSelectedSpanIndex(next)
659
+ else s.flashNotice("No matches")
660
+ } else {
661
+ s.flashNotice("No matches")
662
+ }
663
+ return
664
+ }
665
+ // Fall through when not in a trace detail view — reserves `n`
666
+ // for other future bindings without shadowing them globally.
667
+ }
668
+
498
669
  if (key.name === "/" && !key.shift) {
499
- setFilterMode(true)
670
+ // When drilled into a trace (viewLevel >= 1 — waterfall or
671
+ // span detail is the dominant pane), `/` opens a filter scoped
672
+ // to the current trace's spans instead of the trace list.
673
+ // Drill level here is inferred from selectedSpanIndex/detailView
674
+ // the same way useAppLayout does it.
675
+ const inWaterfall = s.detailView === "span-detail" || s.selectedSpanIndex !== null
676
+ if (inWaterfall) {
677
+ setWaterfallFilterMode(true)
678
+ } else {
679
+ setFilterMode(true)
680
+ }
500
681
  return
501
682
  }
502
683
  if ((key.name === "f" || key.name === "F") && !key.ctrl && !key.meta) {
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import type { TraceSpanItem } from "../domain.ts"
3
+ import { computeMatchingSpanIds, findAdjacentMatch, spanMatchesFilter } from "./waterfallFilter.ts"
4
+
5
+ const span = (spanId: string, operationName: string, tags: Record<string, string> = {}): TraceSpanItem => ({
6
+ spanId,
7
+ parentSpanId: null,
8
+ serviceName: "test",
9
+ scopeName: null,
10
+ kind: null,
11
+ operationName,
12
+ startTime: new Date(0),
13
+ isRunning: false,
14
+ durationMs: 1,
15
+ status: "ok",
16
+ depth: 0,
17
+ tags,
18
+ warnings: [],
19
+ events: [],
20
+ })
21
+
22
+ describe("spanMatchesFilter", () => {
23
+ it("matches operation name case-insensitively", () => {
24
+ expect(spanMatchesFilter(span("a", "ai.StreamText"), "stream")).toBe(true)
25
+ expect(spanMatchesFilter(span("a", "ai.StreamText"), "nope")).toBe(false)
26
+ })
27
+
28
+ it("matches tag values but not keys", () => {
29
+ expect(spanMatchesFilter(span("a", "op", { "ai.model.id": "claude" }), "claude")).toBe(true)
30
+ // Key-only match should not count, otherwise searching "ai" dims nothing.
31
+ expect(spanMatchesFilter(span("a", "op", { "ai.model.id": "claude" }), "model.id")).toBe(false)
32
+ })
33
+
34
+ it("returns true when the needle is empty", () => {
35
+ expect(spanMatchesFilter(span("a", "op"), "")).toBe(true)
36
+ })
37
+ })
38
+
39
+ describe("computeMatchingSpanIds", () => {
40
+ it("returns null for empty/whitespace filter", () => {
41
+ expect(computeMatchingSpanIds([span("a", "op")], "")).toBeNull()
42
+ expect(computeMatchingSpanIds([span("a", "op")], " ")).toBeNull()
43
+ })
44
+
45
+ it("returns only matching span ids", () => {
46
+ const spans = [span("a", "ai.streamText"), span("b", "Agent.get"), span("c", "ai.toolCall")]
47
+ const ids = computeMatchingSpanIds(spans, "ai")
48
+ expect(ids).not.toBeNull()
49
+ expect(Array.from(ids!)).toEqual(["a", "c"])
50
+ })
51
+ })
52
+
53
+ describe("findAdjacentMatch", () => {
54
+ const spans = [span("a", "one"), span("b", "two"), span("c", "three"), span("d", "four")]
55
+ const matches = new Set(["b", "d"])
56
+
57
+ it("finds next from current selection", () => {
58
+ expect(findAdjacentMatch(spans, matches, 0, 1)).toBe(1) // a -> b
59
+ expect(findAdjacentMatch(spans, matches, 1, 1)).toBe(3) // b -> d
60
+ })
61
+
62
+ it("wraps forward past the end", () => {
63
+ expect(findAdjacentMatch(spans, matches, 3, 1)).toBe(1) // d -> b (wrap)
64
+ })
65
+
66
+ it("finds previous from current selection", () => {
67
+ expect(findAdjacentMatch(spans, matches, 3, -1)).toBe(1) // d -> b
68
+ expect(findAdjacentMatch(spans, matches, 1, -1)).toBe(3) // b -> d (wrap)
69
+ })
70
+
71
+ it("starts from beginning/end when nothing is selected", () => {
72
+ expect(findAdjacentMatch(spans, matches, null, 1)).toBe(1) // forward → first match
73
+ expect(findAdjacentMatch(spans, matches, null, -1)).toBe(3) // backward → last match
74
+ })
75
+
76
+ it("returns null when there are no matches", () => {
77
+ expect(findAdjacentMatch(spans, new Set(), 0, 1)).toBeNull()
78
+ })
79
+
80
+ it("handles a single match by returning it regardless of direction", () => {
81
+ expect(findAdjacentMatch(spans, new Set(["c"]), 0, 1)).toBe(2)
82
+ expect(findAdjacentMatch(spans, new Set(["c"]), 0, -1)).toBe(2)
83
+ })
84
+ })
@@ -0,0 +1,59 @@
1
+ import type { TraceSpanItem } from "../domain.ts"
2
+
3
+ /**
4
+ * Case-insensitive substring match against a span's operation name and
5
+ * any of its tag values. Keys aren't checked — they're dotted
6
+ * identifiers the user never types, so matching them just creates false
7
+ * positives on bare tokens like "ai" or "response".
8
+ */
9
+ export const spanMatchesFilter = (span: TraceSpanItem, needle: string): boolean => {
10
+ if (!needle) return true
11
+ if (span.operationName.toLowerCase().includes(needle)) return true
12
+ for (const value of Object.values(span.tags)) {
13
+ if (typeof value === "string" && value.toLowerCase().includes(needle)) return true
14
+ }
15
+ return false
16
+ }
17
+
18
+ /**
19
+ * Compute the set of span IDs that match the given filter text. Returns
20
+ * null when the filter is empty so callers can skip dimming entirely
21
+ * (hot path during waterfall scrolling).
22
+ */
23
+ export const computeMatchingSpanIds = (
24
+ spans: readonly TraceSpanItem[],
25
+ filterText: string,
26
+ ): ReadonlySet<string> | null => {
27
+ const needle = filterText.trim().toLowerCase()
28
+ if (!needle) return null
29
+ const matches = new Set<string>()
30
+ for (const span of spans) {
31
+ if (spanMatchesFilter(span, needle)) matches.add(span.spanId)
32
+ }
33
+ return matches
34
+ }
35
+
36
+ /**
37
+ * Find the next (direction=1) or previous (direction=-1) matching span
38
+ * index in `filteredSpans` relative to `currentIndex`. Wraps around the
39
+ * ends. Returns `null` when the list has no matches at all. When
40
+ * `currentIndex` is null (nothing selected), starts from either end
41
+ * based on direction.
42
+ */
43
+ export const findAdjacentMatch = (
44
+ filteredSpans: readonly TraceSpanItem[],
45
+ matchingSpanIds: ReadonlySet<string>,
46
+ currentIndex: number | null,
47
+ direction: 1 | -1,
48
+ ): number | null => {
49
+ if (filteredSpans.length === 0 || matchingSpanIds.size === 0) return null
50
+ const start = currentIndex ?? (direction === 1 ? -1 : filteredSpans.length)
51
+ const n = filteredSpans.length
52
+ // Walk the ring exactly once so we wrap but never loop forever.
53
+ for (let step = 1; step <= n; step++) {
54
+ const idx = ((start + direction * step) % n + n) % n
55
+ const span = filteredSpans[idx]
56
+ if (span && matchingSpanIds.has(span.spanId)) return idx
57
+ }
58
+ return null
59
+ }