@aryasaatvik/motel 0.3.0

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