@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.
- package/AGENTS.md +5 -0
- package/package.json +7 -5
- package/src/App.tsx +233 -59
- package/src/daemon.test.ts +213 -6
- package/src/daemon.ts +174 -38
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +16 -0
- package/src/localServer.ts +114 -128
- 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 +68 -0
- package/src/services/TelemetryStore.ts +262 -119
- package/src/services/TraceQueryService.ts +3 -1
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +244 -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 +28 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +11 -28
- package/src/ui/Waterfall.tsx +43 -148
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +288 -124
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +174 -40
- package/src/ui/atoms.ts +131 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +27 -13
- package/src/ui/state.ts +4 -199
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +552 -364
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +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,
|
|
@@ -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
|
-
|
|
57
|
+
interface KeyboardKey {
|
|
53
58
|
readonly name: string
|
|
54
59
|
readonly sequence?: string
|
|
55
60
|
readonly ctrl: boolean
|
|
56
61
|
readonly meta: boolean
|
|
57
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
546
|
+
const handlePickerMode = (key: KeyboardKey) => {
|
|
346
547
|
const s = $()
|
|
548
|
+
if (s.pickerMode === "off") return false
|
|
347
549
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
426
|
-
if (
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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 (
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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 (
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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 =
|
|
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
|
-
|
|
678
|
-
|
|
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
|
-
|
|
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.
|
|
706
|
-
|
|
707
|
-
|
|
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.
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
798
|
-
|
|
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 }
|