@kitlangton/motel 0.2.0 → 0.2.4

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 (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +7 -5
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +213 -6
  5. package/src/daemon.ts +174 -38
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +114 -128
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +68 -0
  15. package/src/services/TelemetryStore.ts +262 -119
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +244 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +308 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +11 -28
  32. package/src/ui/Waterfall.tsx +43 -148
  33. package/src/ui/aiChatModel.test.ts +391 -0
  34. package/src/ui/aiChatModel.ts +773 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +288 -124
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +174 -40
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +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,
@@ -33,8 +38,8 @@ import {
33
38
  import { filterFacets } from "./AttrFilterModal.tsx"
34
39
  import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
35
40
  import { cycleThemeName, themeLabel } from "./theme.ts"
36
- import { getVisibleSpans } from "./Waterfall.tsx"
37
41
  import { computeMatchingSpanIds, findAdjacentMatch } from "./waterfallFilter.ts"
42
+ import { getVisibleSpans } from "./waterfallModel.ts"
38
43
  import { resolveCollapseStep } from "./waterfallNav.ts"
39
44
 
40
45
  /**
@@ -49,12 +54,17 @@ import { resolveCollapseStep } from "./waterfallNav.ts"
49
54
  * Returns `null` for non-printable events (function keys, modifiers, etc.)
50
55
  * so callers can skip them.
51
56
  */
52
- const extractPrintable = (key: {
57
+ interface KeyboardKey {
53
58
  readonly name: string
54
59
  readonly sequence?: string
55
60
  readonly ctrl: boolean
56
61
  readonly meta: boolean
57
- }): string | null => {
62
+ readonly option?: boolean
63
+ readonly shift?: boolean
64
+ readonly repeated?: boolean
65
+ }
66
+
67
+ const extractPrintable = (key: KeyboardKey): string | null => {
58
68
  if (key.ctrl || key.meta) return null
59
69
  // Space arrives as `key.name === "space"` with a 1-char sequence. We
60
70
  // handle it explicitly because the generic "length > 1" branch below
@@ -71,6 +81,7 @@ const extractPrintable = (key: {
71
81
  interface KeyboardNavParams {
72
82
  selectedTrace: TraceItem | null
73
83
  filteredTraces: readonly TraceSummaryItem[]
84
+ aiChatChunks: readonly Chunk[]
74
85
  isWideLayout: boolean
75
86
  wideBodyLines: number
76
87
  narrowBodyLines: number
@@ -79,6 +90,11 @@ interface KeyboardNavParams {
79
90
  flashNotice: (message: string) => void
80
91
  }
81
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
+
82
98
  export const useKeyboardNav = (params: KeyboardNavParams) => {
83
99
  const {
84
100
  selectedTrace,
@@ -114,6 +130,10 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
114
130
  const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
115
131
  const [waterfallFilterMode, setWaterfallFilterMode] = useAtom(waterfallFilterModeAtom)
116
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)
117
137
 
118
138
  const pendingGRef = useRef(false)
119
139
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -121,6 +141,19 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
121
141
 
122
142
  const spanNavActive = detailView !== "service-logs" && selectedSpanIndex !== null
123
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)
124
157
 
125
158
  // Bracketed paste: when the terminal has bracketed paste enabled, opentui
126
159
  // surfaces the full pasted text as a single "paste" event on keyInput.
@@ -160,12 +193,46 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
160
193
  }
161
194
  }, [renderer, setFilterText, setPickerInput, setPickerIndex])
162
195
 
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 })
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())
164
231
  // Keep the keyboard handler's state mirror in sync before the next paint.
165
232
  // OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
166
233
  // rapid repeated keypresses can otherwise observe stale selection state.
167
234
  useLayoutEffect(() => {
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 }
235
+ stateRef.current = buildStateSnapshot()
169
236
  })
170
237
 
