@pyric/ui 0.1.0-alpha.11 → 0.1.0-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/package.json +5 -4
  2. package/src/agents/ContextWindowUsage.tsx +514 -0
  3. package/src/agents/EmptyState.tsx +27 -0
  4. package/src/agents/Fold.tsx +64 -0
  5. package/src/agents/Modal.tsx +65 -0
  6. package/src/agents/PulsingDot.tsx +28 -0
  7. package/src/agents/inbrowser-agent-usage.d.ts +141 -0
  8. package/src/agents/index.ts +28 -0
  9. package/src/auth/authApi.ts +72 -0
  10. package/src/auth/claims.ts +63 -0
  11. package/src/auth/components/AuthProviderToggles.tsx +147 -0
  12. package/src/auth/components/AuthSignInHelper.tsx +197 -0
  13. package/src/auth/components/AuthUserForm.tsx +328 -0
  14. package/src/auth/components/AuthUserList.tsx +219 -0
  15. package/src/auth/components/ClaimsField.tsx +50 -0
  16. package/src/auth/components/confirmActions.tsx +114 -0
  17. package/src/auth/controller.ts +173 -0
  18. package/src/auth/hooks/index.ts +36 -0
  19. package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
  20. package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
  21. package/src/auth/hooks/useAuthUserEditor.ts +76 -0
  22. package/src/auth/hooks/useAuthUsers.ts +154 -0
  23. package/src/auth/index.ts +42 -0
  24. package/src/auth/providers.ts +28 -0
  25. package/src/auth/reducers/userEditor.ts +186 -0
  26. package/src/events/components/ActivityActionItems.tsx +136 -0
  27. package/src/events/components/ActivityGrid.tsx +197 -0
  28. package/src/events/components/ActivityGridRow.tsx +65 -0
  29. package/src/events/components/ProposedChangeDiff.tsx +175 -0
  30. package/src/events/components/format.ts +21 -0
  31. package/src/events/components/index.ts +17 -0
  32. package/src/events/digest.ts +630 -0
  33. package/src/events/hooks/index.ts +9 -0
  34. package/src/events/hooks/useActivityDigest.ts +42 -0
  35. package/src/events/hooks/useActivityStream.ts +86 -0
  36. package/src/events/index.ts +39 -0
  37. package/src/events/types.ts +152 -0
  38. package/src/firestore/components/CollectionList.tsx +78 -0
  39. package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
  40. package/src/firestore/components/DocumentEditor.tsx +350 -0
  41. package/src/firestore/components/DocumentList.tsx +217 -0
  42. package/src/firestore/components/DocumentPreview.tsx +265 -0
  43. package/src/firestore/components/FieldRenderer.tsx +25 -0
  44. package/src/firestore/components/QueryBuilder.tsx +181 -0
  45. package/src/firestore/components/ReferencePicker.tsx +212 -0
  46. package/src/firestore/components/TreeEntry.tsx +58 -0
  47. package/src/firestore/components/context.ts +25 -0
  48. package/src/firestore/fieldEditors/array.tsx +30 -0
  49. package/src/firestore/fieldEditors/boolean.tsx +39 -0
  50. package/src/firestore/fieldEditors/bytes.tsx +53 -0
  51. package/src/firestore/fieldEditors/geopoint.tsx +71 -0
  52. package/src/firestore/fieldEditors/map.tsx +33 -0
  53. package/src/firestore/fieldEditors/null.tsx +27 -0
  54. package/src/firestore/fieldEditors/number.tsx +43 -0
  55. package/src/firestore/fieldEditors/reference.tsx +87 -0
  56. package/src/firestore/fieldEditors/registry.ts +45 -0
  57. package/src/firestore/fieldEditors/string.tsx +33 -0
  58. package/src/firestore/fieldEditors/timestamp.tsx +75 -0
  59. package/src/firestore/fieldEditors/types.ts +68 -0
  60. package/src/firestore/fieldEditors/vector.tsx +142 -0
  61. package/src/firestore/firestoreApi.ts +86 -0
  62. package/src/firestore/hooks/coerceError.ts +34 -0
  63. package/src/firestore/hooks/index.ts +46 -0
  64. package/src/firestore/hooks/useCollectionList.ts +102 -0
  65. package/src/firestore/hooks/useDocumentEditor.ts +161 -0
  66. package/src/firestore/hooks/useDocumentList.ts +242 -0
  67. package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
  68. package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
  69. package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
  70. package/src/firestore/hooks/useQueryBuilder.ts +188 -0
  71. package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
  72. package/src/firestore/hooks/useReferencePicker.ts +228 -0
  73. package/src/firestore/import/parseImport.ts +137 -0
  74. package/src/firestore/index.ts +87 -0
  75. package/src/firestore/reducers/defaults.ts +38 -0
  76. package/src/firestore/reducers/documentEditor.ts +239 -0
  77. package/src/firestore/reducers/tree.ts +140 -0
  78. package/src/firestore/reducers/types.ts +92 -0
  79. package/src/firestore/reducers/validation.ts +129 -0
  80. package/src/firestore/types.ts +231 -0
  81. package/src/firestore/validation/ids.ts +45 -0
  82. package/src/firestore/valueEquality.ts +43 -0
  83. package/src/index.ts +10 -0
  84. package/src/primitives/Badge.tsx +40 -0
  85. package/src/primitives/ConfirmDialog.tsx +137 -0
  86. package/src/primitives/CopyButton.tsx +62 -0
  87. package/src/primitives/JsonView.tsx +151 -0
  88. package/src/primitives/SegmentedControl.tsx +72 -0
  89. package/src/primitives/Toast.tsx +161 -0
  90. package/src/primitives/VirtualList.tsx +104 -0
  91. package/src/primitives/hooks/useContainerSize.ts +53 -0
  92. package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
  93. package/src/primitives/index.ts +39 -0
  94. package/src/primitives/useConfirm.tsx +113 -0
  95. package/src/rtdb/components/RtdbPathBar.tsx +135 -0
  96. package/src/rtdb/components/RtdbTree.tsx +409 -0
  97. package/src/rtdb/editor.ts +79 -0
  98. package/src/rtdb/hooks/useRtdbTree.ts +137 -0
  99. package/src/rtdb/index.ts +66 -0
  100. package/src/rtdb/pathInput.ts +47 -0
  101. package/src/rtdb/reducers/tree.ts +191 -0
  102. package/src/rtdb/rtdbApi.ts +23 -0
  103. package/src/rtdb/values.ts +188 -0
  104. package/src/rules/components/DenialInspector.tsx +227 -0
  105. package/src/rules/components/format.ts +171 -0
  106. package/src/rules/components/index.ts +12 -0
  107. package/src/rules/components/scope.ts +75 -0
  108. package/src/rules/hooks/index.ts +5 -0
  109. package/src/rules/hooks/useDenialTrace.ts +100 -0
  110. package/src/rules/index.ts +24 -0
  111. package/src/rules/types.ts +91 -0
  112. package/src/storage/collisionRename.ts +114 -0
  113. package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
  114. package/src/storage/components/ObjectBrowser.tsx +219 -0
  115. package/src/storage/components/ObjectInspector.tsx +169 -0
  116. package/src/storage/components/PathBreadcrumb.tsx +84 -0
  117. package/src/storage/components/UploadDropzone.tsx +182 -0
  118. package/src/storage/folderPlaceholder.ts +40 -0
  119. package/src/storage/hooks/index.ts +59 -0
  120. package/src/storage/hooks/useMetadataEditor.ts +329 -0
  121. package/src/storage/hooks/useObjectUpload.ts +262 -0
  122. package/src/storage/hooks/usePathState.ts +94 -0
  123. package/src/storage/hooks/useStorageDelete.ts +195 -0
  124. package/src/storage/hooks/useStorageList.ts +261 -0
  125. package/src/storage/hooks/useStorageObject.ts +162 -0
  126. package/src/storage/hooks/useStorageRulesGate.ts +270 -0
  127. package/src/storage/hooks/useStorageSelection.ts +90 -0
  128. package/src/storage/index.ts +59 -0
  129. package/src/storage/pendingPrefixes.ts +125 -0
  130. package/src/storage/previews.tsx +120 -0
  131. package/src/storage/storageApi.ts +54 -0
  132. package/src/traffic/components/RuleHeatmap.tsx +97 -0
  133. package/src/traffic/components/TrafficDetail.tsx +160 -0
  134. package/src/traffic/components/TrafficGroupRow.tsx +91 -0
  135. package/src/traffic/components/TrafficLineChart.tsx +139 -0
  136. package/src/traffic/components/TrafficLog.tsx +175 -0
  137. package/src/traffic/components/TrafficMetricCards.tsx +77 -0
  138. package/src/traffic/components/TrafficRow.tsx +69 -0
  139. package/src/traffic/components/TrafficStats.tsx +73 -0
  140. package/src/traffic/components/TrafficTimeline.tsx +289 -0
  141. package/src/traffic/components/format.ts +22 -0
  142. package/src/traffic/components/index.ts +22 -0
  143. package/src/traffic/hooks/index.ts +60 -0
  144. package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
  145. package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
  146. package/src/traffic/hooks/useTrafficFilter.ts +74 -0
  147. package/src/traffic/hooks/useTrafficGroups.ts +126 -0
  148. package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
  149. package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
  150. package/src/traffic/hooks/useTrafficStats.ts +77 -0
  151. package/src/traffic/index.ts +13 -0
  152. package/src/traffic/types.ts +85 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyric/ui",
