@thehoneyjar/sigil-hud 0.1.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.
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Data Source Hook
3
+ *
4
+ * Track data source and staleness for displayed values.
5
+ * Implements TASK-202: State source tracing.
6
+ */
7
+
8
+ import { useState, useCallback, useEffect, useRef } from 'react'
9
+ import type { DataSourceType } from '../components/DataSourceIndicator'
10
+
11
+ /**
12
+ * Data source metadata
13
+ */
14
+ export interface DataSourceMeta {
15
+ /** Data source type */
16
+ source: DataSourceType
17
+ /** When the data was fetched */
18
+ fetchedAt: number
19
+ /** Block number (for on-chain data) */
20
+ blockNumber?: number
21
+ /** Current block (for staleness calculation) */
22
+ currentBlock?: number
23
+ /** Whether the data is stale */
24
+ isStale: boolean
25
+ /** Staleness level */
26
+ stalenessLevel: 'fresh' | 'stale' | 'very-stale'
27
+ /** Human-readable staleness label */
28
+ stalenessLabel: string
29
+ }
30
+
31
+ /**
32
+ * Props for useDataSource
33
+ */
34
+ export interface UseDataSourceProps {
35
+ /** Data source type */
36
+ source: DataSourceType
37
+ /** Block number for on-chain data */
38
+ blockNumber?: number
39
+ /** Interval to check staleness (ms) */
40
+ pollInterval?: number
41
+ /** Threshold for "stale" in seconds (for time-based) */
42
+ staleThresholdSeconds?: number
43
+ /** Threshold for "very stale" in seconds (for time-based) */
44
+ veryStaleThresholdSeconds?: number
45
+ /** Threshold for "stale" in blocks (for block-based) */
46
+ staleThresholdBlocks?: number
47
+ /** Threshold for "very stale" in blocks (for block-based) */
48
+ veryStaleThresholdBlocks?: number
49
+ /** Callback when data becomes stale */
50
+ onStale?: () => void
51
+ }
52
+
53
+ /**
54
+ * Default staleness thresholds
55
+ */
56
+ const DEFAULT_STALE_SECONDS = 60 // 1 minute
57
+ const DEFAULT_VERY_STALE_SECONDS = 300 // 5 minutes
58
+ const DEFAULT_STALE_BLOCKS = 5
59
+ const DEFAULT_VERY_STALE_BLOCKS = 20
60
+ const DEFAULT_POLL_INTERVAL = 10000 // 10 seconds
61
+
62
+ /**
63
+ * Calculate staleness for time-based data
64
+ */
65
+ function calculateTimeStaleness(
66
+ fetchedAt: number,
67
+ staleThreshold: number,
68
+ veryStaleThreshold: number
69
+ ): { isStale: boolean; level: 'fresh' | 'stale' | 'very-stale'; label: string } {
70
+ const secondsAgo = Math.floor((Date.now() - fetchedAt) / 1000)
71
+
72
+ if (secondsAgo < 30) {
73
+ return { isStale: false, level: 'fresh', label: 'just now' }
74
+ }
75
+ if (secondsAgo < 60) {
76
+ return { isStale: false, level: 'fresh', label: `${secondsAgo}s ago` }
77
+ }
78
+ if (secondsAgo < staleThreshold) {
79
+ return { isStale: false, level: 'fresh', label: `${Math.floor(secondsAgo / 60)}m ago` }
80
+ }
81
+ if (secondsAgo < veryStaleThreshold) {
82
+ return { isStale: true, level: 'stale', label: `${Math.floor(secondsAgo / 60)}m ago` }
83
+ }
84
+ return { isStale: true, level: 'very-stale', label: `${Math.floor(secondsAgo / 60)}m ago` }
85
+ }
86
+
87
+ /**
88
+ * Calculate staleness for block-based data
89
+ */
90
+ function calculateBlockStaleness(
91
+ blockNumber: number,
92
+ currentBlock: number,
93
+ staleThreshold: number,
94
+ veryStaleThreshold: number
95
+ ): { isStale: boolean; level: 'fresh' | 'stale' | 'very-stale'; label: string } {
96
+ const blocksBehind = currentBlock - blockNumber
97
+
98
+ if (blocksBehind <= 1) {
99
+ return { isStale: false, level: 'fresh', label: 'current' }
100
+ }
101
+ if (blocksBehind <= staleThreshold) {
102
+ return { isStale: false, level: 'fresh', label: `${blocksBehind} blocks behind` }
103
+ }
104
+ if (blocksBehind <= veryStaleThreshold) {
105
+ return { isStale: true, level: 'stale', label: `${blocksBehind} blocks behind` }
106
+ }
107
+ return { isStale: true, level: 'very-stale', label: `${blocksBehind} blocks behind` }
108
+ }
109
+
110
+ /**
111
+ * Hook to track data source and staleness
112
+ */
113
+ export function useDataSource({
114
+ source,
115
+ blockNumber,
116
+ pollInterval = DEFAULT_POLL_INTERVAL,
117
+ staleThresholdSeconds = DEFAULT_STALE_SECONDS,
118
+ veryStaleThresholdSeconds = DEFAULT_VERY_STALE_SECONDS,
119
+ staleThresholdBlocks = DEFAULT_STALE_BLOCKS,
120
+ veryStaleThresholdBlocks = DEFAULT_VERY_STALE_BLOCKS,
121
+ onStale,
122
+ }: UseDataSourceProps) {
123
+ const [meta, setMeta] = useState<DataSourceMeta>(() => ({
124
+ source,
125
+ fetchedAt: Date.now(),
126
+ blockNumber,
127
+ currentBlock: blockNumber,
128
+ isStale: false,
129
+ stalenessLevel: 'fresh',
130
+ stalenessLabel: 'just now',
131
+ }))
132
+
133
+ const wasStaleRef = useRef(false)
134
+ const onStaleRef = useRef(onStale)
135
+ onStaleRef.current = onStale
136
+
137
+ /**
138
+ * Mark data as refreshed
139
+ */
140
+ const markRefreshed = useCallback(
141
+ (newBlockNumber?: number) => {
142
+ setMeta((prev) => ({
143
+ ...prev,
144
+ fetchedAt: Date.now(),
145
+ blockNumber: newBlockNumber ?? prev.blockNumber,
146
+ currentBlock: newBlockNumber ?? prev.currentBlock,
147
+ isStale: false,
148
+ stalenessLevel: 'fresh',
149
+ stalenessLabel: 'just now',
150
+ }))
151
+ wasStaleRef.current = false
152
+ },
153
+ []
154
+ )
155
+
156
+ /**
157
+ * Update current block (for on-chain data)
158
+ */
159
+ const updateCurrentBlock = useCallback((newCurrentBlock: number) => {
160
+ setMeta((prev) => ({
161
+ ...prev,
162
+ currentBlock: newCurrentBlock,
163
+ }))
164
+ }, [])
165
+
166
+ /**
167
+ * Recalculate staleness
168
+ */
169
+ const recalculateStaleness = useCallback(() => {
170
+ setMeta((prev) => {
171
+ let staleness: { isStale: boolean; level: 'fresh' | 'stale' | 'very-stale'; label: string }
172
+
173
+ if (source === 'on-chain' && prev.blockNumber && prev.currentBlock) {
174
+ staleness = calculateBlockStaleness(
175
+ prev.blockNumber,
176
+ prev.currentBlock,
177
+ staleThresholdBlocks,
178
+ veryStaleThresholdBlocks
179
+ )
180
+ } else {
181
+ staleness = calculateTimeStaleness(
182
+ prev.fetchedAt,
183
+ staleThresholdSeconds,
184
+ veryStaleThresholdSeconds
185
+ )
186
+ }
187
+
188
+ // Trigger callback when becoming stale
189
+ if (staleness.isStale && !wasStaleRef.current) {
190
+ wasStaleRef.current = true
191
+ onStaleRef.current?.()
192
+ }
193
+
194
+ return {
195
+ ...prev,
196
+ isStale: staleness.isStale,
197
+ stalenessLevel: staleness.level,
198
+ stalenessLabel: staleness.label,
199
+ }
200
+ })
201
+ }, [
202
+ source,
203
+ staleThresholdSeconds,
204
+ veryStaleThresholdSeconds,
205
+ staleThresholdBlocks,
206
+ veryStaleThresholdBlocks,
207
+ ])
208
+
209
+ // Poll for staleness updates
210
+ useEffect(() => {
211
+ const interval = setInterval(recalculateStaleness, pollInterval)
212
+ return () => clearInterval(interval)
213
+ }, [recalculateStaleness, pollInterval])
214
+
215
+ // Recalculate when source changes
216
+ useEffect(() => {
217
+ setMeta((prev) => ({
218
+ ...prev,
219
+ source,
220
+ blockNumber: blockNumber ?? prev.blockNumber,
221
+ }))
222
+ recalculateStaleness()
223
+ }, [source, blockNumber, recalculateStaleness])
224
+
225
+ return {
226
+ meta,
227
+ markRefreshed,
228
+ updateCurrentBlock,
229
+ recalculateStaleness,
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Props for multiple data sources
235
+ */
236
+ export interface DataSourceEntry {
237
+ /** Unique key for this data source */
238
+ key: string
239
+ /** Data source type */
240
+ source: DataSourceType
241
+ /** Block number (for on-chain) */
242
+ blockNumber?: number
243
+ /** Label for display */
244
+ label?: string
245
+ }
246
+
247
+ /**
248
+ * Hook to track multiple data sources
249
+ */
250
+ export function useMultipleDataSources(entries: DataSourceEntry[]) {
251
+ const [sources, setSources] = useState<Map<string, DataSourceMeta>>(new Map())
252
+
253
+ // Initialize sources
254
+ useEffect(() => {
255
+ const newSources = new Map<string, DataSourceMeta>()
256
+ const now = Date.now()
257
+
258
+ for (const entry of entries) {
259
+ newSources.set(entry.key, {
260
+ source: entry.source,
261
+ fetchedAt: now,
262
+ blockNumber: entry.blockNumber,
263
+ currentBlock: entry.blockNumber,
264
+ isStale: false,
265
+ stalenessLevel: 'fresh',
266
+ stalenessLabel: 'just now',
267
+ })
268
+ }
269
+
270
+ setSources(newSources)
271
+ }, [entries])
272
+
273
+ /**
274
+ * Mark a specific source as refreshed
275
+ */
276
+ const markRefreshed = useCallback((key: string, newBlockNumber?: number) => {
277
+ setSources((prev) => {
278
+ const newMap = new Map(prev)
279
+ const existing = newMap.get(key)
280
+ if (existing) {
281
+ newMap.set(key, {
282
+ ...existing,
283
+ fetchedAt: Date.now(),
284
+ blockNumber: newBlockNumber ?? existing.blockNumber,
285
+ currentBlock: newBlockNumber ?? existing.currentBlock,
286
+ isStale: false,
287
+ stalenessLevel: 'fresh',
288
+ stalenessLabel: 'just now',
289
+ })
290
+ }
291
+ return newMap
292
+ })
293
+ }, [])
294
+
295
+ /**
296
+ * Get metadata for a specific source
297
+ */
298
+ const getMeta = useCallback(
299
+ (key: string): DataSourceMeta | undefined => {
300
+ return sources.get(key)
301
+ },
302
+ [sources]
303
+ )
304
+
305
+ /**
306
+ * Get all stale sources
307
+ */
308
+ const getStaleSources = useCallback((): string[] => {
309
+ const stale: string[] = []
310
+ sources.forEach((meta, key) => {
311
+ if (meta.isStale) {
312
+ stale.push(key)
313
+ }
314
+ })
315
+ return stale
316
+ }, [sources])
317
+
318
+ return {
319
+ sources,
320
+ markRefreshed,
321
+ getMeta,
322
+ getStaleSources,
323
+ }
324
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Keyboard Shortcuts Hook
3
+ *
4
+ * Handle keyboard shortcuts for HUD navigation.
5
+ */
6
+
7
+ import { useEffect, useCallback } from 'react'
8
+ import { useHudStore } from '../store'
9
+ import type { HudPanelType } from '../types'
10
+
11
+ /**
12
+ * Default keyboard shortcuts
13
+ */
14
+ const DEFAULT_SHORTCUTS: Record<string, () => void> = {}
15
+
16
+ /**
17
+ * Props for useKeyboardShortcuts
18
+ */
19
+ export interface UseKeyboardShortcutsProps {
20
+ /** Whether shortcuts are enabled */
21
+ enabled?: boolean
22
+ /** Custom shortcuts to add */
23
+ customShortcuts?: Record<string, () => void>
24
+ }
25
+
26
+ /**
27
+ * Hook to handle keyboard shortcuts for HUD
28
+ */
29
+ export function useKeyboardShortcuts({
30
+ enabled = true,
31
+ customShortcuts = {},
32
+ }: UseKeyboardShortcutsProps = {}) {
33
+ const toggle = useHudStore((state) => state.toggle)
34
+ const setActivePanel = useHudStore((state) => state.setActivePanel)
35
+ const isOpen = useHudStore((state) => state.isOpen)
36
+
37
+ const handleKeyDown = useCallback(
38
+ (event: KeyboardEvent) => {
39
+ if (!enabled) return
40
+
41
+ // Ignore if typing in an input
42
+ const target = event.target as HTMLElement
43
+ if (
44
+ target.tagName === 'INPUT' ||
45
+ target.tagName === 'TEXTAREA' ||
46
+ target.isContentEditable
47
+ ) {
48
+ return
49
+ }
50
+
51
+ // Cmd/Ctrl + Shift + D: Toggle HUD
52
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'd') {
53
+ event.preventDefault()
54
+ toggle()
55
+ return
56
+ }
57
+
58
+ // Only handle panel shortcuts when HUD is open
59
+ if (!isOpen) return
60
+
61
+ // Number keys for panels when HUD is open
62
+ const panelMap: Record<string, HudPanelType> = {
63
+ '1': 'lens',
64
+ '2': 'simulation',
65
+ '3': 'diagnostics',
66
+ '4': 'state',
67
+ '5': 'signals',
68
+ }
69
+
70
+ if (panelMap[event.key]) {
71
+ event.preventDefault()
72
+ setActivePanel(panelMap[event.key])
73
+ return
74
+ }
75
+
76
+ // Escape to close
77
+ if (event.key === 'Escape') {
78
+ event.preventDefault()
79
+ toggle()
80
+ return
81
+ }
82
+
83
+ // Custom shortcuts
84
+ const shortcutKey = getShortcutKey(event)
85
+ if (customShortcuts[shortcutKey]) {
86
+ event.preventDefault()
87
+ customShortcuts[shortcutKey]()
88
+ }
89
+ },
90
+ [enabled, toggle, setActivePanel, isOpen, customShortcuts]
91
+ )
92
+
93
+ useEffect(() => {
94
+ if (!enabled) return
95
+
96
+ window.addEventListener('keydown', handleKeyDown)
97
+ return () => window.removeEventListener('keydown', handleKeyDown)
98
+ }, [enabled, handleKeyDown])
99
+ }
100
+
101
+ /**
102
+ * Get shortcut key string from event
103
+ */
104
+ function getShortcutKey(event: KeyboardEvent): string {
105
+ const parts: string[] = []
106
+
107
+ if (event.metaKey) parts.push('cmd')
108
+ if (event.ctrlKey) parts.push('ctrl')
109
+ if (event.altKey) parts.push('alt')
110
+ if (event.shiftKey) parts.push('shift')
111
+ parts.push(event.key.toLowerCase())
112
+
113
+ return parts.join('+')
114
+ }
115
+
116
+ /**
117
+ * Get keyboard shortcut help text
118
+ */
119
+ export function getShortcutHelp(): Array<{ keys: string; description: string }> {
120
+ return [
121
+ { keys: '⌘⇧D', description: 'Toggle HUD' },
122
+ { keys: '1-5', description: 'Switch panels (when open)' },
123
+ { keys: 'Esc', description: 'Close HUD' },
124
+ ]
125
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Observation Capture Hook
3
+ *
4
+ * Capture user observations and insights from HUD.
5
+ */
6
+
7
+ import { useCallback, useRef } from 'react'
8
+ import type { Observation } from '../types'
9
+
10
+ /**
11
+ * Props for useObservationCapture
12
+ */
13
+ export interface UseObservationCaptureProps {
14
+ /** Whether observation capture is enabled */
15
+ enabled?: boolean
16
+ /** Callback when observation is captured */
17
+ onObservation?: (observation: Observation) => void
18
+ }
19
+
20
+ /**
21
+ * Generate a unique observation ID
22
+ */
23
+ function generateObservationId(): string {
24
+ const timestamp = Date.now().toString(36)
25
+ const random = Math.random().toString(36).substring(2, 7)
26
+ return `obs-${timestamp}-${random}`
27
+ }
28
+
29
+ /**
30
+ * Hook to capture observations from HUD
31
+ */
32
+ export function useObservationCapture({
33
+ enabled = true,
34
+ onObservation,
35
+ }: UseObservationCaptureProps = {}) {
36
+ const observationsRef = useRef<Observation[]>([])
37
+
38
+ /**
39
+ * Capture a new observation
40
+ */
41
+ const capture = useCallback(
42
+ async (
43
+ content: string,
44
+ type: Observation['type'] = 'insight',
45
+ context?: Observation['context'],
46
+ tags: string[] = []
47
+ ): Promise<Observation> => {
48
+ const observation: Observation = {
49
+ id: generateObservationId(),
50
+ timestamp: new Date().toISOString(),
51
+ type,
52
+ content,
53
+ tags,
54
+ context,
55
+ }
56
+
57
+ if (enabled) {
58
+ // Store locally
59
+ observationsRef.current.push(observation)
60
+
61
+ // Notify callback
62
+ onObservation?.(observation)
63
+
64
+ // In a full implementation, this would write to grimoires/sigil/observations/
65
+ if (process.env.NODE_ENV === 'development') {
66
+ console.log('[Sigil HUD] Observation captured:', observation)
67
+ }
68
+ }
69
+
70
+ return observation
71
+ },
72
+ [enabled, onObservation]
73
+ )
74
+
75
+ /**
76
+ * Capture a user truth observation
77
+ */
78
+ const captureUserTruth = useCallback(
79
+ (content: string, context?: Observation['context']) => {
80
+ return capture(content, 'user-truth', context, ['user-truth'])
81
+ },
82
+ [capture]
83
+ )
84
+
85
+ /**
86
+ * Capture an issue observation
87
+ */
88
+ const captureIssue = useCallback(
89
+ (content: string, context?: Observation['context']) => {
90
+ return capture(content, 'issue', context, ['issue'])
91
+ },
92
+ [capture]
93
+ )
94
+
95
+ /**
96
+ * Capture an insight observation
97
+ */
98
+ const captureInsight = useCallback(
99
+ (content: string, context?: Observation['context']) => {
100
+ return capture(content, 'insight', context, ['insight'])
101
+ },
102
+ [capture]
103
+ )
104
+
105
+ /**
106
+ * Link an observation to signals
107
+ */
108
+ const linkToSignals = useCallback(
109
+ (observationId: string, signalIds: string[]) => {
110
+ const observation = observationsRef.current.find(
111
+ (o) => o.id === observationId
112
+ )
113
+ if (observation) {
114
+ observation.linkedSignals = [
115
+ ...(observation.linkedSignals ?? []),
116
+ ...signalIds,
117
+ ]
118
+ }
119
+ },
120
+ []
121
+ )
122
+
123
+ /**
124
+ * Get all captured observations
125
+ */
126
+ const getObservations = useCallback(() => {
127
+ return [...observationsRef.current]
128
+ }, [])
129
+
130
+ /**
131
+ * Get observations by type
132
+ */
133
+ const getObservationsByType = useCallback((type: Observation['type']) => {
134
+ return observationsRef.current.filter((o) => o.type === type)
135
+ }, [])
136
+
137
+ /**
138
+ * Clear captured observations
139
+ */
140
+ const clearObservations = useCallback(() => {
141
+ observationsRef.current = []
142
+ }, [])
143
+
144
+ return {
145
+ capture,
146
+ captureUserTruth,
147
+ captureIssue,
148
+ captureInsight,
149
+ linkToSignals,
150
+ getObservations,
151
+ getObservationsByType,
152
+ clearObservations,
153
+ }
154
+ }