171
238
  const clearPendingG = () => {
@@ -187,18 +254,114 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
187
254
 
188
255
  const $ = () => stateRef.current
189
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
+
190
278
  const selectFilteredTraceAt = (filteredIdx: number) => {
191
279
  const s = $()
192
280
  const trace = s.filteredTraces[filteredIdx]
193
281
  if (!trace) return
194
- const fullIndex = s.traceState.data.findIndex((t) => t.traceId === trace.traceId)
282
+ const fullIndex = findTraceIndexById(s.traceState.data, trace.traceId)
195
283
  if (fullIndex >= 0) setSelectedTraceIndex(fullIndex)
196
284
  }
197
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
+
198
348
  const jumpToStart = () => {
199
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
+ }
200
363
  if (s.spanNavActive && s.selectedTrace) {
201
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
364
+ const visibleCount = getVisibleSelectedSpans().length
202
365
  setSelectedSpanIndex(visibleCount === 0 ? null : 0)
203
366
  } else {
204
367
  selectFilteredTraceAt(0)
@@ -207,45 +370,32 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
207
370
 
208
371
  const jumpToEnd = () => {
209
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
+ }
210
391
  if (s.spanNavActive && s.selectedTrace) {
211
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
392
+ const visibleCount = getVisibleSelectedSpans().length
212
393
  setSelectedSpanIndex(visibleCount === 0 ? null : visibleCount - 1)
213
394
  } else {
214
395
  selectFilteredTraceAt(s.filteredTraces.length - 1)
215
396
  }
216
397
  }
217
398
 
218
- const moveTraceBy = (direction: -1 | 1) => {
219
- const s = $()
220
- const filtered = s.filteredTraces
221
- if (filtered.length === 0) return
222
- setSelectedTraceIndex((current) => {
223
- const currentTraceId = s.traceState.data[current]?.traceId
224
- const currentFilteredIdx = currentTraceId
225
- ? filtered.findIndex((t) => t.traceId === currentTraceId)
226
- : -1
227
- if (currentFilteredIdx < 0) {
228
- const fallbackTrace = filtered[0]
229
- if (!fallbackTrace) return current
230
- const fallbackIndex = s.traceState.data.findIndex((t) => t.traceId === fallbackTrace.traceId)
231
- return fallbackIndex >= 0 ? fallbackIndex : current
232
- }
233
- const nextFilteredIdx = Math.max(0, Math.min(currentFilteredIdx + direction, filtered.length - 1))
234
- const nextTrace = filtered[nextFilteredIdx]
235
- if (!nextTrace) return current
236
- const fullIndex = s.traceState.data.findIndex((t) => t.traceId === nextTrace.traceId)
237
- return fullIndex >= 0 ? fullIndex : current
238
- })
239
- }
240
-
241
- const moveServiceLogBy = (direction: -1 | 1) => {
242
- const s = $()
243
- setSelectedServiceLogIndex((current) => {
244
- if (s.serviceLogState.data.length === 0) return 0
245
- return Math.max(0, Math.min(current + direction, s.serviceLogState.data.length - 1))
246
- })
247
- }
248
-
249
399
  const cycleService = (direction: -1 | 1) => {
250
400
  const s = $()
251
401
  if (s.traceState.services.length === 0) return
@@ -260,6 +410,46 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
260
410
  if (message) s.flashNotice(message)
261
411
  }
262
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
+
263
453
  const copySelectedIds = () => {
264
454
  const s = $()
265
455
  if (s.serviceLogNavActive) {
@@ -287,8 +477,7 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
287
477
  return
288
478
  }
289
479
 
290
- const visibleSpans = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds)
291
- const selectedSpan = s.selectedSpanIndex !== null ? visibleSpans[s.selectedSpanIndex] ?? null : null
480
+ const selectedSpan = getSelectedVisibleSpan()
292
481
  const lines = [
293
482
  `traceId=${s.selectedTrace.traceId}`,
294
483
  selectedSpan ? `spanId=${selectedSpan.spanId}` : null,
@@ -311,204 +500,199 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
311
500
 
312
501
  const pageBy = (direction: -1 | 1) => {
313
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
+ }
314
536
  if (s.serviceLogNavActive) {
315
537
  const serviceLogPageSize = Math.max(1, Math.floor((s.isWideLayout ? s.wideBodyLines : s.narrowBodyLines) * 0.5))
316
- setSelectedServiceLogIndex((current) => {
317
- if (s.serviceLogState.data.length === 0) return 0
318
- return Math.max(0, Math.min(current + direction * serviceLogPageSize, s.serviceLogState.data.length - 1))
319
- })
538
+ moveServiceLogBy(direction * serviceLogPageSize)
320
539
  } else if (s.spanNavActive) {
321
- if (!s.selectedTrace) return
322
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
323
- setSelectedSpanIndex((current) => {
324
- if (visibleCount === 0) return null
325
- const start = current ?? 0
326
- return Math.max(0, Math.min(start + direction * s.spanPageSize, visibleCount - 1))
327
- })
540
+ moveSpanBy(direction * s.spanPageSize)
328
541
  } else {
329
- const filtered = s.filteredTraces
330
- if (filtered.length === 0) return
331
- setSelectedTraceIndex((current) => {
332
- const currentTraceId = s.traceState.data[current]?.traceId
333
- const currentFilteredIdx = currentTraceId
334
- ? filtered.findIndex((t) => t.traceId === currentTraceId)
335
- : 0
336
- const nextIdx = Math.max(0, Math.min(currentFilteredIdx + direction * s.tracePageSize, filtered.length - 1))
337
- const nextTrace = filtered[nextIdx]
338
- if (!nextTrace) return current
339
- const fullIndex = s.traceState.data.findIndex((t) => t.traceId === nextTrace.traceId)
340
- return fullIndex >= 0 ? fullIndex : current
341
- })
542
+ moveTraceBy(direction * s.tracePageSize)
342
543
  }
343
544
  }
344
545
 
345
- useKeyboard((key) => {
546
+ const handlePickerMode = (key: KeyboardKey) => {
346
547
  const s = $()
548
+ if (s.pickerMode === "off") return false
347
549
 
348
- // Attribute picker modal owns the keyboard while open.
349
- if (s.pickerMode !== "off") {
350
- const rows = filterFacets(s.attrFacets.data, s.pickerInput)
351
- const clampedIndex = rows.length === 0 ? 0 : Math.max(0, Math.min(s.pickerIndex, rows.length - 1))
352
- const move = (delta: number) => {
353
- if (rows.length === 0) return
354
- setPickerIndex(Math.max(0, Math.min(clampedIndex + delta, rows.length - 1)))
355
- }
356
- 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 {
357
565
  setPickerMode("off")
358
- setPickerInput("")
359
566
  setPickerIndex(0)
360
- return
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
567
  }
373
- if (key.name === "up" || (key.ctrl && key.name === "p")) { move(-1); return }
374
- if (key.name === "down" || (key.ctrl && key.name === "n")) { move(1); return }
375
- if (key.name === "pageup") { move(-10); return }
376
- if (key.name === "pagedown") { move(10); return }
377
- if (key.name === "return" || key.name === "enter") {
378
- const row = rows[clampedIndex]
379
- if (!row) return
380
- if (s.pickerMode === "keys") {
381
- // Drill from keys → values for this key.
382
- setActiveAttrKey(row.value)
383
- setPickerMode("values")
384
- setPickerInput("")
385
- setPickerIndex(0)
386
- } else {
387
- // Apply: activeAttrKey is already set, now pin the value.
388
- setActiveAttrValue(row.value)
389
- setPickerMode("off")
390
- setPickerInput("")
391
- setPickerIndex(0)
392
- s.flashNotice(`Filter: ${s.activeAttrKey}=${row.value}`)
393
- }
394
- 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}`)
395
597
  }
396
- if (key.name === "backspace") {
397
- if (s.pickerInput.length > 0) {
398
- setPickerInput(s.pickerInput.slice(0, -1))
399
- setPickerIndex(0)
400
- return
401
- }
402
- // At empty input in values mode, backspace walks back to keys.
403
- if (s.pickerMode === "values") {
404
- setPickerMode("keys")
405
- setActiveAttrKey(null)
406
- setPickerIndex(0)
407
- return
408
- }
409
- 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
410
605
  }
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)
606
+ if (s.pickerMode === "values") {
607
+ setPickerMode("keys")
608
+ setActiveAttrKey(null)
419
609
  setPickerIndex(0)
420
- return
610
+ return true
421
611
  }
422
- return
612
+ return true
423
613
  }
424
614
 
425
- // Filter mode: capture text input
426
- if (s.filterMode) {
427
- if (key.name === "escape") {
428
- setFilterMode(false)
429
- setFilterText("")
430
- return
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
- }
441
- if (key.name === "return" || key.name === "enter") {
442
- setFilterMode(false)
443
- return
444
- }
445
- if (key.name === "backspace") {
446
- setFilterText((current) => current.slice(0, -1))
447
- return
448
- }
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
615
+ const printable = extractPrintable(key)
616
+ if (printable) {
617
+ setPickerInput((current) => current + printable)
618
+ setPickerIndex(0)
457
619
  }
620
+ return true
621
+ }
458
622
 
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)
491
- return
492
- }
493
- return
494
- }
495
- const plainG = key.name === "g" && !key.ctrl && !key.meta && !key.option && !key.shift
496
- const shiftedG = key.name === "g" && key.shift
497
- const questionMark = key.name === "?" || (key.name === "/" && key.shift)
623
+ const handleTraceFilterMode = (key: KeyboardKey) => {
624
+ const s = $()
625
+ if (!s.filterMode) return false
498
626
 
499
- if (questionMark) {
500
- clearPendingG()
501
- setShowHelp((current) => !current)
502
- return
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
503
644
  }
504
645
 
505
- if (s.showHelp) {
506
- if (key.name === "return" || key.name === "enter" || key.name === "escape") {
507
- setShowHelp(false)
508
- }
509
- 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
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
510
672
  }
511
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
+ }
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
512
696
  if (plainG && !key.repeated) {
513
697
  if (pendingGRef.current) {
514
698
  clearPendingG()
@@ -516,141 +700,144 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
516
700
  } else {
517
701
  armPendingG()
518
702
  }
519
- return
703
+ return true
520
704
  }
521
-
522
705
  if (shiftedG) {
523
706
  clearPendingG()
524
707
  jumpToEnd()
525
- return
708
+ return true
526
709
  }
710
+ return false
711
+ }
527
712
 
528
- clearPendingG()
529
-
713
+ const handleSystemKeys = (key: KeyboardKey) => {
714
+ const s = $()
530
715
  if (key.name === "q" || (key.ctrl && key.name === "c")) {
531
- if (quittingRef.current) return
716
+ if (quittingRef.current) return true
532
717
  quittingRef.current = true
533
718
  renderer.destroy()
534
- return
719
+ return true
535
720
  }
536
721
  if (key.name === "home") {
537
- if (s.serviceLogNavActive) {
538
- setSelectedServiceLogIndex(0)
539
- } else {
540
- jumpToStart()
541
- }
542
- return
722
+ if (s.serviceLogNavActive) setSelectedServiceLogIndex(0)
723
+ else jumpToStart()
724
+ return true
543
725
  }
544
726
  if (key.name === "end") {
545
- if (s.serviceLogNavActive) {
546
- setSelectedServiceLogIndex(s.serviceLogState.data.length === 0 ? 0 : s.serviceLogState.data.length - 1)
547
- } else {
548
- jumpToEnd()
549
- }
550
- return
727
+ if (s.serviceLogNavActive) setSelectedServiceLogIndex(s.serviceLogState.data.length === 0 ? 0 : s.serviceLogState.data.length - 1)
728
+ else jumpToEnd()
729
+ return true
551
730
  }
552
731
  if (key.name === "pagedown" || (key.ctrl && key.name === "d")) {
553
732
  pageBy(1)
554
- return
733
+ return true
555
734
  }
556
735
  if (key.name === "pageup" || (key.ctrl && key.name === "u")) {
557
736
  pageBy(-1)
558
- return
737
+ return true
559
738
  }
560
739
  if (key.ctrl && key.name === "p") {
561
740
  moveTraceBy(-1)
562
- return
741
+ return true
563
742
  }
564
743
  if (key.ctrl && key.name === "n") {
565
744
  moveTraceBy(1)
566
- return
745
+ return true
567
746
  }
568
- if (key.name === "escape") {
569
- if (s.showHelp) {
570
- setShowHelp(false)
571
- return
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
- }
581
- if (s.detailView === "span-detail" || s.detailView === "service-logs") {
582
- setDetailView("waterfall")
583
- return
584
- }
585
- if (s.spanNavActive) {
586
- setSelectedSpanIndex(null)
587
- return
588
- }
589
- // At the trace list, `esc` clears any applied attribute filter so
590
- // there's a clean way back to the unfiltered list without hunting
591
- // for the picker key.
592
- if (s.activeAttrKey || s.activeAttrValue) {
593
- setActiveAttrKey(null)
594
- setActiveAttrValue(null)
595
- s.flashNotice("Cleared attribute filter")
596
- 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)
597
787
  }
598
- return
788
+ return true
599
789
  }
600
- if (key.name === "return" || key.name === "enter") {
601
- if (s.detailView === "service-logs") {
602
- const selectedLog = s.serviceLogState.data[s.selectedServiceLogIndex]
603
- if (selectedLog?.traceId) {
604
- const traceIndex = s.traceState.data.findIndex((trace) => trace.traceId === selectedLog.traceId)
605
- if (traceIndex >= 0) {
606
- setSelectedTraceIndex(traceIndex)
607
- setDetailView("waterfall")
608
- s.flashNotice(`Jumped to trace ${selectedLog.traceId.slice(-8)}`)
609
- }
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)}`)
610
798
  }
611
- return
612
- }
613
- if (s.spanNavActive && s.detailView === "waterfall") {
614
- setDetailView("span-detail")
615
- return
616
799
  }
617
- if (!s.spanNavActive && s.selectedTrace && s.selectedTrace.spans.length > 0) {
618
- setSelectedSpanIndex(0)
619
- return
620
- }
621
- return
800
+ return true
801
+ }
802
+ if (s.spanNavActive && s.detailView === "waterfall") {
803
+ setDetailView("span-detail")
804
+ return true
805
+ }
806
+ if (!s.spanNavActive && s.selectedTrace && s.selectedTrace.spans.length > 0) {
807
+ setSelectedSpanIndex(0)
808
+ return true
622
809
  }
810
+ return true
811
+ }
812
+
813
+ const handleToolbarKeys = (key: KeyboardKey) => {
814
+ const s = $()
623
815
  if (key.name === "r") {
624
816
  refresh("Refreshing traces...")
625
- return
817
+ return true
626
818
  }
627
819
  if (key.name === "a") {
628
820
  setAutoRefresh(!s.autoRefresh)
629
821
  s.flashNotice(s.autoRefresh ? "Auto-refresh paused" : "Auto-refresh resumed")
630
- return
822
+ return true
631
823
  }
632
824
  if (key.name === "s") {
633
825
  const modes: readonly TraceSortMode[] = ["recent", "slowest", "errors"]
634
826
  const nextMode = modes[(modes.indexOf(s.traceSort) + 1) % modes.length] ?? "recent"
635
827
  setTraceSort(nextMode)
636
828
  s.flashNotice(`Sort: ${nextMode}`)
637
- return
829
+ return true
638
830
  }
639
831
  if (key.name === "t") {
640
832
  const nextTheme = cycleThemeName(s.selectedTheme)
641
833
  setSelectedTheme(nextTheme)
642
834
  s.flashNotice(`Theme: ${themeLabel(nextTheme)}`)
643
- return
835
+ return true
644
836
  }
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
837
  if ((key.name === "n" || key.name === "N") && !key.ctrl && !key.meta) {
651
838
  const inWaterfall = s.detailView === "span-detail" || s.selectedSpanIndex !== null
652
839
  if (inWaterfall && s.waterfallFilterText.length > 0 && s.selectedTrace) {
653
- const visibleSpans = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds)
840
+ const visibleSpans = getVisibleSelectedSpans()
654
841
  const matchingIds = computeMatchingSpanIds(visibleSpans, s.waterfallFilterText)
655
842
  if (matchingIds && matchingIds.size > 0) {
656
843
  const direction = key.name === "N" ? -1 : 1
@@ -660,81 +847,59 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
660
847
  } else {
661
848
  s.flashNotice("No matches")
662
849
  }
663
- return
850
+ return true
664
851
  }
665
- // Fall through when not in a trace detail view — reserves `n`
666
- // for other future bindings without shadowing them globally.
667
852
  }
