@xpufx/paseo-x-comms 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.
Files changed (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/client/attribution.ts +7 -0
  4. package/client/conversations.ts +229 -0
  5. package/client/main.tsx +885 -0
  6. package/client/peer-label.ts +98 -0
  7. package/client/peer-status.tsx +201 -0
  8. package/client/settings-prototype.tsx +395 -0
  9. package/client/tool-call.ts +115 -0
  10. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  11. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  12. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  13. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  14. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  15. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  16. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  17. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  18. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  19. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  20. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  21. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  22. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  23. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  24. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  25. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  26. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  27. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  28. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  29. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  30. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  31. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  32. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  33. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  34. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  35. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  36. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  37. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  38. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  39. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  40. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  41. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  42. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  43. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  44. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  45. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  46. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  47. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  48. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  49. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  50. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  51. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  52. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  53. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  54. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  55. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  56. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  57. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  58. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  59. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  60. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  61. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  62. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  63. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  64. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  65. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  66. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  67. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  68. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  69. package/client/via-x-comms.tsx +15 -0
  70. package/client/x-comms-conversation.tsx +449 -0
  71. package/client/x-comms-panel.tsx +6 -0
  72. package/client/x-comms-pill.tsx +193 -0
  73. package/client/x-comms-timeline.tsx +176 -0
  74. package/client/x-comms-tool-call.tsx +85 -0
  75. package/docs/mesh.md +115 -0
  76. package/mcp/LICENSE +202 -0
  77. package/mcp/README.md +287 -0
  78. package/mcp/mcp-config.example.json +8 -0
  79. package/mcp/package-lock.json +1186 -0
  80. package/mcp/package.json +43 -0
  81. package/mcp/paseo-cross-daemon-comms.example.json +5 -0
  82. package/mcp/paseo-x-comms.bundled.mjs +36964 -0
  83. package/mcp/paseo-x-comms.mjs +630 -0
  84. package/mcp/test/fixtures/extensions/block/10-block.mjs +4 -0
  85. package/mcp/test/fixtures/extensions/broken-hook/10-thrower.mjs +6 -0
  86. package/mcp/test/fixtures/extensions/broken-hook/20-good.mjs +4 -0
  87. package/mcp/test/fixtures/extensions/broken-load/10-thrower.mjs +4 -0
  88. package/mcp/test/fixtures/extensions/broken-load/20-good.mjs +4 -0
  89. package/mcp/test/fixtures/extensions/custom-tool/10-tool.mjs +12 -0
  90. package/mcp/test/fixtures/extensions/tool-block/10-block.mjs +8 -0
  91. package/mcp/test/fixtures/extensions/transform/10-transform.mjs +15 -0
  92. package/mcp/test/fixtures/fake-paseo.mjs +92 -0
  93. package/mcp/test/register-ts-hooks.mjs +6 -0
  94. package/mcp/test/resolve-ts-hooks.mjs +46 -0
  95. package/package.json +58 -0
  96. package/paseo-plugin.json +7 -0
  97. package/server/conversations-snapshot.ts +249 -0
  98. package/server/handlers.ts +1081 -0
  99. package/server/injection.ts +152 -0
  100. package/server/local-send.ts +153 -0
  101. package/server/mcp-client.ts +47 -0
  102. package/server/outbox.ts +233 -0
  103. package/server/peer-channel.ts +151 -0
  104. package/server/peer-status.ts +233 -0
  105. package/server/presence.ts +204 -0
  106. package/server/registry.ts +215 -0
  107. package/server/relay-status.ts +9 -0
  108. package/server/server-status.ts +138 -0
  109. package/server/settings.ts +81 -0
  110. package/server/snapshot.ts +176 -0
  111. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  112. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  113. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  114. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  115. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  116. package/server/vendor/paseo-plugin-helper/mcp/client.ts +349 -0
  117. package/server/vendor/paseo-plugin-helper/mcp/http-client.ts +399 -0
  118. package/server/vendor/paseo-plugin-helper/mcp/index.ts +5 -0
  119. package/server/vendor/paseo-plugin-helper/mcp/process-killer.ts +65 -0
  120. package/server/vendor/paseo-plugin-helper/mcp/ring-buffer.ts +29 -0
  121. package/server/vendor/paseo-plugin-helper/mcp/types.ts +41 -0
  122. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  123. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  124. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  125. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  126. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  127. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  128. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  129. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  130. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  131. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  132. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  133. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  134. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  135. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  136. package/shared/conversations-snapshot.ts +39 -0
  137. package/shared/envelope.ts +92 -0
  138. package/shared/outbox.ts +21 -0
  139. package/shared/registry.ts +366 -0
  140. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  141. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  142. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  143. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  144. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  145. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  146. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  147. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  148. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  149. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  150. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  151. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  152. package/shared/version.ts +2 -0
@@ -0,0 +1,70 @@
1
+ import React, { useMemo } from "react";
2
+ import { Text, type StyleProp, type TextStyle } from "react-native";
3
+ import { splitHighlightParts } from "../../../../shared/vendor/paseo-plugin-helper/highlight";
4
+ import { usePluginTheme } from "../theme/provider";
5
+
6
+ export interface HighlightedTextProps {
7
+ /** Source text rendered as-is when no query is active. */
8
+ text: string;
9
+ /**
10
+ * Active search query; matched case-insensitively and never as a regex. When
11
+ * the literal query is absent the query's tokens are highlighted, so a fuzzy
12
+ * result still reads.
13
+ */
14
+ query: string;
15
+ style?: StyleProp<TextStyle>;
16
+ /**
17
+ * Overrides the matched-run style. Defaults to the theme accent background
18
+ * with `accentForeground` text so a match reads as selected.
19
+ */
20
+ highlightStyle?: StyleProp<TextStyle>;
21
+ numberOfLines?: number;
22
+ selectable?: boolean;
23
+ /**
24
+ * When true, a query with no literal or token hit marks the whole text, so a
25
+ * fuzzy search result still reads as matched. Use it on the primary label
26
+ * (row/detail title) rather than every small field.
27
+ */
28
+ fuzzyFallback?: boolean;
29
+ }
30
+
31
+ /**
32
+ * `<Text>` that paints every case-insensitive occurrence of `query` with the
33
+ * accent background/foreground. The query is matched literally, so user input
34
+ * is never evaluated as a regular expression. When the literal query is absent
35
+ * the splitter highlights the query's tokens; `fuzzyFallback` additionally
36
+ * marks the whole text when even a token misses, so a fuzzy result is not left
37
+ * silently unmarked. Renders the plain text when the query is empty or absent.
38
+ */
39
+ export function HighlightedText({
40
+ text,
41
+ query,
42
+ style,
43
+ highlightStyle,
44
+ numberOfLines,
45
+ selectable,
46
+ fuzzyFallback = false,
47
+ }: HighlightedTextProps) {
48
+ const { colors } = usePluginTheme();
49
+ const parts = useMemo(
50
+ () => splitHighlightParts(text, query, { fallbackToWholeField: fuzzyFallback }),
51
+ [text, query, fuzzyFallback],
52
+ );
53
+ const matchStyle: StyleProp<TextStyle> = [
54
+ { backgroundColor: colors.accent, color: colors.accentForeground },
55
+ highlightStyle,
56
+ ];
57
+ return (
58
+ <Text style={style} numberOfLines={numberOfLines} selectable={selectable}>
59
+ {parts.map((part, index) =>
60
+ part.matched ? (
61
+ <Text key={`m${index}`} style={matchStyle}>
62
+ {part.text}
63
+ </Text>
64
+ ) : (
65
+ part.text
66
+ ),
67
+ )}
68
+ </Text>
69
+ );
70
+ }
@@ -0,0 +1,73 @@
1
+ import React, { type ReactNode } from "react";
2
+ import {
3
+ Pressable,
4
+ StyleSheet,
5
+ Text,
6
+ type StyleProp,
7
+ type TextStyle,
8
+ type ViewStyle,
9
+ } from "react-native";
10
+ import { getClientHost } from "../host";
11
+ import { usePluginTheme } from "../theme/provider";
12
+
13
+ export interface InlineButtonProps {
14
+ label: string;
15
+ onPress?: () => void | Promise<void>;
16
+ icon?: string | ReactNode;
17
+ disabled?: boolean;
18
+ accessibilityLabel?: string;
19
+ style?: StyleProp<ViewStyle>;
20
+ textStyle?: StyleProp<TextStyle>;
21
+ }
22
+
23
+ /** Compact text/link action for inline cards and timeline content. */
24
+ export function InlineButton({
25
+ label,
26
+ onPress,
27
+ icon,
28
+ disabled = false,
29
+ accessibilityLabel,
30
+ style,
31
+ textStyle,
32
+ }: InlineButtonProps) {
33
+ const { Icon } = getClientHost();
34
+ const { colors, touchTargetMin, alpha } = usePluginTheme();
35
+
36
+ return (
37
+ <Pressable
38
+ onPress={onPress}
39
+ disabled={disabled}
40
+ accessibilityRole="button"
41
+ accessibilityLabel={accessibilityLabel || label}
42
+ hitSlop={Math.max(0, (touchTargetMin - 24) / 2)}
43
+ style={({ pressed }) => [
44
+ styles.base,
45
+ {
46
+ opacity: disabled ? 0.45 : 1,
47
+ backgroundColor: pressed && !disabled ? alpha(colors.accent, 0.12) : "transparent",
48
+ },
49
+ style,
50
+ ]}
51
+ >
52
+ {typeof icon === "string" ? <Icon name={icon} size={13} color={colors.accent} /> : icon}
53
+ <Text style={[styles.text, { color: colors.accent }, textStyle]}>{label}</Text>
54
+ </Pressable>
55
+ );
56
+ }
57
+
58
+ const styles = StyleSheet.create({
59
+ base: {
60
+ alignSelf: "flex-start",
61
+ flexDirection: "row",
62
+ alignItems: "center",
63
+ gap: 4,
64
+ minHeight: 24,
65
+ paddingHorizontal: 3,
66
+ paddingVertical: 2,
67
+ borderRadius: 4,
68
+ },
69
+ text: {
70
+ fontSize: 12,
71
+ fontWeight: "600",
72
+ },
73
+ });
@@ -0,0 +1,446 @@
1
+ import React, { useState, type ReactNode } from "react";
2
+ import {
3
+ Platform,
4
+ Pressable,
5
+ StyleSheet,
6
+ Text,
7
+ View,
8
+ type StyleProp,
9
+ type TextStyle,
10
+ type ViewStyle,
11
+ } from "react-native";
12
+ import { getClientHost } from "../host";
13
+ import { usePluginTheme } from "../theme/provider";
14
+ import { resolveGridColumns } from "../theme/responsive";
15
+ import { spacing } from "../theme/tokens";
16
+ import { copyToClipboard } from "../utils/clipboard";
17
+ import {
18
+ truncate,
19
+ truncateMiddle,
20
+ truncatePath,
21
+ TruncatePathOptions,
22
+ } from "../../../../shared/vendor/paseo-plugin-helper/formatters";
23
+
24
+ export type KeyValueTruncateMode = "end" | "middle" | "path";
25
+
26
+ export interface KeyValueProps {
27
+ label: string;
28
+ value: string | number | null | undefined;
29
+ subValue?: string;
30
+ mono?: boolean;
31
+ copyable?: boolean;
32
+ /** Truncate long value: "middle" (UUIDs/hashes), "path" (filepaths), or "end" (standard) */
33
+ truncate?: boolean | KeyValueTruncateMode;
34
+ /** Maximum length before truncation applies. Default: 32 */
35
+ truncateMaxLength?: number;
36
+ /** Custom options when truncate="path" */
37
+ truncatePathOptions?: TruncatePathOptions;
38
+ /**
39
+ * "stacked" (default) keeps the existing label-above-value layout.
40
+ * "inline" renders label and value on one line, with the value truncating
41
+ * middle so the row stays a single text line.
42
+ */
43
+ layout?: "stacked" | "inline";
44
+ stackOnCompact?: boolean;
45
+ style?: StyleProp<ViewStyle>;
46
+ labelStyle?: StyleProp<TextStyle>;
47
+ valueStyle?: StyleProp<TextStyle>;
48
+ }
49
+
50
+ export function KeyValue({
51
+ label,
52
+ value,
53
+ subValue,
54
+ mono = false,
55
+ copyable = false,
56
+ truncate: truncateProp = false,
57
+ truncateMaxLength = 32,
58
+ truncatePathOptions,
59
+ layout = "stacked",
60
+ stackOnCompact = true,
61
+ style,
62
+ labelStyle,
63
+ valueStyle,
64
+ }: KeyValueProps) {
65
+ const { Icon, useToast } = getClientHost();
66
+ const { colors, flair, isCompact, touchTargetMin, fonts, typography } = usePluginTheme();
67
+ const toast = useToast();
68
+ const [copied, setCopied] = useState(false);
69
+
70
+ const rawString = value === null || value === undefined ? "" : String(value);
71
+
72
+ let displayValue = rawString || "-";
73
+ if (truncateProp && rawString.length > truncateMaxLength) {
74
+ const mode: KeyValueTruncateMode =
75
+ typeof truncateProp === "string" ? truncateProp : "middle";
76
+ switch (mode) {
77
+ case "path":
78
+ displayValue = truncatePath(rawString, truncateMaxLength, truncatePathOptions);
79
+ break;
80
+ case "end":
81
+ displayValue = truncate(rawString, truncateMaxLength);
82
+ break;
83
+ case "middle":
84
+ default:
85
+ displayValue = truncateMiddle(rawString, truncateMaxLength);
86
+ break;
87
+ }
88
+ }
89
+
90
+ const handleCopy = async () => {
91
+ if (!copyable || !rawString) return;
92
+ const ok = await copyToClipboard(rawString, {
93
+ toast,
94
+ toastMessage: label,
95
+ });
96
+ if (ok) {
97
+ setCopied(true);
98
+ setTimeout(() => setCopied(false), 2000);
99
+ }
100
+ };
101
+
102
+ const fontFamily = mono
103
+ ? fonts.mono ?? Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" })
104
+ : undefined;
105
+
106
+ const shouldStack = stackOnCompact && isCompact;
107
+
108
+ const copyButton = copyable && value ? (
109
+ <Pressable
110
+ onPress={handleCopy}
111
+ hitSlop={Math.max(8, (touchTargetMin - 20) / 2)}
112
+ style={styles.copyBtn}
113
+ accessibilityRole="button"
114
+ accessibilityLabel={`Copy ${label}`}
115
+ >
116
+ <Icon
117
+ name={copied ? "Check" : "Copy"}
118
+ size={isCompact ? 12 : 13}
119
+ color={copied ? colors.statusSuccess : colors.foregroundMuted}
120
+ />
121
+ </Pressable>
122
+ ) : null;
123
+
124
+ if (layout === "inline") {
125
+ return (
126
+ <View
127
+ style={[
128
+ styles.container,
129
+ styles.inlineContainer,
130
+ { paddingVertical: isCompact ? spacing.xs : spacing.sm },
131
+ style,
132
+ ]}
133
+ >
134
+ <Text
135
+ style={[
136
+ styles.inlineLabel,
137
+ {
138
+ color: colors.foregroundMuted,
139
+ ...typography.label,
140
+ textTransform: flair.headingTransform === "uppercase" ? "uppercase" : "none",
141
+ },
142
+ labelStyle,
143
+ ]}
144
+ >
145
+ {label}
146
+ </Text>
147
+
148
+ <Text
149
+ selectable
150
+ numberOfLines={1}
151
+ ellipsizeMode="middle"
152
+ style={[
153
+ styles.inlineValueText,
154
+ {
155
+ color: colors.foreground,
156
+ ...typography.bodySmall,
157
+ fontFamily,
158
+ },
159
+ valueStyle,
160
+ ]}
161
+ >
162
+ {displayValue}
163
+ </Text>
164
+
165
+ {subValue ? (
166
+ <Text
167
+ selectable
168
+ numberOfLines={1}
169
+ style={[
170
+ styles.inlineSubValue,
171
+ {
172
+ color: colors.foregroundMuted,
173
+ ...typography.caption,
174
+ },
175
+ ]}
176
+ >
177
+ {subValue}
178
+ </Text>
179
+ ) : null}
180
+
181
+ {copyButton}
182
+ </View>
183
+ );
184
+ }
185
+
186
+ if (shouldStack) {
187
+ return (
188
+ <View
189
+ style={[
190
+ styles.container,
191
+ styles.stackedContainer,
192
+ { paddingVertical: isCompact ? spacing.xs : spacing.sm },
193
+ style,
194
+ ]}
195
+ >
196
+ <View style={styles.stackedHeaderRow}>
197
+ <Text
198
+ style={[
199
+ styles.label,
200
+ {
201
+ color: colors.foregroundMuted,
202
+ ...typography.label,
203
+ textTransform: flair.headingTransform === "uppercase" ? "uppercase" : "none",
204
+ },
205
+ labelStyle,
206
+ ]}
207
+ >
208
+ {label}
209
+ </Text>
210
+ {copyButton}
211
+ </View>
212
+
213
+ <Text
214
+ selectable
215
+ style={[
216
+ styles.stackedValueText,
217
+ {
218
+ color: colors.foreground,
219
+ ...typography.bodySmall,
220
+ fontFamily,
221
+ },
222
+ valueStyle,
223
+ ]}
224
+ >
225
+ {displayValue}
226
+ </Text>
227
+
228
+ {subValue ? (
229
+ <Text
230
+ style={[
231
+ styles.subValue,
232
+ {
233
+ color: colors.foregroundMuted,
234
+ ...typography.caption,
235
+ },
236
+ ]}
237
+ >
238
+ {subValue}
239
+ </Text>
240
+ ) : null}
241
+ </View>
242
+ );
243
+ }
244
+
245
+ // Horizontal layout for Desktop / Wide screens
246
+ return (
247
+ <View
248
+ style={[
249
+ styles.container,
250
+ styles.rowContainer,
251
+ { paddingVertical: isCompact ? spacing.xs : spacing.sm },
252
+ style,
253
+ ]}
254
+ >
255
+ <Text
256
+ style={[
257
+ styles.label,
258
+ {
259
+ color: colors.foregroundMuted,
260
+ ...typography.label,
261
+ textTransform: flair.headingTransform === "uppercase" ? "uppercase" : "none",
262
+ },
263
+ labelStyle,
264
+ ]}
265
+ >
266
+ {label}
267
+ </Text>
268
+
269
+ <View style={styles.rowValueWrapper}>
270
+ <View style={styles.rowValueLine}>
271
+ <Text
272
+ selectable
273
+ style={[
274
+ styles.rowValueText,
275
+ {
276
+ color: colors.foreground,
277
+ ...typography.bodySmall,
278
+ fontFamily,
279
+ },
280
+ valueStyle,
281
+ ]}
282
+ >
283
+ {displayValue}
284
+ </Text>
285
+
286
+ {copyButton}
287
+ </View>
288
+
289
+ {subValue ? (
290
+ <Text
291
+ style={[
292
+ styles.subValue,
293
+ {
294
+ color: colors.foregroundMuted,
295
+ ...typography.caption,
296
+ },
297
+ ]}
298
+ >
299
+ {subValue}
300
+ </Text>
301
+ ) : null}
302
+ </View>
303
+ </View>
304
+ );
305
+ }
306
+
307
+ export interface KeyValueGroupProps {
308
+ children: ReactNode;
309
+ columns?: 1 | 2 | 3 | 4;
310
+ gap?: number;
311
+ /**
312
+ * How a compact surface treats the column count.
313
+ * - "compact" (default): collapse to a single column on a compact surface —
314
+ * the historical behavior.
315
+ * - "never": keep the requested column count on a compact surface.
316
+ */
317
+ collapse?: "compact" | "never";
318
+ /**
319
+ * Minimum width a column should keep. When set and the container width is
320
+ * known, the effective column count is capped so each column stays at least
321
+ * this wide, wrapping to fewer columns rather than collapsing to one.
322
+ * `columns` remains the upper bound.
323
+ */
324
+ minColumnWidth?: number;
325
+ style?: StyleProp<ViewStyle>;
326
+ }
327
+
328
+ export function KeyValueGroup({
329
+ children,
330
+ columns = 2,
331
+ gap = spacing.md,
332
+ collapse = "compact",
333
+ minColumnWidth,
334
+ style,
335
+ }: KeyValueGroupProps) {
336
+ const { isCompact, layout } = usePluginTheme();
337
+ const effectiveColumns = resolveGridColumns({
338
+ columns,
339
+ gap,
340
+ width: layout.width,
341
+ minColumnWidth,
342
+ collapse,
343
+ isCompact,
344
+ });
345
+
346
+ const childArray = React.Children.toArray(children).filter(Boolean);
347
+
348
+ return (
349
+ <View style={[styles.groupContainer, { gap }, style]}>
350
+ {childArray.map((child, index) => (
351
+ <View
352
+ key={index}
353
+ style={{
354
+ flexGrow: 1,
355
+ flexShrink: 1,
356
+ flexBasis: `${Math.floor(100 / effectiveColumns) - 2}%`,
357
+ }}
358
+ >
359
+ {child}
360
+ </View>
361
+ ))}
362
+ </View>
363
+ );
364
+ }
365
+
366
+ const styles = StyleSheet.create({
367
+ container: {
368
+ width: "100%",
369
+ },
370
+ rowContainer: {
371
+ flexDirection: "row",
372
+ alignItems: "flex-start",
373
+ justifyContent: "space-between",
374
+ gap: 12,
375
+ },
376
+ stackedContainer: {
377
+ flexDirection: "column",
378
+ gap: 3,
379
+ width: "100%",
380
+ },
381
+ stackedHeaderRow: {
382
+ flexDirection: "row",
383
+ alignItems: "center",
384
+ justifyContent: "space-between",
385
+ width: "100%",
386
+ },
387
+ stackedValueText: {
388
+ width: "100%",
389
+ },
390
+ inlineContainer: {
391
+ flexDirection: "row",
392
+ alignItems: "center",
393
+ gap: spacing.xs,
394
+ minWidth: 0,
395
+ },
396
+ inlineLabel: {
397
+ flexShrink: 0,
398
+ },
399
+ inlineValueText: {
400
+ flexShrink: 1,
401
+ minWidth: 0,
402
+ },
403
+ inlineSubValue: {
404
+ flexShrink: 0,
405
+ },
406
+ rowValueWrapper: {
407
+ flexDirection: "column",
408
+ alignItems: "flex-end",
409
+ flexGrow: 1,
410
+ flexShrink: 1,
411
+ minWidth: 0,
412
+ gap: 2,
413
+ },
414
+ rowValueLine: {
415
+ flexDirection: "row",
416
+ alignItems: "center",
417
+ justifyContent: "flex-end",
418
+ gap: 6,
419
+ flexShrink: 1,
420
+ minWidth: 0,
421
+ maxWidth: "100%",
422
+ },
423
+ rowValueText: {
424
+ flexShrink: 1,
425
+ minWidth: 0,
426
+ textAlign: "right",
427
+ },
428
+ groupContainer: {
429
+ flexDirection: "row",
430
+ flexWrap: "wrap",
431
+ width: "100%",
432
+ },
433
+ label: {
434
+ flexShrink: 1,
435
+ minWidth: 0,
436
+ },
437
+ subValue: {
438
+ flexShrink: 1,
439
+ minWidth: 0,
440
+ },
441
+ copyBtn: {
442
+ padding: 3,
443
+ alignItems: "center",
444
+ justifyContent: "center",
445
+ },
446
+ });