@kitlangton/motel 0.1.1 → 0.1.3

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.
package/src/ui/state.ts CHANGED
@@ -106,6 +106,32 @@ export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
106
106
  export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
107
107
  export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
108
108
 
109
+ // Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
110
+ export type AttrPickerMode = "off" | "keys" | "values"
111
+ export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
112
+ export const attrPickerInputAtom = Atom.make("").pipe(Atom.keepAlive)
113
+ export const attrPickerIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
114
+
115
+ export interface AttrFacetState {
116
+ readonly status: LoadStatus
117
+ readonly key: string | null // null when loading keys; set when loading values
118
+ readonly data: readonly { readonly value: string; readonly count: number }[]
119
+ readonly error: string | null
120
+ }
121
+
122
+ export const initialAttrFacetState: AttrFacetState = {
123
+ status: "ready",
124
+ key: null,
125
+ data: [],
126
+ error: null,
127
+ }
128
+
129
+ export const attrFacetStateAtom = Atom.make(initialAttrFacetState).pipe(Atom.keepAlive)
130
+
131
+ // Applied filter (drives trace list query)
132
+ export const activeAttrKeyAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
133
+ export const activeAttrValueAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
134
+
109
135
  const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
110
136
  const readLastTheme = (): ThemeName => {
111
137
  try {
@@ -132,6 +158,12 @@ export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<s
132
158
 
133
159
  export const loadTraceServices = () => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
134
160
  export const loadRecentTraceSummaries = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
161
+ export const loadFilteredTraceSummaries = (serviceName: string, attributeFilters: Readonly<Record<string, string>>) =>
162
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({ serviceName, attributeFilters, limit: config.otel.traceFetchLimit })))
163
+ export const loadTraceAttributeKeys = (serviceName: string) =>
164
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
165
+ export const loadTraceAttributeValues = (serviceName: string, key: string) =>
166
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
135
167
  export const loadTraceDetail = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
136
168
  export const loadTraceLogs = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
137
169
  export const loadServiceLogs = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
package/src/ui/theme.ts CHANGED
@@ -32,32 +32,37 @@ export interface ThemeDefinition {
32
32
  readonly waterfall: ThemeWaterfallColors
33
33
  }
34
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.
35
40
  const motelDefaultTheme: ThemeDefinition = {
36
41
  name: "motel-default",
37
42
  label: "Motel Default",
38
43
  colors: {
39
- screenBg: "#1c1b29",
40
- text: "#ede7da",
41
- muted: "#9f9788",
42
- separator: "#6f685d",
43
- accent: "#f4a51c",
44
- error: "#f97316",
45
- selectedBg: "#263044",
46
- warning: "#facc15",
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)
47
52
  selectedText: "#f8fafc",
48
- count: "#d7c5a1",
49
- passing: "#7dd3a3",
50
- defaultService: "#93c5fd",
51
- footerBg: "#000000",
52
- treeLine: "#524d45",
53
- previewKey: "#6a6358",
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)
54
59
  },
55
60
  waterfall: {
56
- bar: "#f4a51c",
57
- barError: "#f97316",
58
- barBg: "#2a2520",
59
- barLane: "#4a4338",
60
- barSelected: "#e8c547",
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
61
66
  barSelectedError: "#ff8c42",
62
67
  },
63
68
  }