668
-
669
853
  if (key.name === "/" && !key.shift) {
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
854
  const inWaterfall = s.detailView === "span-detail" || s.selectedSpanIndex !== null
676
- if (inWaterfall) {
677
- setWaterfallFilterMode(true)
678
- } else {
679
- setFilterMode(true)
680
- }
681
- return
855
+ if (inWaterfall) setWaterfallFilterMode(true)
856
+ else setFilterMode(true)
857
+ return true
682
858
  }
683
859
  if ((key.name === "f" || key.name === "F") && !key.ctrl && !key.meta) {
684
- // Open attribute picker at the keys step. If a filter is already
685
- // applied, reopening lets the user refine or switch.
686
860
  setPickerMode("keys")
687
- setPickerInput("")
688
- setPickerIndex(0)
861
+ resetPicker()
689
862
  setActiveAttrKey(null)
690
- return
863
+ return true
691
864
  }
692
865
  if (key.name === "tab") {
693
866
  toggleServiceLogsView()
694
- return
867
+ return true
695
868
  }
696
869
  if (key.name === "[") {
697
870
  cycleService(-1)
698
- return
871
+ return true
699
872
  }
700
873
  if (key.name === "]") {
701
874
  cycleService(1)
702
- return
875
+ return true
703
876
  }
