@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.
- package/AGENTS.md +11 -1
- package/package.json +5 -3
- package/src/App.tsx +239 -59
- package/src/daemon.test.ts +144 -7
- package/src/daemon.ts +113 -8
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +62 -4
- package/src/httpApi.ts +4 -1
- package/src/localServer.ts +112 -121
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +52 -0
- package/src/services/TelemetryStore.ts +285 -27
- package/src/services/TraceQueryService.ts +4 -2
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +243 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +61 -0
- package/src/ui/AiChatView.tsx +292 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +35 -3
- package/src/ui/Waterfall.tsx +94 -167
- package/src/ui/aiChatModel.test.ts +347 -0
- package/src/ui/aiChatModel.ts +736 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +295 -120
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +191 -35
- package/src/ui/atoms.ts +131 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +47 -21
- package/src/ui/state.ts +4 -169
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +576 -300
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
- package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
- package/web/dist/index.html +1 -1
package/src/ui/useKeyboardNav.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
57
|
+
interface KeyboardKey {
|
|
50
58
|
readonly name: string
|
|
51
59
|
readonly sequence?: string
|
|
52
60
|
readonly ctrl: boolean
|
|
53
61
|
readonly meta: boolean
|
|
54
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
546
|
+
const handlePickerMode = (key: KeyboardKey) => {
|
|
337
547
|
const s = $()
|
|
548
|
+
if (s.pickerMode === "off") return false
|
|
338
549
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
const questionMark = key.name === "?" || (key.name === "/" && key.shift)
|
|
620
|
+
return true
|
|
621
|
+
}
|
|
452
622
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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 (
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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.
|
|
618
|
-
|
|
619
|
-
|
|
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.
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
710
|
-
|
|
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 }
|