@@ -0,0 +1,115 @@
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 lastServicePath = join(tempDir, "last-service.txt")
56
+ let canRun = false
57
+
58
+ beforeAll(async () => {
59
+ canRun = await hasTuistory()
60
+ if (!canRun) return
61
+
62
+ writeFileSync(lastServicePath, "waterfall-repro")
63
+
64
+ const seed = Bun.spawn({
65
+ cmd: ["bun", "run", "src/ui/waterfallNav.repro.seed.ts"],
66
+ cwd: process.cwd(),
67
+ env: {
68
+ ...process.env,
69
+ MOTEL_OTEL_DB_PATH: dbPath,
70
+ MOTEL_OTEL_ENABLED: "false",
71
+ },
72
+ stdout: "pipe",
73
+ stderr: "pipe",
74
+ })
75
+ const seedCode = await seed.exited
76
+ if (seedCode !== 0) {
77
+ const err = await new Response(seed.stderr).text()
78
+ throw new Error(`seed failed: ${err}`)
79
+ }
80
+
81
+ await tui(["close", "--session", SESSION])
82
+ const launch = await tui([
83
+ "launch",
84
+ "bun run src/index.tsx",
85
+ "--session", SESSION,
86
+ "--cols", "96",
87
+ "--rows", "40",
88
+ "--cwd", process.cwd(),
89
+ "--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
90
+ "--env", "MOTEL_OTEL_ENABLED=false",
91
+ "--timeout", "15000",
92
+ ])
93
+ if (launch.code !== 0) throw new Error(`launch failed: ${launch.stderr}`)
94
+ await tui(["wait", "root.op", "--session", SESSION, "--timeout", "10000"])
95
+ await tui(["wait-idle", "--session", SESSION, "--timeout", "5000"])
96
+ }, 60_000)
97
+
98
+ afterAll(async () => {
99
+ if (canRun) await tui(["close", "--session", SESSION])
100
+ try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
101
+ })
102
+
103
+ it("fills the full-width trace details pane in narrow mode", async () => {
104
+ if (!canRun) return
105
+
106
+ await press("return")
107
+ const snap = await snapshot()
108
+ const divider = dividerWidth(snap)
109
+ const row = rootWaterfallRow(snap)
110
+
111
+ expect(divider).toBe(96)
112
+ expect(row).not.toBeNull()
113
+ expect(row!.length).toBeGreaterThanOrEqual(divider - 1)
114
+ }, 60_000)
115
+ })
@@ -0,0 +1,47 @@
1
+ import { useAtom } from "@effect/atom-react"
2
+ import { useEffect } from "react"
3
+ import {
4
+ attrFacetStateAtom,
5
+ attrPickerModeAtom,
6
+ initialAttrFacetState,
7
+ loadTraceAttributeKeys,
8
+ loadTraceAttributeValues,
9
+ selectedTraceServiceAtom,
10
+ } from "./state.ts"
11
+
12
+ // When the picker is open, load the current facet page (keys, or values for
13
+ // a specific key) and keep it in sync with the selected service. We key the
14
+ // effect off picker mode + service + target key so refetches happen on drill
15
+ // in/out and when the user switches services mid-pick.
16
+ export const useAttrFilterPicker = (selectedKey: string | null) => {
17
+ const [pickerMode] = useAtom(attrPickerModeAtom)
18
+ const [service] = useAtom(selectedTraceServiceAtom)
19
+ const [, setFacetState] = useAtom(attrFacetStateAtom)
20
+
21
+ useEffect(() => {
22
+ if (pickerMode === "off" || !service) {
23
+ setFacetState(initialAttrFacetState)
24
+ return
25
+ }
26
+ let cancelled = false
27
+ setFacetState({ status: "loading", key: pickerMode === "values" ? selectedKey : null, data: [], error: null })
28
+ const load = async () => {
29
+ try {
30
+ const rows = pickerMode === "keys"
31
+ ? await loadTraceAttributeKeys(service)
32
+ : selectedKey
33
+ ? await loadTraceAttributeValues(service, selectedKey)
34
+ : []
35
+ if (cancelled) return
36
+ setFacetState({ status: "ready", key: pickerMode === "values" ? selectedKey : null, data: rows, error: null })
37
+ } catch (err) {
38
+ if (cancelled) return
39
+ setFacetState({ status: "error", key: pickerMode === "values" ? selectedKey : null, data: [], error: err instanceof Error ? err.message : String(err) })
40
+ }
41
+ }
42
+ void load()
43
+ return () => {
44
+ cancelled = true
45
+ }
46
+ }, [pickerMode, service, selectedKey, setFacetState])
47
+ }
@@ -1,10 +1,16 @@
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"
7
7
  import {
8
+ activeAttrKeyAtom,
9
+ activeAttrValueAtom,
10
+ attrFacetStateAtom,
11
+ attrPickerIndexAtom,
12
+ attrPickerInputAtom,
13
+ attrPickerModeAtom,
8
14
  autoRefreshAtom,
9
15
  collapsedSpanIdsAtom,
10
16
  detailViewAtom,
@@ -22,11 +28,39 @@ import {
22
28
  type TraceSortMode,
23
29
  traceStateAtom,
24
30
  } from "./state.ts"
31
+ import { filterFacets } from "./AttrFilterModal.tsx"
25
32
  import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
26
33
  import { cycleThemeName, themeLabel } from "./theme.ts"
27
34
  import { getVisibleSpans } from "./Waterfall.tsx"
28
35
  import { resolveCollapseStep } from "./waterfallNav.ts"
29
36
 
37
+ /**
38
+ * Pull a printable string out of a key event. Handles two cases:
39
+ *
40
+ * 1. A plain printable key (1 char) — returns the char.
41
+ * 2. A multi-char sequence that arrived as one event (common when the
42
+ * terminal has bracketed paste disabled but the user pasted quickly and
43
+ * opentui's parser returned the whole buffer as one key). Returns the
44
+ * sanitised sequence with control bytes stripped.
45
+ *
46
+ * Returns `null` for non-printable events (function keys, modifiers, etc.)
47
+ * so callers can skip them.
48
+ */
49
+ const extractPrintable = (key: {
50
+ readonly name: string
51
+ readonly sequence?: string
52
+ readonly ctrl: boolean
53
+ readonly meta: boolean
54
+ }): string | null => {
55
+ if (key.ctrl || key.meta) return null
56
+ if (key.name.length === 1) return key.name
57
+ const seq = key.sequence ?? ""
58
+ // Only accept sequences that are pure printable text. Any escape or
59
+ // control byte means this was a function / navigation key.
60
+ if (seq.length > 1 && !/[\x00-\x1f\x7f]/.test(seq)) return seq
61
+ return null
62
+ }
63
+
30
64
  interface KeyboardNavParams {
31
65
  selectedTrace: TraceItem | null
32
66
  filteredTraces: readonly TraceSummaryItem[]
@@ -65,6 +99,12 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
65
99
  const [filterMode, setFilterMode] = useAtom(filterModeAtom)
66
100
  const [filterText, setFilterText] = useAtom(filterTextAtom)
67
101
  const [traceSort, setTraceSort] = useAtom(traceSortAtom)
102
+ const [pickerMode, setPickerMode] = useAtom(attrPickerModeAtom)
103
+ const [pickerInput, setPickerInput] = useAtom(attrPickerInputAtom)
104
+ const [pickerIndex, setPickerIndex] = useAtom(attrPickerIndexAtom)
105
+ const [attrFacets] = useAtom(attrFacetStateAtom)
106
+ const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
107
+ const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
68
108
 
69
109
  const pendingGRef = useRef(false)
70
110
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -73,12 +113,50 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
73
113
  const spanNavActive = detailView !== "service-logs" && selectedSpanIndex !== null
74
114
  const serviceLogNavActive = detailView === "service-logs"
75
115
 
76
- const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, ...params })
116
+ // Bracketed paste: when the terminal has bracketed paste enabled, opentui
117
+ // surfaces the full pasted text as a single "paste" event on keyInput.
118
+ // Route it into whichever input is currently open. We also enable the
119
+ // mode ourselves (`\x1b[?2004h`) in case the host terminal didn't — it's
120
+ // a no-op on terminals that already had it on.
121
+ useEffect(() => {
122
+ const keyInput = (renderer as unknown as { keyInput?: { on: (event: string, handler: (e: unknown) => void) => void; off: (event: string, handler: (e: unknown) => void) => void } }).keyInput
123
+ if (!keyInput) return
124
+ try {
125
+ process.stdout.write("\x1b[?2004h")
126
+ } catch {
127
+ // Best effort — some test environments don't have a real TTY.
128
+ }
129
+ const handler = (event: unknown) => {
130
+ const bytes = (event as { bytes?: Uint8Array }).bytes
131
+ if (!bytes || bytes.length === 0) return
132
+ const text = Buffer.from(bytes).toString("utf8").replace(/[\x00-\x1f\x7f]+/g, (match) => match === "\n" ? " " : "")
133
+ if (!text) return
134
+ const s = stateRef.current
135
+ if (s.pickerMode !== "off") {
136
+ setPickerInput((current) => current + text)
137
+ setPickerIndex(0)
138
+ return
139
+ }
140
+ if (s.filterMode) {
141
+ setFilterText((current) => current + text)
142
+ return
143
+ }
144
+ }
145
+ keyInput.on("paste", handler)
146
+ return () => {
147
+ keyInput.off("paste", handler)
148
+ try {
149
+ process.stdout.write("\x1b[?2004l")
150
+ } catch {}
151
+ }
152
+ }, [renderer, setFilterText, setPickerInput, setPickerIndex])
153
+
154
+ 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 })
77
155
  // Keep the keyboard handler's state mirror in sync before the next paint.