877
+ return false
878
+ }
879
+
880
+ const handleMovementKeys = (key: KeyboardKey) => {
881
+ const s = $()
704
882
  if (key.name === "up" || key.name === "k") {
705
- if (s.serviceLogNavActive) {
706
- moveServiceLogBy(-1)
707
- } else if (s.spanNavActive) {
708
- // Locked to span nav; never fall through to trace-list nav while
709
- // drilled in. If the trace detail is still loading, swallow the
710
- // key instead of silently leaking it to the trace list.
711
- if (s.selectedTrace) {
712
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
713
- setSelectedSpanIndex((current) => {
714
- if (current === null || visibleCount === 0) return 0
715
- return Math.max(0, current - 1)
716
- })
717
- }
718
- } else {
719
- moveTraceBy(-1)
883
+ if (s.chatNavActive) {
884
+ moveChatChunkBy(-1)
885
+ return true
720
886
  }
721
- 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
722
892
  }
723
893
  if (key.name === "down" || key.name === "j") {
724
- if (s.serviceLogNavActive) {
725
- moveServiceLogBy(1)
726
- } else if (s.spanNavActive) {
727
- if (s.selectedTrace) {
728
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
729
- setSelectedSpanIndex((current) => {
730
- if (current === null || visibleCount === 0) return 0
731
- return Math.min(current + 1, visibleCount - 1)
732
- })
733
- }
734
- } else {
735
- moveTraceBy(1)
894
+ if (s.chatNavActive) {
895
+ moveChatChunkBy(1)
896
+ return true
736
897
  }
737
- 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
738
903
  }
739
904
  if (key.name === "left" || key.name === "h") {
740
905
  if (s.spanNavActive && s.selectedTrace) {
@@ -746,13 +911,11 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
746
911
  selectedIndex: s.selectedSpanIndex,
747
912
  direction: "left",
748
913
  })
749
- if (result.selectedIndex !== s.selectedSpanIndex) {
750
- setSelectedSpanIndex(result.selectedIndex)
751
- }
914
+ if (result.selectedIndex !== s.selectedSpanIndex) setSelectedSpanIndex(result.selectedIndex)
752
915
  return result.collapsed
753
916
  })
754
917
  }