3
- "version": "0.1.0-alpha.11",
3
+ "version": "0.1.0-alpha.12",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://pyric.dev",
6
6
  "repository": {
@@ -77,6 +77,7 @@
77
77
  },
78
78
  "files": [
79
79
  "dist",
80
+ "src",
80
81
  "README.md",
81
82
  "LICENSE"
82
83
  ],
@@ -92,7 +93,7 @@
92
93
  "react": ">=19",
93
94
  "react-dom": ">=19",
94
95
  "firebase": ">=12",
95
- "pyric": "^0.1.0-alpha.11"
96
+ "pyric": "^0.1.0-alpha.12"
96
97
  },
97
98
  "peerDependenciesMeta": {
98
99
  "@inbrowser/agent": {
@@ -108,8 +109,8 @@
108
109
  "fake-indexeddb": "^6.0.0",
109
110
  "firebase": "^12.12.0",
110
111
  "jsdom": "^29.1.1",
111
- "pyric": "^0.1.0-alpha.11",
112
- "pyric-admin": "^0.1.0-alpha.11",
112
+ "pyric": "^0.1.0-alpha.12",
113
+ "pyric-admin": "^0.1.0-alpha.12",
113
114
  "react": "^19.0.0",
114
115
  "react-dom": "^19.0.0",
115
116
  "react-test-renderer": "^19.2.6",
@@ -0,0 +1,514 @@
1
+ import type { CSSProperties, ReactNode } from 'react';
2
+ import type {
3
+ ContextWindowSnapshot,
4
+ SessionRequestUsage,
5
+ SessionTokenUsage,
6
+ } from '@inbrowser/agent/usage';
7
+
8
+ type ClassSlots = {
9
+ root?: string;
10
+ header?: string;
11
+ body?: string;
12
+ row?: string;
13
+ label?: string;
14
+ value?: string;
15
+ bar?: string;
16
+ segment?: string;
17
+ empty?: string;
18
+ };
19
+
20
+ export interface ContextWindowRingProps {
21
+ snapshot: ContextWindowSnapshot;
22
+ size?: number;
23
+ className?: string;
24
+ innerClassName?: string;
25
+ style?: CSSProperties;
26
+ }
27
+
28
+ export interface ContextWindowMeterProps {
29
+ snapshot: ContextWindowSnapshot;
30
+ onOpen?: () => void;
31
+ className?: string;
32
+ buttonClassName?: string;
33
+ tooltipClassName?: string;
34
+ ringClassName?: string;
35
+ ringInnerClassName?: string;
36
+ formatTokens?: (tokens: number) => string;
37
+ }
38
+
39
+ export interface TokenUsageInlineProps {
40
+ usage?: SessionTokenUsage;
41
+ className?: string;
42
+ labelClassName?: string;
43
+ valueClassName?: string;
44
+ formatTokens?: (tokens: number) => string;
45
+ }
46
+
47
+ export interface SessionSpendSummaryProps {
48
+ usage?: SessionTokenUsage;
49
+ currentContextTokens?: number;
50
+ className?: string;
51
+ slots?: ClassSlots;
52
+ formatTokens?: (tokens: number) => string;
53
+ }
54
+
55
+ export interface RequestUsageTimelineProps {
56
+ requests: readonly SessionRequestUsage[];
57
+ onOpenTool?: (messageId: string, callId: string) => void;
58
+ className?: string;
59
+ slots?: ClassSlots;
60
+ formatTokens?: (tokens: number) => string;
61
+ empty?: ReactNode;
62
+ }
63
+
64
+ export interface ContextWindowPanelProps {
65
+ snapshot: ContextWindowSnapshot;
66
+ onCompactNow?: () => void;
67
+ className?: string;
68
+ slots?: ClassSlots;
69
+ formatTokens?: (tokens: number) => string;
70
+ }
71
+
72
+ const STATUS_COLORS: Record<ContextWindowSnapshot['status'], string> = {
73
+ unknown: '#8b8b95',
74
+ low: '#a4d4a8',
75
+ medium: '#f0c36a',
76
+ high: '#f08a8a',
77
+ critical: '#ff5c7a',
78
+ };
79
+
80
+ export function ContextWindowRing({
81
+ snapshot,
82
+ size = 20,
83
+ className,
84
+ innerClassName,
85
+ style,
86
+ }: ContextWindowRingProps) {
87
+ const pct = snapshot.percentFull === undefined
88
+ ? 0.28
89
+ : Math.max(0, Math.min(1, snapshot.percentFull));
90
+ const degrees = Math.max(10, Math.round(pct * 360));
91
+ const color = `var(--pyric-context-window-status-color, ${STATUS_COLORS[snapshot.status]})`;
92
+ const track = 'var(--pyric-context-window-track-color, #3a3a45)';
93
+ const innerSize = Math.max(8, size - Math.max(6, Math.round(size * 0.34)));
94
+ return (
95
+ <span
96
+ data-pyric-ui="context-window-ring"
97
+ data-status={snapshot.status}
98
+ data-basis={snapshot.basis}
99
+ className={className}
100
+ style={{
101
+ display: 'inline-grid',
102
+ placeItems: 'center',
103
+ borderRadius: '999px',
104
+ width: size,
105
+ height: size,
106
+ background: `conic-gradient(${color} ${degrees}deg, ${track} ${degrees}deg)`,
107
+ ...style,
108
+ }}
109
+ aria-hidden="true"
110
+ >
111
+ <span
112
+ data-pyric-ui="context-window-ring-inner"
113
+ className={innerClassName}
114
+ style={{
115
+ display: 'block',
116
+ borderRadius: '999px',
117
+ width: innerSize,
118
+ height: innerSize,
119
+ background: 'var(--pyric-context-window-inner-color, currentColor)',
120
+ }}
121
+ />
122
+ </span>
123
+ );
124
+ }
125
+
126
+ export function ContextWindowMeter({
127
+ snapshot,
128
+ onOpen,
129
+ className,
130
+ buttonClassName,
131
+ tooltipClassName,
132
+ ringClassName,
133
+ ringInnerClassName,
134
+ formatTokens = formatCompactTokens,
135
+ }: ContextWindowMeterProps) {
136
+ const title = `Context window: ${formatPercent(snapshot)} · ${formatRatio(snapshot, formatTokens)}`;
137
+ return (
138
+ <span
139
+ data-pyric-ui="context-window-meter"
140
+ data-status={snapshot.status}
141
+ data-basis={snapshot.basis}
142
+ className={className}
143
+ >
144
+ <button
145
+ type="button"
146
+ onClick={onOpen}
147
+ className={buttonClassName}
148
+ aria-label="Open context window details"
149
+ title={title}
150
+ >
151
+ <ContextWindowRing
152
+ snapshot={snapshot}
153
+ size={20}
154
+ className={ringClassName}
155
+ innerClassName={ringInnerClassName}
156
+ />
157
+ </button>
158
+ <span
159
+ data-pyric-ui="context-window-meter-tooltip"
160
+ className={tooltipClassName}
161
+ >
162
+ <span data-pyric-ui="context-window-meter-percent">
163
+ {formatPercent(snapshot)}
164
+ </span>
165
+ <span data-pyric-ui="context-window-meter-ratio">
166
+ {formatRatio(snapshot, formatTokens)}
167
+ </span>
168
+ </span>
169
+ </span>
170
+ );
171
+ }
172
+
173
+ export function TokenUsageInline({
174
+ usage,
175
+ className,
176
+ labelClassName,
177
+ valueClassName,
178
+ formatTokens = formatCompactTokens,
179
+ }: TokenUsageInlineProps) {
180
+ if (!usage) return null;
181
+ return (
182
+ <dl data-pyric-ui="token-usage-inline" className={className}>
183
+ <MetricTerm
184
+ label="input"
185
+ value={formatTokens(usage.inputTokens)}
186
+ labelClassName={labelClassName}
187
+ valueClassName={valueClassName}
188
+ />
189
+ <MetricTerm
190
+ label="output"
191
+ value={formatTokens(usage.outputTokens)}
192
+ labelClassName={labelClassName}
193
+ valueClassName={valueClassName}
194
+ />
195
+ {usage.cachedInputTokens > 0 ? (
196
+ <MetricTerm
197
+ label="cached"
198
+ value={formatTokens(usage.cachedInputTokens)}
199
+ labelClassName={labelClassName}
200
+ valueClassName={valueClassName}
201
+ />
202
+ ) : null}
203
+ {usage.reasoningTokens > 0 ? (
204
+ <MetricTerm
205
+ label="reasoning"
206
+ value={formatTokens(usage.reasoningTokens)}
207
+ labelClassName={labelClassName}
208
+ valueClassName={valueClassName}
209
+ />
210
+ ) : null}
211
+ </dl>
212
+ );
213
+ }
214
+
215
+ export function SessionSpendSummary({
216
+ usage,
217
+ currentContextTokens,
218
+ className,
219
+ slots,
220
+ formatTokens = formatCompactTokens,
221
+ }: SessionSpendSummaryProps) {
222
+ if (!usage || usage.tokensTotal <= 0) return null;
223
+ const contextCopies =
224
+ usage.workMultiplier ??
225
+ (currentContextTokens && currentContextTokens > 0
226
+ ? usage.tokensTotal / currentContextTokens
227
+ : undefined);
228
+ return (
229
+ <section
230
+ data-pyric-ui="session-spend-summary"
231
+ className={className ?? slots?.root}
232
+ >
233
+ <div data-pyric-ui="session-spend-summary-header" className={slots?.header}>
234
+ <span data-pyric-ui="session-spend-summary-title">Overall session spend</span>
235
+ <span data-pyric-ui="session-spend-summary-total">
236
+ {formatTokens(usage.tokensTotal)}
237
+ </span>
238
+ </div>
239
+ <dl data-pyric-ui="session-spend-summary-body" className={slots?.body}>
240
+ <MetricTerm
241
+ label="turns"
242
+ value={usage.turns === null ? 'unknown' : usage.turns.toLocaleString()}
243
+ labelClassName={slots?.label}
244
+ valueClassName={slots?.value}
245
+ />
246
+ <MetricTerm
247
+ label="requests"
248
+ value={usage.requests === null ? 'unknown' : usage.requests.toLocaleString()}
249
+ labelClassName={slots?.label}
250
+ valueClassName={slots?.value}
251
+ />
252
+ <MetricTerm
253
+ label="average request"
254
+ value={usage.averageRequestTokens ? formatTokens(usage.averageRequestTokens) : 'n/a'}
255
+ labelClassName={slots?.label}
256
+ valueClassName={slots?.value}
257
+ />
258
+ <MetricTerm
259
+ label="context multiplier"
260
+ value={contextCopies ? `${formatMultiplier(contextCopies)}x` : 'n/a'}
261
+ labelClassName={slots?.label}
262
+ valueClassName={slots?.value}
263
+ />
264
+ </dl>
265
+ <TokenUsageInline
266
+ usage={usage}
267
+ className={slots?.row}
268
+ labelClassName={slots?.label}
269
+ valueClassName={slots?.value}
270
+ formatTokens={formatTokens}
271
+ />
272
+ </section>
273
+ );
274
+ }
275
+
276
+ export function RequestUsageTimeline({
277
+ requests,
278
+ onOpenTool,
279
+ className,
280
+ slots,
281
+ formatTokens = formatCompactTokens,
282
+ empty,
283
+ }: RequestUsageTimelineProps) {
284
+ if (requests.length === 0) {
285
+ return (
286
+ <div
287
+ data-pyric-ui="request-usage-timeline-empty"
288
+ className={slots?.empty ?? className}
289
+ >
290
+ {empty ?? 'No request usage rows available.'}
291
+ </div>
292
+ );
293
+ }
294
+ return (
295
+ <ol data-pyric-ui="request-usage-timeline" className={className ?? slots?.root}>
296
+ {requests.map((request) => (
297
+ <li
298
+ key={request.id}
299
+ data-pyric-ui="request-usage-row"
300
+ data-usage-source={request.usageSource}
301
+ data-provider={request.providerId}
302
+ className={slots?.row}
303
+ >
304
+ <div data-pyric-ui="request-usage-row-header" className={slots?.header}>
305
+ <span data-pyric-ui="request-usage-row-title">
306
+ Request {request.iteration + 1}
307
+ </span>
308
+ <span data-pyric-ui="request-usage-row-total">
309
+ {formatTokens(request.tokensTotal)}
310
+ </span>
311
+ </div>
312
+ <SegmentBar
313
+ rows={[
314
+ ['fresh-input', request.freshInputTokens, '#8bb7ff'],
315
+ ['cached-input', request.cachedInputTokens, '#a4d4a8'],
316
+ ['visible-output', request.visibleOutputTokens, '#f0c36a'],
317
+ ['reasoning-output', request.reasoningTokens, '#c9a7ff'],
318
+ ]}
319
+ total={Math.max(1, request.tokensTotal)}
320
+ barClassName={slots?.bar}
321
+ segmentClassName={slots?.segment}
322
+ />
323
+ <dl data-pyric-ui="request-usage-row-metrics" className={slots?.body}>
324
+ <MetricTerm
325
+ label="input"
326
+ value={formatTokens(request.inputTokens)}
327
+ labelClassName={slots?.label}
328
+ valueClassName={slots?.value}
329
+ />
330
+ <MetricTerm
331
+ label="output"
332
+ value={formatTokens(request.outputTokens)}
333
+ labelClassName={slots?.label}
334
+ valueClassName={slots?.value}
335
+ />
336
+ <MetricTerm
337
+ label="messages"
338
+ value={request.messageCount.toLocaleString()}
339
+ labelClassName={slots?.label}
340
+ valueClassName={slots?.value}
341
+ />
342
+ </dl>
343
+ {onOpenTool && request.emittedToolCalls.length > 0 ? (
344
+ <div data-pyric-ui="request-usage-tools">
345
+ {request.emittedToolCalls.map((tool) => (
346
+ <button
347
+ key={`${tool.messageId ?? 'unknown'}:${tool.callId ?? tool.name}`}
348
+ type="button"
349
+ data-pyric-ui="request-usage-tool"
350
+ disabled={!tool.messageId || !tool.callId}
351
+ onClick={() => {
352
+ if (tool.messageId && tool.callId) onOpenTool(tool.messageId, tool.callId);
353
+ }}
354
+ >
355
+ {tool.name}
356
+ </button>
357
+ ))}
358
+ </div>
359
+ ) : null}
360
+ </li>
361
+ ))}
362
+ </ol>
363
+ );
364
+ }
365
+
366
+ export function ContextWindowPanel({
367
+ snapshot,
368
+ onCompactNow,
369
+ className,
370
+ slots,
371
+ formatTokens = formatCompactTokens,
372
+ }: ContextWindowPanelProps) {
373
+ return (
374
+ <section
375
+ data-pyric-ui="context-window-panel"
376
+ data-status={snapshot.status}
377
+ data-basis={snapshot.basis}
378
+ className={className ?? slots?.root}
379
+ >
380
+ <header data-pyric-ui="context-window-panel-header" className={slots?.header}>
381
+ <ContextWindowRing snapshot={snapshot} size={56} />
382
+ <div>
383
+ <div data-pyric-ui="context-window-panel-percent">
384
+ {formatPercent(snapshot)}
385
+ </div>
386
+ <div data-pyric-ui="context-window-panel-ratio">
387
+ {formatRatio(snapshot, formatTokens)}
388
+ </div>
389
+ </div>
390
+ </header>
391
+ <SegmentBar
392
+ rows={snapshot.breakdown.map((row) => [row.id, row.tokens, row.color])}
393
+ total={Math.max(1, snapshot.usedTokens)}
394
+ barClassName={slots?.bar}
395
+ segmentClassName={slots?.segment}
396
+ />
397
+ <dl data-pyric-ui="context-window-breakdown" className={slots?.body}>
398
+ {snapshot.breakdown.map((row) => (
399
+ <MetricTerm
400
+ key={row.id}
401
+ label={row.label}
402
+ value={formatTokens(row.tokens)}
403
+ labelClassName={slots?.label}
404
+ valueClassName={slots?.value}
405
+ />
406
+ ))}
407
+ </dl>
408
+ <SessionSpendSummary
409
+ usage={snapshot.sessionUsage}
410
+ currentContextTokens={snapshot.usedTokens}
411
+ slots={slots}
412
+ formatTokens={formatTokens}
413
+ />
414
+ {onCompactNow ? (
415
+ <button
416
+ type="button"
417
+ data-pyric-ui="context-window-compact-action"
418
+ onClick={onCompactNow}
419
+ >
420
+ Compact now
421
+ </button>
422
+ ) : null}
423
+ </section>
424
+ );
425
+ }
426
+
427
+ function MetricTerm({
428
+ label,
429
+ value,
430
+ labelClassName,
431
+ valueClassName,
432
+ }: {
433
+ label: string;
434
+ value: string;
435
+ labelClassName?: string;
436
+ valueClassName?: string;
437
+ }) {
438
+ return (
439
+ <div data-pyric-ui="metric-term">
440
+ <dt data-pyric-ui="metric-label" className={labelClassName}>
441
+ {label}
442
+ </dt>
443
+ <dd data-pyric-ui="metric-value" className={valueClassName}>
444
+ {value}
445
+ </dd>
446
+ </div>
447
+ );
448
+ }
449
+
450
+ function SegmentBar({
451
+ rows,
452
+ total,
453
+ barClassName,
454
+ segmentClassName,
455
+ }: {
456
+ rows: readonly (readonly [id: string, tokens: number, color: string])[];
457
+ total: number;
458
+ barClassName?: string;
459
+ segmentClassName?: string;
460
+ }) {
461
+ const visibleRows = rows.filter(([, tokens]) => tokens > 0);
462
+ if (visibleRows.length === 0) return null;
463
+ return (
464
+ <div data-pyric-ui="token-segment-bar" className={barClassName}>
465
+ {visibleRows.map(([id, tokens, color]) => (
466
+ <span
467
+ key={id}
468
+ data-pyric-ui="token-segment"
469
+ data-segment={id}
470
+ className={segmentClassName}
471
+ style={{
472
+ display: 'inline-block',
473
+ width: `${Math.max(1, (tokens / total) * 100)}%`,
474
+ background: `var(--pyric-token-segment-${id}, ${color})`,
475
+ }}
476
+ />
477
+ ))}
478
+ </div>
479
+ );
480
+ }
481
+
482
+ function formatRatio(
483
+ snapshot: ContextWindowSnapshot,
484
+ formatTokens: (tokens: number) => string,
485
+ ): string {
486
+ const used = formatTokens(snapshot.usedTokens);
487
+ if (!snapshot.limitTokens) return `${used} tokens used`;
488
+ return `${used} / ${formatTokens(snapshot.limitTokens)} tokens used`;
489
+ }
490
+
491
+ function formatPercent(snapshot: ContextWindowSnapshot): string {
492
+ if (snapshot.percentFull === undefined) return 'limit unknown';
493
+ return `${Math.round(snapshot.percentFull * 100)}% full`;
494
+ }
495
+
496
+ function formatCompactTokens(tokens: number): string {
497
+ if (tokens < 1000) return String(Math.max(0, Math.round(tokens)));
498
+ if (tokens < 100_000) {
499
+ const k = tokens / 1000;
500
+ const value = k.toFixed(1);
501
+ return `${value.endsWith('.0') ? value.slice(0, -2) : value}k`;
502
+ }
503
+ if (tokens < 1_000_000) return `${Math.round(tokens / 1000)}k`;
504
+ // Millions tier — "14.59M", never "14586k".
505
+ const m = (tokens / 1_000_000).toFixed(2);
506
+ return `${m.endsWith('.00') ? m.slice(0, -3) : m}M`;
507
+ }
508
+
509
+ function formatMultiplier(value: number): string {
510
+ if (!Number.isFinite(value)) return 'n/a';
511
+ if (value < 10) return value.toFixed(1);
512
+ if (value < 100) return Math.round(value).toString();
513
+ return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
514
+ }
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface EmptyStateProps {
4
+ /** Optional pre-rendered icon node — consumers pass whatever icon
5
+ * primitive their app uses (Material Symbols span, lucide-react
6
+ * component, plain SVG, …). */
7
+ icon?: ReactNode;
8
+ title: ReactNode;
9
+ body?: ReactNode;
10
+ className?: string;
11
+ }
12
+
13
+ /**
14
+ * Headless zero-state. Structural only: icon slot, title, optional
15
+ * body, all wrapped in a flex container marked
16
+ * `[data-pyric-ui="empty-state"]`. Consumers attach all visual
17
+ * styling.
18
+ */
19
+ export function EmptyState({ icon, title, body, className }: EmptyStateProps) {
20
+ return (
21
+ <div data-pyric-ui="empty-state" className={className}>
22
+ {icon ? <span data-pyric-empty-icon>{icon}</span> : null}
23
+ <p data-pyric-empty-title>{title}</p>
24
+ {body ? <p data-pyric-empty-body>{body}</p> : null}
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,64 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export type FoldTone = 'normal' | 'error' | 'thought';
4
+
5
+ export interface FoldProps {
6
+ /** Visible summary line, always rendered. */
7
+ header: ReactNode;
8
+ /** Optional right-aligned action (e.g. a copy button). Children
9
+ * should `stopPropagation` so clicks don't toggle the fold. */
10
+ headerAction?: ReactNode;
11
+ /** Revealed when the user expands. */
12
+ children: ReactNode;
13
+ defaultOpen?: boolean;
14
+ /** Surfaced via `[data-pyric-fold-tone]` so consumers can tint the
15
+ * summary or border based on semantic context (error / thought).
16
+ * Defaults to `normal`. */
17
+ tone?: FoldTone;
18
+ /** Forwarded to the root `<details>` element. */
19
+ className?: string;
20
+ /** Forwarded to the `<summary>` element. */
21
+ summaryClassName?: string;
22
+ /** Forwarded to the body wrapper revealed when open. */
23
+ bodyClassName?: string;
24
+ }
25
+
26
+ /**
27
+ * Headless disclosure container — native `<details>` for keyboard /
28
+ * a11y. Ships zero visual styling. Consumers style via the three
29
+ * `className` slots or by selecting on:
30
+ *
31
+ * [data-pyric-ui="fold"] — root `<details>`
32
+ * [data-pyric-ui="fold"][open] — expanded state
33
+ * [data-pyric-fold-tone="error"] — error tint
34
+ * [data-pyric-fold-tone="thought"] — thought-stream tint
35
+ * [data-pyric-fold-summary] — clickable summary row
36
+ * [data-pyric-fold-chevron] — disclosure indicator slot
37
+ * [data-pyric-fold-body] — revealed body wrapper
38
+ */
39
+ export function Fold({
40
+ header,
41
+ headerAction,
42
+ children,
43
+ defaultOpen = false,
44
+ tone = 'normal',
45
+ className,
46
+ summaryClassName,
47
+ bodyClassName,
48
+ }: FoldProps) {
49
+ return (
50
+ <details
51
+ data-pyric-ui="fold"
52
+ data-pyric-fold-tone={tone}
53
+ open={defaultOpen}
54
+ className={className}
55
+ >
56
+ <summary data-pyric-fold-summary className={summaryClassName}>
57
+ <span data-pyric-fold-chevron aria-hidden="true" />
58
+ <span data-pyric-fold-header>{header}</span>
59
+ {headerAction ? <span data-pyric-fold-action>{headerAction}</span> : null}
60
+ </summary>
61
+ <div data-pyric-fold-body className={bodyClassName}>{children}</div>
62
+ </details>
63
+ );
64
+ }
@@ -0,0 +1,65 @@
1
+ import { useEffect, type ReactNode } from 'react';
2
+
3
+ export interface ModalProps {
4
+ open: boolean;
5
+ onClose: () => void;
6
+ children: ReactNode;
7
+ ariaLabel?: string;
8
+ /** Forwarded to the root wrapper (covers the full viewport). */
9
+ className?: string;
10
+ /** Forwarded to the backdrop element. */
11
+ backdropClassName?: string;
12
+ /** Forwarded to the inner panel. */
13
+ panelClassName?: string;
14
+ }
15
+
16
+ /**
17
+ * Headless modal — provides behavior only (Escape-to-close, backdrop
18
+ * click, `aria-modal`). Consumers attach all visual styling via the
19
+ * three `className` slots or by selecting on the emitted data
20
+ * attributes:
21
+ *
22
+ * [data-pyric-ui="modal"] — root viewport wrapper
23
+ * [data-pyric-modal-backdrop] — clickable backdrop
24
+ * [data-pyric-modal-panel] — inner panel containing children
25
+ */
26
+ export function Modal({
27
+ open,
28
+ onClose,
29
+ children,
30
+ ariaLabel,
31
+ className,
32
+ backdropClassName,
33
+ panelClassName,
34
+ }: ModalProps) {
35
+ useEffect(() => {
36
+ if (!open) return;
37
+ const handler = (e: KeyboardEvent) => {
38
+ if (e.key === 'Escape') onClose();
39
+ };
40
+ window.addEventListener('keydown', handler);
41
+ return () => window.removeEventListener('keydown', handler);
42
+ }, [open, onClose]);
43
+
44
+ if (!open) return null;
45
+
46
+ return (
47
+ <div
48
+ role="dialog"
49
+ aria-modal="true"
50
+ aria-label={ariaLabel}
51
+ data-pyric-ui="modal"
52
+ className={className}
53
+ >
54
+ <div
55
+ data-pyric-modal-backdrop
56
+ className={backdropClassName}
57
+ onClick={onClose}
58
+ aria-hidden="true"
59
+ />
60
+ <div data-pyric-modal-panel className={panelClassName}>
61
+ {children}
62
+ </div>
63
+ </div>
64
+ );
65
+ }