@kitlangton/motel 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/AGENTS.md +11 -1
  2. package/package.json +5 -3
  3. package/src/App.tsx +239 -59
  4. package/src/daemon.test.ts +144 -7
  5. package/src/daemon.ts +113 -8
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +62 -4
  8. package/src/httpApi.ts +4 -1
  9. package/src/localServer.ts +112 -121
  10. package/src/mcp.ts +172 -0
  11. package/src/motelClient.ts +166 -14
  12. package/src/registry.ts +26 -23
  13. package/src/runtime.ts +8 -2
  14. package/src/server.ts +10 -9
  15. package/src/services/AsyncIngest.ts +52 -0
  16. package/src/services/TelemetryStore.ts +285 -27
  17. package/src/services/TraceQueryService.ts +4 -2
  18. package/src/services/ingestRpc.ts +41 -0
  19. package/src/services/telemetryWorker.ts +62 -0
  20. package/src/storybook/aiChatStory.tsx +243 -0
  21. package/src/storybook/fixtures/errorState.ts +44 -0
  22. package/src/storybook/fixtures/imagePaste.ts +34 -0
  23. package/src/storybook/fixtures/index.ts +62 -0
  24. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  25. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  26. package/src/storybook/fixtures/short.ts +27 -0
  27. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  28. package/src/telemetry.test.ts +61 -0
  29. package/src/ui/AiChatView.tsx +292 -0
  30. package/src/ui/SpanContentView.tsx +181 -0
  31. package/src/ui/SpanDetail.tsx +98 -17
  32. package/src/ui/TraceDetailsPane.tsx +35 -3
  33. package/src/ui/Waterfall.tsx +94 -167
  34. package/src/ui/aiChatModel.test.ts +347 -0
  35. package/src/ui/aiChatModel.ts +736 -0
  36. package/src/ui/aiState.ts +71 -0
  37. package/src/ui/app/TraceWorkspace.tsx +295 -120
  38. package/src/ui/app/useAppLayout.ts +14 -11
  39. package/src/ui/app/useTraceScreenData.ts +191 -35
  40. package/src/ui/atoms.ts +131 -0
  41. package/src/ui/filterParser.test.ts +56 -0
  42. package/src/ui/filterParser.ts +45 -0
  43. package/src/ui/loaders.ts +120 -0
  44. package/src/ui/persistence.ts +41 -0
  45. package/src/ui/primitives.tsx +47 -21
  46. package/src/ui/state.ts +4 -169
  47. package/src/ui/useAttrFilterPicker.ts +63 -23
  48. package/src/ui/useKeyboardNav.ts +576 -300
  49. package/src/ui/waterfallFilter.test.ts +84 -0
  50. package/src/ui/waterfallFilter.ts +59 -0
  51. package/src/ui/waterfallModel.ts +130 -0
  52. package/src/ui/waterfallNav.test.ts +17 -1
  53. package/src/ui/waterfallNav.ts +1 -1
  54. package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
  55. package/web/dist/index.html +1 -1
@@ -1,8 +1,9 @@
1
1
  import { useAtom } from "@effect/atom-react"
2
2
  import { useKeyboard, useRenderer } from "@opentui/react"
3
3
  import { useEffect, useLayoutEffect, useRef } from "react"
4
- import type { TraceItem, TraceSummaryItem } from "../domain.ts"
4
+ import { isAiSpan, type TraceItem, type TraceSummaryItem } from "../domain.ts"
5
5
  import { otelServerInstructions } from "../instructions.ts"
6
+ import { renderChunkDetailLines, type Chunk } from "./aiChatModel.ts"
6
7
  import { copyToClipboard, traceUiUrl, webUiUrl } from "./format.ts"