755
- return
918
+ return true
756
919
  }
757
920
  if (key.name === "right" || key.name === "l") {
758
921
  if (s.spanNavActive && s.selectedTrace) {
@@ -764,16 +927,19 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
764
927
  selectedIndex: s.selectedSpanIndex,
765
928
  direction: "right",
766
929
  })
767
- if (result.selectedIndex !== s.selectedSpanIndex) {
768
- setSelectedSpanIndex(result.selectedIndex)
769
- }
930
+ if (result.selectedIndex !== s.selectedSpanIndex) setSelectedSpanIndex(result.selectedIndex)
770
931
  return result.collapsed
771
932
  })
772
933
  } else if (!s.spanNavActive && !s.serviceLogNavActive) {
773
934
  toggleServiceLogsView()
774
935
  }
775
- return
936
+ return true
776
937
  }
938
+ return false
939
+ }
940
+
941
+ const handleOpenCopyKeys = (key: KeyboardKey) => {
942
+ const s = $()
777
943
  if (key.name === "o" && !key.shift) {
778
944
  if (s.serviceLogNavActive) {
779
945
  const selectedLog = s.serviceLogState.data[s.selectedServiceLogIndex]
@@ -781,21 +947,23 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
781
947
  void Bun.spawn({ cmd: ["open", traceUiUrl(selectedLog.traceId)], stdout: "ignore", stderr: "ignore" })
782
948
  s.flashNotice(`Opened trace ${selectedLog.traceId.slice(-8)}`)
783
949
  }
784
- return
950
+ return true
785
951
  }
786
- if (!s.selectedTrace) return
952
+ if (!s.selectedTrace) return true
787
953
  void Bun.spawn({ cmd: ["open", traceUiUrl(s.selectedTrace.traceId)], stdout: "ignore", stderr: "ignore" })
788
954
  s.flashNotice(`Opened trace ${s.selectedTrace.traceId.slice(-8)}`)
789
- return
955
+ return true
790
956
  }
791
957
  if (key.name === "o" && key.shift) {
792
958
  void Bun.spawn({ cmd: ["open", webUiUrl()], stdout: "ignore", stderr: "ignore" })
793
959
  s.flashNotice("Opened web UI")
794
- return
960
+ return true
795
961
  }
796
962
  if (key.name === "y" || key.name === "Y") {
797
- copySelectedIds()
798
- return
963
+ if (s.chatNavActive) copySelectedChatChunk()
964
+ else if (s.attrNavActive) copySelectedAttrValue()
965
+ else copySelectedIds()
966
+ return true
799
967
  }
800
968
  if (key.name === "c" || key.name === "C") {
801
969
  void copyToClipboard(otelServerInstructions())
@@ -805,7 +973,27 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
805
973
  .catch((error) => {
806
974
  s.flashNotice(error instanceof Error ? error.message : String(error))
807
975
  })
976
+ return true
808
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)
809
997
  })
810
998
 
811
999
  return { spanNavActive }