78
156
  // OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
79
157
  // rapid repeated keypresses can otherwise observe stale selection state.
80
158
  useLayoutEffect(() => {
81
- stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, ...params }
159
+ 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 }
82
160
  })
83
161
 
84
162
  const clearPendingG = () => {
@@ -230,7 +308,8 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
230
308
  if (s.serviceLogState.data.length === 0) return 0
231
309
  return Math.max(0, Math.min(current + direction * serviceLogPageSize, s.serviceLogState.data.length - 1))
232
310
  })
233
- } else if (s.spanNavActive && s.selectedTrace) {
311
+ } else if (s.spanNavActive) {
312
+ if (!s.selectedTrace) return
234
313
  const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
235
314
  setSelectedSpanIndex((current) => {
236
315
  if (visibleCount === 0) return null
@@ -257,6 +336,83 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
257
336
  useKeyboard((key) => {
258
337
  const s = $()
259
338
 
339
+ // Attribute picker modal owns the keyboard while open.
340
+ if (s.pickerMode !== "off") {
341
+ const rows = filterFacets(s.attrFacets.data, s.pickerInput)
342
+ const clampedIndex = rows.length === 0 ? 0 : Math.max(0, Math.min(s.pickerIndex, rows.length - 1))
343
+ const move = (delta: number) => {
344
+ if (rows.length === 0) return
345
+ setPickerIndex(Math.max(0, Math.min(clampedIndex + delta, rows.length - 1)))
346
+ }
347
+ if (key.name === "escape") {
348
+ setPickerMode("off")
349
+ setPickerInput("")
350
+ setPickerIndex(0)
351
+ return
352
+ }
353
+ // Ctrl-C: clear input, or close the picker if already empty.
354
+ if (key.ctrl && key.name === "c") {
355
+ if (s.pickerInput.length > 0) {
356
+ setPickerInput("")
357
+ setPickerIndex(0)
358
+ } else {
359
+ setPickerMode("off")
360
+ setPickerIndex(0)
361
+ }
362
+ return
363
+ }
364
+ if (key.name === "up" || (key.ctrl && key.name === "p")) { move(-1); return }
365
+ if (key.name === "down" || (key.ctrl && key.name === "n")) { move(1); return }
366
+ if (key.name === "pageup") { move(-10); return }
367
+ if (key.name === "pagedown") { move(10); return }
368
+ if (key.name === "return" || key.name === "enter") {
369
+ const row = rows[clampedIndex]
370
+ if (!row) return
371
+ if (s.pickerMode === "keys") {
372
+ // Drill from keys → values for this key.
373
+ setActiveAttrKey(row.value)
374
+ setPickerMode("values")
375
+ setPickerInput("")
376
+ setPickerIndex(0)
377
+ } else {
378
+ // Apply: activeAttrKey is already set, now pin the value.
379
+ setActiveAttrValue(row.value)
380
+ setPickerMode("off")
381
+ setPickerInput("")
382
+ setPickerIndex(0)
383
+ s.flashNotice(`Filter: ${s.activeAttrKey}=${row.value}`)
384
+ }
385
+ return
386
+ }
387
+ if (key.name === "backspace") {
388
+ if (s.pickerInput.length > 0) {
389
+ setPickerInput(s.pickerInput.slice(0, -1))
390
+ setPickerIndex(0)
391
+ return
392
+ }
393
+ // At empty input in values mode, backspace walks back to keys.
394
+ if (s.pickerMode === "values") {
395
+ setPickerMode("keys")
396
+ setActiveAttrKey(null)
397
+ setPickerIndex(0)
398
+ return
399
+ }
400
+ return
401
+ }
402
+ // Prefer key.sequence over key.name so multi-char paste events that
403
+ // slip through as a single raw sequence still get inserted in full.
404
+ const printable = extractPrintable(key)
405
+ if (printable) {
406
+ // Functional setState — multiple key events in the same tick would
407
+ // otherwise all read a stale stateRef.current.pickerInput and
408
+ // clobber each other, losing all but the last char of a paste.
409
+ setPickerInput((current) => current + printable)
410
+ setPickerIndex(0)
411
+ return
412
+ }
413
+ return
414
+ }
415
+
260
416
  // Filter mode: capture text input
261
417
  if (s.filterMode) {
262
418
  if (key.name === "escape") {
@@ -264,17 +420,28 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
264
420
  setFilterText("")
265
421
  return
266
422
  }
423
+ // Ctrl-C: clear the input, or exit filter mode if already empty.
424
+ if (key.ctrl && key.name === "c") {
425
+ if (s.filterText.length > 0) {
426
+ setFilterText("")
427
+ } else {
428
+ setFilterMode(false)
429
+ }
430
+ return
431
+ }
267
432
  if (key.name === "return" || key.name === "enter") {
268
433
  setFilterMode(false)
269
434
  return
270
435
  }
271
436
  if (key.name === "backspace") {
272
- setFilterText(s.filterText.slice(0, -1))
437
+ setFilterText((current) => current.slice(0, -1))
273
438
  return
274
439
  }
275
- // Single printable character
276
- if (key.name.length === 1 && !key.ctrl && !key.meta) {
277
- setFilterText(s.filterText + key.name)
440
+ const printable = extractPrintable(key)
441
+ if (printable) {
442
+ // Functional setState so rapid keystrokes / pastes don't clobber
443
+ // each other via a stale stateRef.current.filterText closure.
444
+ setFilterText((current) => current + printable)
278
445
  return
279
446
  }
280
447
  return
@@ -365,6 +532,15 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
365
532
  setSelectedSpanIndex(null)
366
533
  return
367
534
  }
535
+ // At the trace list, `esc` clears any applied attribute filter so
536
+ // there's a clean way back to the unfiltered list without hunting
537
+ // for the picker key.
538
+ if (s.activeAttrKey || s.activeAttrValue) {
539
+ setActiveAttrKey(null)
540
+ setActiveAttrValue(null)
541
+ s.flashNotice("Cleared attribute filter")
542
+ return
543
+ }
368
544
  return
369
545
  }
370
546
  if (key.name === "return" || key.name === "enter") {
@@ -416,6 +592,15 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
416
592
  setFilterMode(true)
417
593
  return
418
594
  }
595
+ if ((key.name === "f" || key.name === "F") && !key.ctrl && !key.meta) {
596
+ // Open attribute picker at the keys step. If a filter is already
597
+ // applied, reopening lets the user refine or switch.
598
+ setPickerMode("keys")
599
+ setPickerInput("")
600
+ setPickerIndex(0)
601
+ setActiveAttrKey(null)
602
+ return
603
+ }
419
604
  if (key.name === "tab") {
420
605
  toggleServiceLogsView()
421
606
  return
@@ -431,12 +616,17 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
431
616
  if (key.name === "up" || key.name === "k") {
432
617
  if (s.serviceLogNavActive) {
433
618
  moveServiceLogBy(-1)
434
- } else if (s.spanNavActive && s.selectedTrace) {
435
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
436
- setSelectedSpanIndex((current) => {
437
- if (current === null || visibleCount === 0) return 0
438
- return Math.max(0, current - 1)
439
- })
619
+ } else if (s.spanNavActive) {
620
+ // Locked to span nav; never fall through to trace-list nav while
621
+ // drilled in. If the trace detail is still loading, swallow the
622
+ // key instead of silently leaking it to the trace list.
623
+ if (s.selectedTrace) {
624
+ const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
625
+ setSelectedSpanIndex((current) => {
626
+ if (current === null || visibleCount === 0) return 0
627
+ return Math.max(0, current - 1)
628
+ })
629
+ }
440
630
  } else {
441
631
  moveTraceBy(-1)
442
632
  }
@@ -445,12 +635,14 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
445
635
  if (key.name === "down" || key.name === "j") {
446
636
  if (s.serviceLogNavActive) {
447
637
  moveServiceLogBy(1)
448
- } else if (s.spanNavActive && s.selectedTrace) {
449
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
450
- setSelectedSpanIndex((current) => {
451
- if (current === null || visibleCount === 0) return 0
452
- return Math.min(current + 1, visibleCount - 1)
453
- })
638
+ } else if (s.spanNavActive) {
639
+ if (s.selectedTrace) {
640
+ const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
641
+ setSelectedSpanIndex((current) => {
642
+ if (current === null || visibleCount === 0) return 0
643
+ return Math.min(current + 1, visibleCount - 1)
644
+ })
645
+ }
454
646
  } else {
455
647
  moveTraceBy(1)
456
648
  }
@@ -3,7 +3,8 @@ import type { TraceSpanItem } from "../domain.ts"
3
3
  import {
4
4
  findFirstChildIndex,
5
5
  findParentIndex,
6
- getWaterfallColumns,
6
+ getWaterfallLayout,
7
+ getWaterfallSuffixMetrics,
7
8
  getVisibleSpans,
8
9
  } from "./Waterfall.tsx"
9
10
  import { resolveCollapseStep } from "./waterfallNav.ts"
@@ -130,13 +131,27 @@ describe("getVisibleSpans", () => {
130
131
  })
131
132
  })
132
133
 
133
- describe("getWaterfallColumns", () => {
134
- it("pads duration and log columns to fill the reserved width", () => {
134
+ describe("getWaterfallSuffixMetrics", () => {
135
+ it("uses the widest visible duration as the shared suffix width", () => {
136
+ const spans = [
137
+ { spanId: "a", durationMs: 1 },
138
+ { spanId: "b", durationMs: 57_000 },
139
+ { spanId: "c", durationMs: 120 },
140
+ ]
141
+ const metrics = getWaterfallSuffixMetrics(spans)
142
+ // `120ms` = 5 is the widest
143
+ expect(metrics.maxDurationWidth).toBe(5)
144
+ expect(metrics.suffixWidth).toBe(5)
145
+ })
146
+
147
+ it("layout reserves the suffix once and leaves the rest for the bar", () => {
135
148
  const contentWidth = 72
136
- const columns = getWaterfallColumns(contentWidth, 153_000, 1, 0)
137
- expect(columns.durationCell.length).toBe(columns.durationWidth)
138
- expect(columns.logCell.length).toBe(columns.logWidth)
139
- expect(columns.labelMaxWidth + 1 + columns.barWidth + 1 + columns.durationCell.length + columns.logCell.length).toBe(contentWidth)
149
+ const metrics = getWaterfallSuffixMetrics(
150
+ [{ spanId: "a", durationMs: 57_000 }, { spanId: "b", durationMs: 1 }],
151
+ )
152
+ const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
153
+ // label + 1 (gap before bar) + bar + 1 (gap before suffix) + suffix = contentWidth
154
+ expect(labelMaxWidth + 1 + barWidth + 1 + metrics.suffixWidth).toBe(contentWidth)
140
155
  })
141
156
  })
142
157