7
8
  import {
8
9
  activeAttrKeyAtom,
@@ -12,11 +13,15 @@ import {
12
13
  attrPickerInputAtom,
13
14
  attrPickerModeAtom,
14
15
  autoRefreshAtom,
16
+ chatDetailChunkIdAtom,
17
+ chatDetailScrollOffsetAtom,
15
18
  collapsedSpanIdsAtom,
16
19
  detailViewAtom,
17
20
  filterModeAtom,
18
21
  filterTextAtom,
19
22
  refreshNonceAtom,
23
+ selectedAttrIndexAtom,
24
+ selectedChatChunkIdAtom,
20
25
  selectedThemeAtom,
21
26
  selectedServiceLogIndexAtom,
22
27
  selectedSpanIndexAtom,
@@ -27,10 +32,13 @@ import {
27
32
  traceSortAtom,
28
33
  type TraceSortMode,
29
34
  traceStateAtom,
35
+ waterfallFilterModeAtom,
36
+ waterfallFilterTextAtom,
30
37
  } from "./state.ts"
31
38
  import { filterFacets } from "./AttrFilterModal.tsx"
32
39
  import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
33
40
  import { cycleThemeName, themeLabel } from "./theme.ts"
41
+ import { computeMatchingSpanIds, findAdjacentMatch } from "./waterfallFilter.ts"
34
42
  import { getVisibleSpans } from "./Waterfall.tsx"
35
43
  import { resolveCollapseStep } from "./waterfallNav.ts"
36
44
 
@@ -46,13 +54,22 @@ import { resolveCollapseStep } from "./waterfallNav.ts"
46
54
  * Returns `null` for non-printable events (function keys, modifiers, etc.)
47
55
  * so callers can skip them.
48
56
  */
49
- const extractPrintable = (key: {
57
+ interface KeyboardKey {
50
58
  readonly name: string
51
59
  readonly sequence?: string
52
60
  readonly ctrl: boolean
53
61
  readonly meta: boolean
54
- }): string | null => {
62
+ readonly option?: boolean
63
+ readonly shift?: boolean
64
+ readonly repeated?: boolean
65
+ }
66
+
67
+ const extractPrintable = (key: KeyboardKey): string | null => {
55
68
  if (key.ctrl || key.meta) return null
69
+ // Space arrives as `key.name === "space"` with a 1-char sequence. We
70
+ // handle it explicitly because the generic "length > 1" branch below
71
+ // only catches multi-char paste sequences, not a lone " ".
72
+ if (key.name === "space") return " "
56
73
  if (key.name.length === 1) return key.name
57
74
  const seq = key.sequence ?? ""
58
75
  // Only accept sequences that are pure printable text. Any escape or
@@ -64,6 +81,7 @@ const extractPrintable = (key: {
64
81
  interface KeyboardNavParams {
65
82
  selectedTrace: TraceItem | null
66
83
  filteredTraces: readonly TraceSummaryItem[]
84
+ aiChatChunks: readonly Chunk[]
67
85
  isWideLayout: boolean
68
86
  wideBodyLines: number
69
87
  narrowBodyLines: number
@@ -72,6 +90,11 @@ interface KeyboardNavParams {
72
90
  flashNotice: (message: string) => void
73
91
  }
74
92
 
93
+ const findTraceIndexById = (traces: readonly TraceSummaryItem[], traceId: string | null) =>
94
+ traceId === null ? -1 : traces.findIndex((trace) => trace.traceId === traceId)
95
+
96
+ const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
97
+
75
98
  export const useKeyboardNav = (params: KeyboardNavParams) => {
76
99
  const {
77
100
  selectedTrace,
@@ -105,6 +128,12 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
105
128
  const [attrFacets] = useAtom(attrFacetStateAtom)
106
129
  const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
107
130
  const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
131
+ const [waterfallFilterMode, setWaterfallFilterMode] = useAtom(waterfallFilterModeAtom)
132
+ const [waterfallFilterText, setWaterfallFilterText] = useAtom(waterfallFilterTextAtom)
133
+ const [selectedAttrIndex, setSelectedAttrIndex] = useAtom(selectedAttrIndexAtom)
134
+ const [chatDetailChunkId, setChatDetailChunkId] = useAtom(chatDetailChunkIdAtom)
135
+ const [chatDetailScrollOffset, setChatDetailScrollOffset] = useAtom(chatDetailScrollOffsetAtom)
136
+ const [selectedChatChunkId, setSelectedChatChunkId] = useAtom(selectedChatChunkIdAtom)
108
137
 
109
138
  const pendingGRef = useRef(false)
110
139
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -112,6 +141,19 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
112
141
 
113
142
  const spanNavActive = detailView !== "service-logs" && selectedSpanIndex !== null
114
143
  const serviceLogNavActive = detailView === "service-logs"
144
+ // L2 (full-screen content view): j/k/y/gg/G operate on the tag list
145
+ // instead of the waterfall or trace list. Enter drilled us here from
146
+ // L1; esc drills back.
147
+ const attrNavActive = detailView === "span-detail" && selectedSpanIndex !== null
148
+ // L2 specialisation: when drilled into an AI-flagged span we render
149
+ // the chat transcript view instead of the attribute dump. j/k scroll
150
+ // the transcript by a line, ctrl-d/u page by half the viewport, y
151
+ // falls back to copying trace/span ids (the individual message
152
+ // copying can come later; line-level is rarely what you want).
153
+ const selectedSpanForAi = selectedTrace && selectedSpanIndex !== null
154
+ ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds)[selectedSpanIndex] ?? null
155
+ : null
156
+ const chatNavActive = attrNavActive && selectedSpanForAi !== null && isAiSpan(selectedSpanForAi.tags)
115
157
 
116
158
  // Bracketed paste: when the terminal has bracketed paste enabled, opentui
117
159
  // surfaces the full pasted text as a single "paste" event on keyInput.
@@ -151,12 +193,46 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
151
193
  }
152
194
  }, [renderer, setFilterText, setPickerInput, setPickerIndex])
153
195
 
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 })
196
+ const buildStateSnapshot = () => ({
197
+ traceState,
198
+ serviceLogState,
199
+ selectedServiceLogIndex,
200
+ selectedTheme,
201
+ selectedTraceIndex,
202
+ selectedSpanIndex,
203
+ selectedTraceService,
204
+ detailView,
205
+ showHelp,
206
+ collapsedSpanIds,
207
+ spanNavActive,
208
+ serviceLogNavActive,
209
+ attrNavActive,
210
+ chatNavActive,
211
+ selectedAttrIndex,
212
+ chatDetailChunkId,
213
+ chatDetailScrollOffset,
214
+ selectedChatChunkId,
215
+ filterMode,
216
+ filterText,
217
+ autoRefresh,
218
+ traceSort,
219
+ pickerMode,
220
+ pickerInput,
221
+ pickerIndex,
222
+ attrFacets,
223
+ activeAttrKey,
224
+ activeAttrValue,
225
+ waterfallFilterMode,
226
+ waterfallFilterText,
227
+ ...params,
228
+ })
229
+
230
+ const stateRef = useRef(buildStateSnapshot())
155
231
  // Keep the keyboard handler's state mirror in sync before the next paint.
156
232
  // OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
157
233
  // rapid repeated keypresses can otherwise observe stale selection state.
158
234
  useLayoutEffect(() => {
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 }
235
+ stateRef.current = buildStateSnapshot()
160
236
  })
161
237
 
162
238
  const clearPendingG = () => {
@@ -178,18 +254,114 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
178
254
 
179
255
  const $ = () => stateRef.current
180
256
 
257
+ const resetPicker = () => {
258
+ setPickerInput("")
259
+ setPickerIndex(0)
260
+ }
261
+
262
+ const closePicker = () => {
263
+ setPickerMode("off")
264
+ resetPicker()
265
+ }
266
+
267
+ const getVisibleSelectedSpans = () => {
268
+ const s = $()
269
+ return s.selectedTrace ? getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds) : []
270
+ }
271
+
272
+ const getSelectedVisibleSpan = () => {
273
+ const s = $()
274
+ if (s.selectedSpanIndex === null) return null
275
+ return getVisibleSelectedSpans()[s.selectedSpanIndex] ?? null
276
+ }
277
+
181
278
  const selectFilteredTraceAt = (filteredIdx: number) => {
182
279
  const s = $()
183
280
  const trace = s.filteredTraces[filteredIdx]
184
281
  if (!trace) return
185
- const fullIndex = s.traceState.data.findIndex((t) => t.traceId === trace.traceId)
282
+ const fullIndex = findTraceIndexById(s.traceState.data, trace.traceId)
186
283
  if (fullIndex >= 0) setSelectedTraceIndex(fullIndex)
187
284
  }
188
285
 
286
+ const currentFilteredTraceIndex = () => {
287
+ const s = $()
288
+ const selectedTraceId = s.traceState.data[s.selectedTraceIndex]?.traceId ?? null
289
+ return findTraceIndexById(s.filteredTraces, selectedTraceId)
290
+ }
291
+
292
+ const attrCountForSelectedSpan = () => {
293
+ const span = getSelectedVisibleSpan()
294
+ return span ? Object.keys(span.tags).length : 0
295
+ }
296
+
297
+ const moveTraceBy = (delta: number) => {
298
+ const s = $()
299
+ if (s.filteredTraces.length === 0) return
300
+ const currentIndex = currentFilteredTraceIndex()
301
+ const nextIndex = currentIndex < 0
302
+ ? 0
303
+ : Math.max(0, Math.min(currentIndex + delta, s.filteredTraces.length - 1))
304
+ selectFilteredTraceAt(nextIndex)
305
+ }
306
+
307
+ const moveServiceLogBy = (delta: number) => {
308
+ const s = $()
309
+ if (s.serviceLogState.data.length === 0) {
310
+ setSelectedServiceLogIndex(0)
311
+ return
312
+ }
313
+ setSelectedServiceLogIndex(Math.max(0, Math.min(s.selectedServiceLogIndex + delta, s.serviceLogState.data.length - 1)))
314
+ }
315
+
316
+ const moveSpanBy = (delta: number) => {
317
+ const s = $()
318
+ if (!s.selectedTrace) return
319
+ const visibleCount = getVisibleSelectedSpans().length
320
+ if (visibleCount === 0) {
321
+ setSelectedSpanIndex(null)
322
+ return
323
+ }
324
+ const current = s.selectedSpanIndex ?? 0
325
+ setSelectedSpanIndex(Math.max(0, Math.min(current + delta, visibleCount - 1)))
326
+ }
327
+
328
+ const moveAttrBy = (delta: number) => {
329
+ const count = attrCountForSelectedSpan()
330
+ if (count === 0) return
331
+ const s = $()
332
+ setSelectedAttrIndex(Math.max(0, Math.min(s.selectedAttrIndex + delta, count - 1)))
333
+ }
334
+
335
+ const moveChatChunkBy = (direction: -1 | 1) => {
336
+ const s = $()
337
+ const chunks = s.aiChatChunks
338
+ if (chunks.length === 0) return
339
+ if (s.chatDetailChunkId) return
340
+ const currentIdx = s.selectedChatChunkId
341
+ ? chunks.findIndex((c) => c.id === s.selectedChatChunkId)
342
+ : 0
343
+ const nextIdx = Math.max(0, Math.min(currentIdx + direction, chunks.length - 1))
344
+ const next = chunks[nextIdx]
345
+ if (next) setSelectedChatChunkId(next.id)
346
+ }
347
+
189
348
  const jumpToStart = () => {
190
349
  const s = $()
350
+ if (s.chatNavActive) {
351
+ if (s.chatDetailChunkId) {
352
+ setChatDetailScrollOffset(0)
353
+ return
354
+ }
355
+ const first = s.aiChatChunks[0]
356
+ if (first) setSelectedChatChunkId(first.id)
357
+ return
358
+ }
359
+ if (s.attrNavActive) {
360
+ setSelectedAttrIndex(0)
361
+ return
362
+ }
191
363
  if (s.spanNavActive && s.selectedTrace) {
192
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
364
+ const visibleCount = getVisibleSelectedSpans().length
193
365
  setSelectedSpanIndex(visibleCount === 0 ? null : 0)
194
366
  } else {
195
367
  selectFilteredTraceAt(0)
@@ -198,45 +370,32 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
198
370
 
199
371
  const jumpToEnd = () => {
200
372
  const s = $()
373
+ if (s.chatNavActive) {
374
+ if (s.chatDetailChunkId) {
375
+ const openChunk = s.aiChatChunks.find((c) => c.id === s.chatDetailChunkId)
376
+ if (!openChunk) return
377
+ const lines = renderChunkDetailLines(openChunk, 80)
378
+ const pageSize = Math.max(4, Math.floor((s.isWideLayout ? s.wideBodyLines : s.narrowBodyLines) * 0.75))
379
+ setChatDetailScrollOffset(Math.max(0, lines.length - pageSize))
380
+ return
381
+ }
382
+ const last = s.aiChatChunks[s.aiChatChunks.length - 1]
383
+ if (last) setSelectedChatChunkId(last.id)
384
+ return
385
+ }
386
+ if (s.attrNavActive) {
387
+ const count = attrCountForSelectedSpan()
388
+ setSelectedAttrIndex(Math.max(0, count - 1))
389
+ return
390
+ }
201
391
  if (s.spanNavActive && s.selectedTrace) {
202
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
392
+ const visibleCount = getVisibleSelectedSpans().length
203
393
  setSelectedSpanIndex(visibleCount === 0 ? null : visibleCount - 1)
204
394
  } else {
205
395
  selectFilteredTraceAt(s.filteredTraces.length - 1)
206
396
  }
207
397
  }
208
398
 
209
- const moveTraceBy = (direction: -1 | 1) => {
210
- const s = $()
211
- const filtered = s.filteredTraces
212
- if (filtered.length === 0) return
213
- setSelectedTraceIndex((current) => {
214
- const currentTraceId = s.traceState.data[current]?.traceId
215
- const currentFilteredIdx = currentTraceId
216
- ? filtered.findIndex((t) => t.traceId === currentTraceId)
217
- : -1
218
- if (currentFilteredIdx < 0) {
219
- const fallbackTrace = filtered[0]
220
- if (!fallbackTrace) return current
221
- const fallbackIndex = s.traceState.data.findIndex((t) => t.traceId === fallbackTrace.traceId)
222
- return fallbackIndex >= 0 ? fallbackIndex : current
223
- }
224
- const nextFilteredIdx = Math.max(0, Math.min(currentFilteredIdx + direction, filtered.length - 1))
225
- const nextTrace = filtered[nextFilteredIdx]
226
- if (!nextTrace) return current
227
- const fullIndex = s.traceState.data.findIndex((t) => t.traceId === nextTrace.traceId)
228
- return fullIndex >= 0 ? fullIndex : current
229
- })
230
- }
231
-
232
- const moveServiceLogBy = (direction: -1 | 1) => {
233
- const s = $()
234
- setSelectedServiceLogIndex((current) => {
235
- if (s.serviceLogState.data.length === 0) return 0
236
- return Math.max(0, Math.min(current + direction, s.serviceLogState.data.length - 1))
237
- })
238
- }
239
-
240
399
  const cycleService = (direction: -1 | 1) => {
241
400
  const s = $()
242
401
  if (s.traceState.services.length === 0) return
@@ -251,6 +410,46 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
251
410
  if (message) s.flashNotice(message)
252
411
  }
253
412
 
413
+ const copySelectedAttrValue = () => {
414
+ const s = $()
415
+ const span = getSelectedVisibleSpan()
416
+ if (!span) return
417
+ const entries = Object.entries(span.tags)
418
+ const entry = entries[s.selectedAttrIndex] ?? entries[0]
419
+ if (!entry) {
420
+ s.flashNotice("No tag to copy")
421
+ return
422
+ }
423
+ const [key, value] = entry
424
+ void copyToClipboard(value)
425
+ .then(() => {
426
+ const preview = value.length > 40 ? `${value.slice(0, 39)}\u2026` : value
427
+ s.flashNotice(`Copied ${key}: ${preview}`)
428
+ })
429
+ .catch((error) => {
430
+ s.flashNotice(error instanceof Error ? error.message : String(error))
431
+ })
432
+ }
433
+
434
+ const copySelectedChatChunk = () => {
435
+ const s = $()
436
+ const chunkId = s.chatDetailChunkId ?? s.selectedChatChunkId
437
+ const chunk = s.aiChatChunks.find((c) => c.id === chunkId)
438
+ if (!chunk) {
439
+ s.flashNotice("No chunk selected")
440
+ return
441
+ }
442
+ const payload = chunk.body.length > 0 ? chunk.body : chunk.header
443
+ void copyToClipboard(payload)
444
+ .then(() => {
445
+ const label = chunk.toolName ?? chunk.kind
446
+ s.flashNotice(`Copied ${label} (${payload.length} chars)`)
447
+ })
448
+ .catch((error) => {
449
+ s.flashNotice(error instanceof Error ? error.message : String(error))
450
+ })
451
+ }
452
+
254
453
  const copySelectedIds = () => {
255
454
  const s = $()
256
455
  if (s.serviceLogNavActive) {
@@ -278,8 +477,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
278
477
  return
279
478
  }
280
479
 
281
- const visibleSpans = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds)
282
- const selectedSpan = s.selectedSpanIndex !== null ? visibleSpans[s.selectedSpanIndex] ?? null : null
480
+ const selectedSpan = getSelectedVisibleSpan()
283
481
  const lines = [
284
482
  `traceId=${s.selectedTrace.traceId}`,
285
483
  selectedSpan ? `spanId=${selectedSpan.spanId}` : null,
@@ -302,167 +500,199 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
302
500
 
303
501
  const pageBy = (direction: -1 | 1) => {
304
502
  const s = $()
503
+ if (s.chatNavActive) {
504
+ if (s.chatDetailChunkId) {
505
+ const openChunk = s.aiChatChunks.find((c) => c.id === s.chatDetailChunkId)
506
+ if (!openChunk) return
507
+ const pageSize = Math.max(1, Math.floor((s.isWideLayout ? s.wideBodyLines : s.narrowBodyLines) / 2))
508
+ const detailLines = renderChunkDetailLines(openChunk, 80)
509
+ const maxOffset = Math.max(0, detailLines.length - pageSize)
510
+ setChatDetailScrollOffset((current) => clamp(current + direction * pageSize, 0, maxOffset))
511
+ return
512
+ }
513
+ // Page-by-half in chunk units.
514
+ const pageSize = Math.max(1, Math.floor(s.aiChatChunks.length / 4))
515
+ const chunks = s.aiChatChunks
516
+ const currentIdx = s.selectedChatChunkId
517
+ ? chunks.findIndex((c) => c.id === s.selectedChatChunkId)
518
+ : 0
519
+ const nextIdx = Math.max(0, Math.min(currentIdx + direction * pageSize, chunks.length - 1))
520
+ const next = chunks[nextIdx]
521
+ if (next) setSelectedChatChunkId(next.id)
522
+ return
523
+ }
524
+ if (s.attrNavActive) {
525
+ const count = attrCountForSelectedSpan()
526
+ if (count === 0) return
527
+ // Attr page size: ~half the viewport in "blocks", not rows.
528
+ // Attributes are variable height so measuring in blocks keeps
529
+ // the jump feeling consistent regardless of value length.
530
+ const pageSize = Math.max(1, Math.floor((s.isWideLayout ? s.wideBodyLines : s.narrowBodyLines) / 4))
531
+ setSelectedAttrIndex((current) =>
532
+ Math.max(0, Math.min(current + direction * pageSize, count - 1)),
533
+ )
534
+ return
535
+ }
305
536
  if (s.serviceLogNavActive) {
306
537
  const serviceLogPageSize = Math.max(1, Math.floor((s.isWideLayout ? s.wideBodyLines : s.narrowBodyLines) * 0.5))
307
- setSelectedServiceLogIndex((current) => {
308
- if (s.serviceLogState.data.length === 0) return 0
309
- return Math.max(0, Math.min(current + direction * serviceLogPageSize, s.serviceLogState.data.length - 1))
310
- })
538
+ moveServiceLogBy(direction * serviceLogPageSize)
311
539
  } else if (s.spanNavActive) {
312
- if (!s.selectedTrace) return
313
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
314
- setSelectedSpanIndex((current) => {
315
- if (visibleCount === 0) return null
316
- const start = current ?? 0
317
- return Math.max(0, Math.min(start + direction * s.spanPageSize, visibleCount - 1))
318
- })
540
+ moveSpanBy(direction * s.spanPageSize)
319
541
  } else {
320
- const filtered = s.filteredTraces
321
- if (filtered.length === 0) return
322
- setSelectedTraceIndex((current) => {
323
- const currentTraceId = s.traceState.data[current]?.traceId
324
- const currentFilteredIdx = currentTraceId
325
- ? filtered.findIndex((t) => t.traceId === currentTraceId)
326
- : 0
327
- const nextIdx = Math.max(0, Math.min(currentFilteredIdx + direction * s.tracePageSize, filtered.length - 1))
328
- const nextTrace = filtered[nextIdx]
329
- if (!nextTrace) return current
330
- const fullIndex = s.traceState.data.findIndex((t) => t.traceId === nextTrace.traceId)
331
- return fullIndex >= 0 ? fullIndex : current
332
- })
542
+ moveTraceBy(direction * s.tracePageSize)
333
543
  }
334
544
  }
335
545
 
336
- useKeyboard((key) => {
546
+ const handlePickerMode = (key: KeyboardKey) => {
337
547
  const s = $()
548
+ if (s.pickerMode === "off") return false
338
549
 
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") {
550
+ const rows = filterFacets(s.attrFacets.data, s.pickerInput)
551
+ const clampedIndex = rows.length === 0 ? 0 : Math.max(0, Math.min(s.pickerIndex, rows.length - 1))
552
+ const move = (delta: number) => {
553
+ if (rows.length === 0) return
554
+ setPickerIndex(Math.max(0, Math.min(clampedIndex + delta, rows.length - 1)))
555
+ }
556
+
557
+ if (key.name === "escape") {
558
+ closePicker()
559
+ return true
560
+ }
561
+ if (key.ctrl && key.name === "c") {
562
+ if (s.pickerInput.length > 0) {
563
+ resetPicker()
564
+ } else {
348
565
  setPickerMode("off")
349
- setPickerInput("")
350
566
  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
567
  }
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
568
+ return true
569
+ }
570
+ if (key.name === "up" || (key.ctrl && key.name === "p")) {
571
+ move(-1)
572
+ return true
573
+ }
574
+ if (key.name === "down" || (key.ctrl && key.name === "n")) {
575
+ move(1)
576
+ return true
577
+ }
578
+ if (key.name === "pageup") {
579
+ move(-10)
580
+ return true
581
+ }
582
+ if (key.name === "pagedown") {
583
+ move(10)
584
+ return true
585
+ }
586
+ if (key.name === "return" || key.name === "enter") {
587
+ const row = rows[clampedIndex]
588
+ if (!row) return true
589
+ if (s.pickerMode === "keys") {
590
+ setActiveAttrKey(row.value)
591
+ setPickerMode("values")
592
+ resetPicker()
593
+ } else {
594
+ setActiveAttrValue(row.value)
595
+ closePicker()
596
+ s.flashNotice(`Filter: ${s.activeAttrKey}=${row.value}`)
386
597
  }
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
598
+ return true
599
+ }
600
+ if (key.name === "backspace") {
601
+ if (s.pickerInput.length > 0) {
602
+ setPickerInput(s.pickerInput.slice(0, -1))
603
+ setPickerIndex(0)
604
+ return true
401
605
  }
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)
606
+ if (s.pickerMode === "values") {
607
+ setPickerMode("keys")
608
+ setActiveAttrKey(null)
410
609
  setPickerIndex(0)
411
- return
610
+ return true
412
611
  }
413
- return
612
+ return true
414
613
  }
415
614
 
416
- // Filter mode: capture text input
417
- if (s.filterMode) {
418
- if (key.name === "escape") {
419
- setFilterMode(false)
420
- setFilterText("")
421
- return
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
- }
432
- if (key.name === "return" || key.name === "enter") {
433
- setFilterMode(false)
434
- return
435
- }
436
- if (key.name === "backspace") {
437
- setFilterText((current) => current.slice(0, -1))
438
- return
439
- }
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)
445
- return
446
- }
447
- return
615
+ const printable = extractPrintable(key)
616
+ if (printable) {
617
+ setPickerInput((current) => current + printable)
618
+ setPickerIndex(0)
448
619
  }
449
- const plainG = key.name === "g" && !key.ctrl && !key.meta && !key.option && !key.shift
450
- const shiftedG = key.name === "g" && key.shift
451
- const questionMark = key.name === "?" || (key.name === "/" && key.shift)
620
+ return true
621
+ }
452
622
 
453
- if (questionMark) {
454
- clearPendingG()
455
- setShowHelp((current) => !current)
456
- return
623
+ const handleTraceFilterMode = (key: KeyboardKey) => {
624
+ const s = $()
625
+ if (!s.filterMode) return false
626
+
627
+ if (key.name === "escape") {
628
+ setFilterMode(false)
629
+ setFilterText("")
630
+ return true
631
+ }
632
+ if (key.ctrl && key.name === "c") {
633
+ if (s.filterText.length > 0) setFilterText("")
634
+ else setFilterMode(false)
635
+ return true
636
+ }
637
+ if (key.name === "return" || key.name === "enter") {
638
+ setFilterMode(false)
639
+ return true
640
+ }
641
+ if (key.name === "backspace") {
642
+ setFilterText((current) => current.slice(0, -1))
643
+ return true
457
644
  }
458
645
 
459
- if (s.showHelp) {
460
- if (key.name === "return" || key.name === "enter" || key.name === "escape") {
461
- setShowHelp(false)
462
- }
463
- return
646
+ const printable = extractPrintable(key)
647
+ if (printable) setFilterText((current) => current + printable)
648
+ return true
649
+ }
650
+
651
+ const handleWaterfallFilterMode = (key: KeyboardKey) => {
652
+ const s = $()
653
+ if (!s.waterfallFilterMode) return false
654
+
655
+ if (key.name === "escape") {
656
+ setWaterfallFilterMode(false)
657
+ setWaterfallFilterText("")
658
+ return true
464
659
  }
660
+ if (key.ctrl && key.name === "c") {
661
+ if (s.waterfallFilterText.length > 0) setWaterfallFilterText("")
662
+ else setWaterfallFilterMode(false)
663
+ return true
664
+ }
665
+ if (key.name === "return" || key.name === "enter") {
666
+ setWaterfallFilterMode(false)
667
+ return true
668
+ }
669
+ if (key.name === "backspace") {
670
+ setWaterfallFilterText((current) => current.slice(0, -1))
671
+ return true
672
+ }
673
+
674
+ const printable = extractPrintable(key)
675
+ if (printable) setWaterfallFilterText((current) => current + printable)
676
+ return true
677
+ }
678
+
679
+ const handleQuestionMarkKey = (key: KeyboardKey) => {
680
+ const questionMark = key.name === "?" || (key.name === "/" && key.shift)
681
+ if (!questionMark) return false
682
+ clearPendingG()
683
+ setShowHelp((current) => !current)
684
+ return true
685
+ }
686
+
687
+ const handleHelpModalKey = (key: KeyboardKey) => {
688
+ if (!$().showHelp) return false
689
+ if (key.name === "return" || key.name === "enter" || key.name === "escape") setShowHelp(false)
690
+ return true
691
+ }
465
692
 
693
+ const handleJumpKeys = (key: KeyboardKey) => {
694
+ const plainG = key.name === "g" && !key.ctrl && !key.meta && !key.option && !key.shift
695
+ const shiftedG = key.name === "g" && key.shift
466
696
  if (plainG && !key.repeated) {
467
697
  if (pendingGRef.current) {
468
698
  clearPendingG()
@@ -470,183 +700,206 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
470
700
  } else {
471
701
  armPendingG()
472
702
  }
473
- return
703
+ return true
474
704
  }
475
-
476
705
  if (shiftedG) {
477
706
  clearPendingG()
478
707
  jumpToEnd()
479
- return
708
+ return true
480
709
  }
710
+ return false
711
+ }
481
712
 
482
- clearPendingG()
483
-
713
+ const handleSystemKeys = (key: KeyboardKey) => {
714
+ const s = $()
484
715
  if (key.name === "q" || (key.ctrl && key.name === "c")) {
485
- if (quittingRef.current) return
716
+ if (quittingRef.current) return true
486
717
  quittingRef.current = true
487
718
  renderer.destroy()
488
- return
719
+ return true
489
720
  }
490
721
  if (key.name === "home") {
491
- if (s.serviceLogNavActive) {
492
- setSelectedServiceLogIndex(0)
493
- } else {
494
- jumpToStart()
495
- }
496
- return
722
+ if (s.serviceLogNavActive) setSelectedServiceLogIndex(0)
723
+ else jumpToStart()
724
+ return true
497
725
  }
498
726
  if (key.name === "end") {
499
- if (s.serviceLogNavActive) {
500
- setSelectedServiceLogIndex(s.serviceLogState.data.length === 0 ? 0 : s.serviceLogState.data.length - 1)
501
- } else {
502
- jumpToEnd()
503
- }
504
- return
727
+ if (s.serviceLogNavActive) setSelectedServiceLogIndex(s.serviceLogState.data.length === 0 ? 0 : s.serviceLogState.data.length - 1)
728
+ else jumpToEnd()
729
+ return true
505
730
  }
506
731
  if (key.name === "pagedown" || (key.ctrl && key.name === "d")) {
507
732
  pageBy(1)
508
- return
733
+ return true
509
734
  }
510
735
  if (key.name === "pageup" || (key.ctrl && key.name === "u")) {
511
736
  pageBy(-1)
512
- return
737
+ return true
513
738
  }
514
739
  if (key.ctrl && key.name === "p") {
515
740
  moveTraceBy(-1)
516
- return
741
+ return true
517
742
  }
518
743
  if (key.ctrl && key.name === "n") {
519
744
  moveTraceBy(1)
520
- return
745
+ return true
521
746
  }
522
- if (key.name === "escape") {
523
- if (s.showHelp) {
524
- setShowHelp(false)
525
- return
526
- }
527
- if (s.detailView === "span-detail" || s.detailView === "service-logs") {
528
- setDetailView("waterfall")
529
- return
530
- }
531
- if (s.spanNavActive) {
532
- setSelectedSpanIndex(null)
533
- return
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
747
+ return false
748
+ }
749
+
750
+ const handleEscapeKey = (key: KeyboardKey) => {
751
+ if (key.name !== "escape") return false
752
+ const s = $()
753
+ if (s.chatDetailChunkId) {
754
+ setChatDetailChunkId(null)
755
+ setChatDetailScrollOffset(0)
756
+ return true
757
+ }
758
+ if (s.waterfallFilterText.length > 0) {
759
+ setWaterfallFilterText("")
760
+ return true
761
+ }
762
+ if (s.detailView === "span-detail" || s.detailView === "service-logs") {
763
+ setDetailView("waterfall")
764
+ return true
765
+ }
766
+ if (s.spanNavActive) {
767
+ setSelectedSpanIndex(null)
768
+ return true
769
+ }
770
+ if (s.activeAttrKey || s.activeAttrValue) {
771
+ setActiveAttrKey(null)
772
+ setActiveAttrValue(null)
773
+ s.flashNotice("Cleared attribute filter")
774
+ return true
775
+ }
776
+ return true
777
+ }
778
+
779
+ const handleEnterKey = (key: KeyboardKey) => {
780
+ if (key.name !== "return" && key.name !== "enter") return false
781
+ const s = $()
782
+ if (s.chatNavActive) {
783
+ const chunk = s.aiChatChunks.find((c) => c.id === s.selectedChatChunkId)
784
+ if (chunk) {
785
+ setChatDetailChunkId(chunk.id)
786
+ setChatDetailScrollOffset(0)
543
787
  }
544
- return
788
+ return true
545
789
  }
546
- if (key.name === "return" || key.name === "enter") {
547
- if (s.detailView === "service-logs") {
548
- const selectedLog = s.serviceLogState.data[s.selectedServiceLogIndex]
549
- if (selectedLog?.traceId) {
550
- const traceIndex = s.traceState.data.findIndex((trace) => trace.traceId === selectedLog.traceId)
551
- if (traceIndex >= 0) {
552
- setSelectedTraceIndex(traceIndex)
553
- setDetailView("waterfall")
554
- s.flashNotice(`Jumped to trace ${selectedLog.traceId.slice(-8)}`)
555
- }
790
+ if (s.detailView === "service-logs") {
791
+ const selectedLog = s.serviceLogState.data[s.selectedServiceLogIndex]
792
+ if (selectedLog?.traceId) {
793
+ const traceIndex = findTraceIndexById(s.traceState.data, selectedLog.traceId)
794
+ if (traceIndex >= 0) {
795
+ setSelectedTraceIndex(traceIndex)
796
+ setDetailView("waterfall")
797
+ s.flashNotice(`Jumped to trace ${selectedLog.traceId.slice(-8)}`)
556
798
  }
557
- return
558
- }
559
- if (s.spanNavActive && s.detailView === "waterfall") {
560
- setDetailView("span-detail")
561
- return
562
- }
563
- if (!s.spanNavActive && s.selectedTrace && s.selectedTrace.spans.length > 0) {
564
- setSelectedSpanIndex(0)
565
- return
566
799
  }
567
- return
800
+ return true
801
+ }
802
+ if (s.spanNavActive && s.detailView === "waterfall") {
803
+ setDetailView("span-detail")
804
+ return true
568
805
  }
806
+ if (!s.spanNavActive && s.selectedTrace && s.selectedTrace.spans.length > 0) {
807
+ setSelectedSpanIndex(0)
808
+ return true
809
+ }
810
+ return true
811
+ }
812
+
813
+ const handleToolbarKeys = (key: KeyboardKey) => {
814
+ const s = $()
569
815
  if (key.name === "r") {
570
816
  refresh("Refreshing traces...")
571
- return
817
+ return true
572
818
  }
573
819
  if (key.name === "a") {
574
820
  setAutoRefresh(!s.autoRefresh)
575
821
  s.flashNotice(s.autoRefresh ? "Auto-refresh paused" : "Auto-refresh resumed")
576
- return
822
+ return true
577
823
  }
578
824
  if (key.name === "s") {
579
825
  const modes: readonly TraceSortMode[] = ["recent", "slowest", "errors"]
580
826
  const nextMode = modes[(modes.indexOf(s.traceSort) + 1) % modes.length] ?? "recent"
581
827
  setTraceSort(nextMode)
582
828
  s.flashNotice(`Sort: ${nextMode}`)
583
- return
829
+ return true
584
830
  }
585
831
  if (key.name === "t") {
586
832
  const nextTheme = cycleThemeName(s.selectedTheme)
587
833
  setSelectedTheme(nextTheme)
588
834
  s.flashNotice(`Theme: ${themeLabel(nextTheme)}`)
589
- return
835
+ return true
836
+ }
837
+ if ((key.name === "n" || key.name === "N") && !key.ctrl && !key.meta) {
838
+ const inWaterfall = s.detailView === "span-detail" || s.selectedSpanIndex !== null
839
+ if (inWaterfall && s.waterfallFilterText.length > 0 && s.selectedTrace) {
840
+ const visibleSpans = getVisibleSelectedSpans()
841
+ const matchingIds = computeMatchingSpanIds(visibleSpans, s.waterfallFilterText)
842
+ if (matchingIds && matchingIds.size > 0) {
843
+ const direction = key.name === "N" ? -1 : 1
844
+ const next = findAdjacentMatch(visibleSpans, matchingIds, s.selectedSpanIndex, direction)
845
+ if (next !== null) setSelectedSpanIndex(next)
846
+ else s.flashNotice("No matches")
847
+ } else {
848
+ s.flashNotice("No matches")
849
+ }
850
+ return true
851
+ }
590
852
  }
591
853
  if (key.name === "/" && !key.shift) {
592
- setFilterMode(true)
593
- return
854
+ const inWaterfall = s.detailView === "span-detail" || s.selectedSpanIndex !== null
855
+ if (inWaterfall) setWaterfallFilterMode(true)
856
+ else setFilterMode(true)
857
+ return true
594
858
  }
595
859
  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
860
  setPickerMode("keys")
599
- setPickerInput("")
600
- setPickerIndex(0)
861
+ resetPicker()
601
862
  setActiveAttrKey(null)
602
- return
863
+ return true
603
864
  }
604
865
  if (key.name === "tab") {
605
866
  toggleServiceLogsView()
606
- return
867
+ return true
607
868
  }
608
869
  if (key.name === "[") {
609
870
  cycleService(-1)
610
- return
871
+ return true
611
872
  }
612
873
  if (key.name === "]") {
613
874
  cycleService(1)
614
- return
875
+ return true
615
876
  }
877
+ return false
878
+ }
879
+
880
+ const handleMovementKeys = (key: KeyboardKey) => {
881
+ const s = $()
616
882
  if (key.name === "up" || key.name === "k") {
617
- if (s.serviceLogNavActive) {
618
- moveServiceLogBy(-1)
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
- }
630
- } else {
631
- moveTraceBy(-1)
883
+ if (s.chatNavActive) {
884
+ moveChatChunkBy(-1)
885
+ return true
632
886
  }
633
- return
887
+ if (s.attrNavActive) { moveAttrBy(-1); return true }
888
+ if (s.serviceLogNavActive) { moveServiceLogBy(-1); return true }
889
+ if (s.spanNavActive) { moveSpanBy(-1); return true }
890
+ moveTraceBy(-1)
891
+ return true
634
892
  }
635
893
  if (key.name === "down" || key.name === "j") {
636
- if (s.serviceLogNavActive) {
637
- moveServiceLogBy(1)
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
- }
646
- } else {
647
- moveTraceBy(1)
894
+ if (s.chatNavActive) {
895
+ moveChatChunkBy(1)
896
+ return true
648
897
  }
649
- return
898
+ if (s.attrNavActive) { moveAttrBy(1); return true }
899
+ if (s.serviceLogNavActive) { moveServiceLogBy(1); return true }
900
+ if (s.spanNavActive) { moveSpanBy(1); return true }
901
+ moveTraceBy(1)
902
+ return true
650
903
  }
651
904
  if (key.name === "left" || key.name === "h") {
652
905
  if (s.spanNavActive && s.selectedTrace) {
@@ -658,13 +911,11 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
658
911
  selectedIndex: s.selectedSpanIndex,
659
912
  direction: "left",
660
913
  })
661
- if (result.selectedIndex !== s.selectedSpanIndex) {
662
- setSelectedSpanIndex(result.selectedIndex)
663
- }
914
+ if (result.selectedIndex !== s.selectedSpanIndex) setSelectedSpanIndex(result.selectedIndex)
664
915
  return result.collapsed
665
916
  })
666
917
  }
667
- return
918
+ return true
668
919
  }
669
920
  if (key.name === "right" || key.name === "l") {
670
921
  if (s.spanNavActive && s.selectedTrace) {
@@ -676,16 +927,19 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
676
927
  selectedIndex: s.selectedSpanIndex,
677
928
  direction: "right",
678
929
  })
679
- if (result.selectedIndex !== s.selectedSpanIndex) {
680
- setSelectedSpanIndex(result.selectedIndex)
681
- }
930
+ if (result.selectedIndex !== s.selectedSpanIndex) setSelectedSpanIndex(result.selectedIndex)
682
931
  return result.collapsed
683
932
  })
684
933
  } else if (!s.spanNavActive && !s.serviceLogNavActive) {
685
934
  toggleServiceLogsView()
686
935
  }
687
- return
936
+ return true
688
937
  }
938
+ return false
939
+ }
940
+
941
+ const handleOpenCopyKeys = (key: KeyboardKey) => {
942
+ const s = $()
689
943
  if (key.name === "o" && !key.shift) {
690
944
  if (s.serviceLogNavActive) {
691
945
  const selectedLog = s.serviceLogState.data[s.selectedServiceLogIndex]
@@ -693,21 +947,23 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
693
947
  void Bun.spawn({ cmd: ["open", traceUiUrl(selectedLog.traceId)], stdout: "ignore", stderr: "ignore" })
694
948
  s.flashNotice(`Opened trace ${selectedLog.traceId.slice(-8)}`)
695
949
  }
696
- return
950
+ return true
697
951
  }
698
- if (!s.selectedTrace) return
952
+ if (!s.selectedTrace) return true
699
953
  void Bun.spawn({ cmd: ["open", traceUiUrl(s.selectedTrace.traceId)], stdout: "ignore", stderr: "ignore" })
700
954
  s.flashNotice(`Opened trace ${s.selectedTrace.traceId.slice(-8)}`)
701
- return
955
+ return true
702
956
  }
703
957
  if (key.name === "o" && key.shift) {
704
958
  void Bun.spawn({ cmd: ["open", webUiUrl()], stdout: "ignore", stderr: "ignore" })
705
959
  s.flashNotice("Opened web UI")
706
- return
960
+ return true
707
961
  }
708
962
  if (key.name === "y" || key.name === "Y") {
709
- copySelectedIds()
710
- return
963
+ if (s.chatNavActive) copySelectedChatChunk()
964
+ else if (s.attrNavActive) copySelectedAttrValue()
965
+ else copySelectedIds()
966
+ return true
711
967
  }
712
968
  if (key.name === "c" || key.name === "C") {
713
969
  void copyToClipboard(otelServerInstructions())
@@ -717,7 +973,27 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
717
973
  .catch((error) => {
718
974
  s.flashNotice(error instanceof Error ? error.message : String(error))
719
975
  })
976
+ return true
720
977
  }
978
+ return false
979
+ }
980
+
981
+ useKeyboard((key: KeyboardKey) => {
982
+ if (handlePickerMode(key)) return
983
+ if (handleTraceFilterMode(key)) return
984
+ if (handleWaterfallFilterMode(key)) return
985
+ if (handleQuestionMarkKey(key)) return
986
+ if (handleHelpModalKey(key)) return
987
+ if (handleJumpKeys(key)) return
988
+
989
+ clearPendingG()
990
+
991
+ if (handleSystemKeys(key)) return
992
+ if (handleEscapeKey(key)) return
993
+ if (handleEnterKey(key)) return
994
+ if (handleToolbarKeys(key)) return
995
+ if (handleMovementKeys(key)) return
996
+ handleOpenCopyKeys(key)
721
997
  })
722
998
 
723
999
  return { spanNavActive }