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