@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,271 @@
1
+ import type { StatusVariant } from "./types";
2
+
3
+ export interface FormatBytesOptions {
4
+ /**
5
+ * Number of decimal places (default: 1).
6
+ */
7
+ decimals?: number;
8
+
9
+ /**
10
+ * If true, produces compact format with no space and single-character suffix
11
+ * (e.g. "5.3G", "320M", "1.2K"). Ideal for width-constrained composer pills.
12
+ */
13
+ compact?: boolean;
14
+
15
+ /**
16
+ * Fixes output unit (e.g. "GB" or "MB") regardless of value size.
17
+ */
18
+ fixedUnit?: "B" | "KB" | "MB" | "GB" | "TB";
19
+ }
20
+
21
+ /**
22
+ * Formats a raw byte count into a human-readable string.
23
+ * Supports standard ("1.5 GB", "320 KB") and compact ("1.5G", "320K") formats.
24
+ */
25
+ export function formatBytes(
26
+ bytes: number,
27
+ optionsOrDecimals: FormatBytesOptions | number = 1,
28
+ ): string {
29
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
30
+
31
+ const options: FormatBytesOptions =
32
+ typeof optionsOrDecimals === "number"
33
+ ? { decimals: optionsOrDecimals }
34
+ : optionsOrDecimals;
35
+
36
+ const { decimals = 1, compact = false, fixedUnit } = options;
37
+ const k = 1024;
38
+ const dm = decimals < 0 ? 0 : decimals;
39
+ const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
40
+ const compactSizes = ["B", "K", "M", "G", "T", "P"];
41
+
42
+ let unitIndex = Math.floor(Math.log(bytes) / Math.log(k));
43
+ if (fixedUnit) {
44
+ const found = sizes.indexOf(fixedUnit);
45
+ if (found !== -1) unitIndex = found;
46
+ }
47
+
48
+ const clampedIndex = Math.max(0, Math.min(unitIndex, sizes.length - 1));
49
+ const value = bytes / Math.pow(k, clampedIndex);
50
+
51
+ if (compact) {
52
+ return `${value.toFixed(dm)}${compactSizes[clampedIndex]}`;
53
+ }
54
+
55
+ return `${value.toFixed(dm)} ${sizes[clampedIndex]}`;
56
+ }
57
+
58
+ export interface MetricThresholds {
59
+ /**
60
+ * Threshold for warning status (default: 75).
61
+ */
62
+ warning?: number;
63
+
64
+ /**
65
+ * Threshold for danger status (default: 90).
66
+ */
67
+ danger?: number;
68
+
69
+ /**
70
+ * Inverts logic: lower values become worse (e.g. battery level, disk free space).
71
+ */
72
+ invert?: boolean;
73
+ }
74
+
75
+ /**
76
+ * Evaluates a numeric percentage metric (0 - 100) against warning and danger thresholds.
77
+ */
78
+ export function resolveMetricStatus(
79
+ value: number,
80
+ thresholds: MetricThresholds = {},
81
+ ): StatusVariant {
82
+ const { warning = 75, danger = 90, invert = false } = thresholds;
83
+
84
+ if (!invert) {
85
+ if (value >= danger) return "danger";
86
+ if (value >= warning) return "warning";
87
+ return "success";
88
+ } else {
89
+ if (value <= danger) return "danger";
90
+ if (value <= warning) return "warning";
91
+ return "success";
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Formats uptime seconds into concise human-readable duration (e.g. "3d 4h 12m", "45m").
97
+ */
98
+ export function formatUptime(seconds: number): string {
99
+ if (!Number.isFinite(seconds) || seconds <= 0) return "0m";
100
+ const days = Math.floor(seconds / 86400);
101
+ const hours = Math.floor((seconds % 86400) / 3600);
102
+ const minutes = Math.floor((seconds % 3600) / 60);
103
+ const secs = Math.floor(seconds % 60);
104
+
105
+ if (days > 0) return `${days}d ${hours}h ${minutes}m`;
106
+ if (hours > 0) return `${hours}h ${minutes}m`;
107
+ if (minutes > 0) return `${minutes}m`;
108
+ return `${secs}s`;
109
+ }
110
+
111
+ /**
112
+ * Formats milliseconds into human-readable latency or duration (e.g. "45ms", "1.2s", "3m 12s").
113
+ */
114
+ export function formatDuration(ms: number): string {
115
+ if (!Number.isFinite(ms) || ms < 0) return "0ms";
116
+ if (ms < 1000) return `${Math.round(ms)}ms`;
117
+ const seconds = ms / 1000;
118
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
119
+ return formatUptime(Math.round(seconds));
120
+ }
121
+
122
+ /**
123
+ * Formats a number with comma separators (e.g. 1,234,567).
124
+ */
125
+ export function formatNumber(num: number): string {
126
+ if (!Number.isFinite(num)) return "0";
127
+ return new Intl.NumberFormat("en-US").format(num);
128
+ }
129
+
130
+ export interface FormatCompactNumberOptions {
131
+ /** Number of decimal places when abbreviated (default: 1). */
132
+ decimals?: number;
133
+ /** Minimum threshold before abbreviating with K/M/B suffixes (default: 1000). */
134
+ threshold?: number;
135
+ }
136
+
137
+ /**
138
+ * Formats a count or token number into a compact string (e.g. 950 -> "950", 1250 -> "1.3k", 1450000 -> "1.5M").
139
+ * Ideal for pills, vitals chips, and narrow timeline badges.
140
+ */
141
+ export function formatCompactNumber(
142
+ num: number,
143
+ options: FormatCompactNumberOptions = {},
144
+ ): string {
145
+ if (!Number.isFinite(num)) return "0";
146
+ const { decimals = 1, threshold = 1000 } = options;
147
+ const abs = Math.abs(num);
148
+ if (abs < threshold) return `${Math.round(num)}`;
149
+ const sign = num < 0 ? "-" : "";
150
+ if (abs >= 1_000_000_000) {
151
+ const val = (abs / 1_000_000_000).toFixed(decimals);
152
+ return `${sign}${val.replace(/\.0$/, "")}B`;
153
+ }
154
+ if (abs >= 1_000_000) {
155
+ const val = (abs / 1_000_000).toFixed(decimals);
156
+ return `${sign}${val.replace(/\.0$/, "")}M`;
157
+ }
158
+ const val = (abs / 1_000).toFixed(decimals);
159
+ return `${sign}${val.replace(/\.0$/, "")}k`;
160
+ }
161
+
162
+ export interface TruncateOptions {
163
+ /** Ellipsis token to insert. Default: "…" */
164
+ ellipsis?: string;
165
+ }
166
+
167
+ export interface TruncatePathOptions extends TruncateOptions {
168
+ /** Directory separator. Default: "/" */
169
+ separator?: string;
170
+ /** Keep leading path segments (e.g. 1 keeps the root/first folder). Default: 1 */
171
+ keepLeading?: number;
172
+ /** Keep trailing path segments (e.g. 1 keeps the filename). Default: 1 */
173
+ keepTrailing?: number;
174
+ }
175
+
176
+ /**
177
+ * Safely truncates a string with an ellipsis if it exceeds maxLength.
178
+ */
179
+ export function truncate(text: string, maxLength: number, suffix = "…"): string {
180
+ if (!text || text.length <= maxLength) return text;
181
+ return text.slice(0, Math.max(0, maxLength - suffix.length)) + suffix;
182
+ }
183
+
184
+ /**
185
+ * Truncates a string in the middle, preserving distinct head and tail characters.
186
+ * Ideal for UUIDs, commit SHAs, hashes, cryptographic keys, and long identifiers.
187
+ *
188
+ * Example: `truncateMiddle("0359a72f-5b58-453b-a35b-956a3f6908ba", 16)` => `"0359a72…6908ba"`
189
+ */
190
+ export function truncateMiddle(text: string, maxLength: number, options?: TruncateOptions): string {
191
+ if (!text || text.length <= maxLength) return text;
192
+ const ellipsis = options?.ellipsis ?? "…";
193
+ if (maxLength <= ellipsis.length) return ellipsis.slice(0, maxLength);
194
+
195
+ const available = maxLength - ellipsis.length;
196
+ const headLength = Math.floor(available / 2);
197
+ const tailLength = available - headLength;
198
+
199
+ const head = text.slice(0, headLength);
200
+ const tail = tailLength > 0 ? text.slice(-tailLength) : "";
201
+ return `${head}${ellipsis}${tail}`;
202
+ }
203
+
204
+ /**
205
+ * Truncates file system and URL paths intelligently, preserving the leaf filename
206
+ * and root directory while compressing intermediate parent directories.
207
+ *
208
+ * Example: `truncatePath("~/code/paseo-plugin-helper/src/client/approvals.tsx", 35)`
209
+ * => `"~/code/…/src/client/approvals.tsx"`
210
+ */
211
+ export function truncatePath(filePath: string, maxLength: number, options?: TruncatePathOptions): string {
212
+ if (!filePath || filePath.length <= maxLength) return filePath;
213
+
214
+ const sep = options?.separator ?? "/";
215
+ const ellipsis = options?.ellipsis ?? "…";
216
+ let keepLeading = options?.keepLeading ?? 1;
217
+ const keepTrailing = options?.keepTrailing ?? 1;
218
+
219
+ // If path starts with separator (e.g. /home/...), parts[0] is empty, so adjust keepLeading
220
+ if (filePath.startsWith(sep) && keepLeading === 1) {
221
+ keepLeading = 2; // Keep ["", "home"] which joins to "/home"
222
+ }
223
+
224
+ const parts = filePath.split(sep);
225
+ // If no path separators or very few segments, fallback to truncateMiddle
226
+ if (parts.length <= keepLeading + keepTrailing) {
227
+ return truncateMiddle(filePath, maxLength, { ellipsis });
228
+ }
229
+
230
+ const prefixParts = parts.slice(0, keepLeading);
231
+ const suffixParts = parts.slice(parts.length - keepTrailing);
232
+ let middleParts = parts.slice(keepLeading, parts.length - keepTrailing);
233
+
234
+ // Progressive compression: drop elements from start of middle until it fits
235
+ while (middleParts.length > 0) {
236
+ const candidate = [...prefixParts, ellipsis, ...middleParts, ...suffixParts].join(sep);
237
+ if (candidate.length <= maxLength) {
238
+ return candidate;
239
+ }
240
+ middleParts.shift();
241
+ }
242
+
243
+ // If still too long with full ellipsis, just join prefix + ellipsis + suffix
244
+ const minimalCandidate = [...prefixParts, ellipsis, ...suffixParts].join(sep);
245
+ if (minimalCandidate.length <= maxLength) {
246
+ return minimalCandidate;
247
+ }
248
+
249
+ // If even prefix + ellipsis + filename exceeds maxLength, middle-truncate the filename
250
+ const filename = suffixParts.join(sep);
251
+ const prefix = prefixParts.join(sep);
252
+ const availableForFile = maxLength - prefix.length - sep.length - ellipsis.length - sep.length;
253
+ if (availableForFile > 4) {
254
+ return `${prefix}${sep}${ellipsis}${sep}${truncateMiddle(filename, availableForFile, { ellipsis })}`;
255
+ }
256
+
257
+ return truncateMiddle(filePath, maxLength, { ellipsis });
258
+ }
259
+
260
+ /**
261
+ * Strips ANSI escape sequences (colors, text formatting, cursor controls) from terminal output strings.
262
+ */
263
+ export function stripAnsi(text: string): string {
264
+ if (!text) return "";
265
+ // Matches 7-bit ASCII and 8-bit ANSI escape codes
266
+ return text.replace(
267
+ // eslint-disable-next-line no-control-regex
268
+ /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
269
+ ""
270
+ );
271
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Text-run splitting for search highlighting, shared by every helper surface
3
+ * that paints matched query text and by the forges search surfaces that scroll
4
+ * to a match. A query is matched literally via `indexOf` on lowercased strings —
5
+ * never compiled as a regular expression — so user input cannot inject a
6
+ * pattern.
7
+ *
8
+ * Remote forge search is fuzzy: a returned issue may match a query it does not
9
+ * contain verbatim. When the literal query is absent the splitter highlights
10
+ * the query's multi-character tokens at word starts, so a fuzzy result still
11
+ * reads. Callers on a single primary label may additionally opt into marking
12
+ * the whole field when not even a token hits (`fallbackToWholeField`); callers
13
+ * that paint many small runs leave it off. `hasFuzzyHighlight` is the
14
+ * whole-surface test for go-to-match.
15
+ *
16
+ * `normalizeSearchQuery` strips surrounding quotes and whitespace for the local
17
+ * filter/highlight paths; remote search keeps the raw query.
18
+ */
19
+
20
+ export interface HighlightPart {
21
+ /** The run's source text, in its original casing. */
22
+ text: string;
23
+ /** True when this run is an occurrence of the (normalized) query. */
24
+ matched: boolean;
25
+ }
26
+
27
+ const TOKEN_PATTERN = /[^\p{L}\p{N}]+/u;
28
+
29
+ /**
30
+ * Normalize a search query for local matching and highlighting: trim, then peel
31
+ * any surrounding pair of matching single/double quotes (`"meta"`, `'meta'`,
32
+ * `""meta""`). Remote forge search keeps the raw query — Forgejo treats quotes
33
+ * as a phrase operator — so only the local paths normalize, letting quoted and
34
+ * unquoted input filter and paint identically. A lone quote is left intact.
35
+ */
36
+ export function normalizeSearchQuery(query: string): string {
37
+ let value = query.trim();
38
+ while (value.length >= 2) {
39
+ const first = value[0];
40
+ const last = value[value.length - 1];
41
+ if ((first !== '"' && first !== "'") || first !== last) break;
42
+ value = value.slice(1, -1).trim();
43
+ }
44
+ return value;
45
+ }
46
+
47
+ export interface HighlightOptions {
48
+ /**
49
+ * When true and neither a literal nor a token hit exists, the whole text is
50
+ * returned as a single matched run so a fuzzy search result is still visibly
51
+ * marked. Off by default: callers that paint many small runs (label chips,
52
+ * markdown spans) must not light every one of them up.
53
+ */
54
+ fallbackToWholeField?: boolean;
55
+ }
56
+
57
+ /**
58
+ * Query pieces used for the token fallback. A query with no literal hit is
59
+ * split on non-alphanumeric boundaries; single-character tokens are dropped so
60
+ * a fuzzy query cannot paint a stray letter in every result.
61
+ */
62
+ function queryTokens(needle: string): string[] {
63
+ return needle.split(TOKEN_PATTERN).filter((token) => token.length >= 2);
64
+ }
65
+
66
+ /** True when `index` begins a word (a word-start query token may prefix it). */
67
+ function isWordStartAt(haystack: string, index: number): boolean {
68
+ return index === 0 || TOKEN_PATTERN.test(haystack[index - 1]);
69
+ }
70
+
71
+ /** Earliest word-start occurrence of `token` in `haystack` at or after `from`. */
72
+ function firstTokenStart(haystack: string, token: string, from: number): number {
73
+ let index = haystack.indexOf(token, from);
74
+ while (index !== -1) {
75
+ if (isWordStartAt(haystack, index)) return index;
76
+ index = haystack.indexOf(token, index + 1);
77
+ }
78
+ return -1;
79
+ }
80
+
81
+ /**
82
+ * Split `text` into alternating unmatched/matched runs for every
83
+ * case-insensitive occurrence of `query`. Surrounding whitespace and matching
84
+ * surrounding quotes on the query are ignored; an empty or whitespace-only
85
+ * query (or empty text) yields the whole text as a single unmatched run.
86
+ *
87
+ * When the literal query is absent the splitter highlights the query's
88
+ * multi-character tokens at word starts. If no token hits either, the text is
89
+ * left unmatched unless the caller opted into `fallbackToWholeField`, which
90
+ * marks the whole field so a fuzzy result is never silently unmarked.
91
+ */
92
+ export function splitHighlightParts(
93
+ text: string,
94
+ query: string,
95
+ options: HighlightOptions = {},
96
+ ): HighlightPart[] {
97
+ const needle = normalizeSearchQuery(query).toLowerCase();
98
+ if (!needle || !text) return [{ text, matched: false }];
99
+ const haystack = text.toLowerCase();
100
+ if (haystack.includes(needle)) return splitLiteralParts(text, haystack, needle);
101
+ const tokens = queryTokens(needle);
102
+ const tokenParts = tokens.length ? splitTokenParts(text, haystack, tokens) : null;
103
+ if (tokenParts) return tokenParts;
104
+ // Whole-field fallback: a fuzzy result may match on text the query never
105
+ // contains, so paint the field rather than leave the row unmarked.
106
+ return options.fallbackToWholeField ? [{ text, matched: true }] : [{ text, matched: false }];
107
+ }
108
+
109
+ /** Runs for every non-overlapping literal occurrence of `needle`. */
110
+ function splitLiteralParts(text: string, haystack: string, needle: string): HighlightPart[] {
111
+ const parts: HighlightPart[] = [];
112
+ let cursor = 0;
113
+ while (cursor < text.length) {
114
+ const hit = haystack.indexOf(needle, cursor);
115
+ if (hit === -1) {
116
+ parts.push({ text: text.slice(cursor), matched: false });
117
+ break;
118
+ }
119
+ if (hit > cursor) parts.push({ text: text.slice(cursor, hit), matched: false });
120
+ parts.push({ text: text.slice(hit, hit + needle.length), matched: true });
121
+ cursor = hit + needle.length;
122
+ }
123
+ return parts.length > 0 ? parts : [{ text, matched: false }];
124
+ }
125
+
126
+ /**
127
+ * Runs matching any `token` beginning at a word boundary, so a fuzzy query
128
+ * paints the token that prefixes a word (`meta` inside `metadata`) and never a
129
+ * fragment mid-word (`eta` inside `metadata`). The longest token wins a shared
130
+ * start; ties fall to the earliest occurrence. Returns null when no token is
131
+ * found, letting the caller apply its whole-field policy.
132
+ */
133
+ function splitTokenParts(
134
+ text: string,
135
+ haystack: string,
136
+ tokens: string[],
137
+ ): HighlightPart[] | null {
138
+ const sorted = [...tokens].sort((a, b) => b.length - a.length);
139
+ const parts: HighlightPart[] = [];
140
+ let matchedAny = false;
141
+ let cursor = 0;
142
+ while (cursor < text.length) {
143
+ let best: { index: number; token: string } | null = null;
144
+ for (const token of sorted) {
145
+ const index = firstTokenStart(haystack, token, cursor);
146
+ if (index === -1) continue;
147
+ if (best === null || index < best.index) best = { index, token };
148
+ }
149
+ if (best === null) {
150
+ parts.push({ text: text.slice(cursor), matched: false });
151
+ break;
152
+ }
153
+ if (best.index > cursor) parts.push({ text: text.slice(cursor, best.index), matched: false });
154
+ parts.push({ text: text.slice(best.index, best.index + best.token.length), matched: true });
155
+ matchedAny = true;
156
+ cursor = best.index + best.token.length;
157
+ }
158
+ return matchedAny ? parts : null;
159
+ }
160
+
161
+ /**
162
+ * Whether `text` contains the literal trimmed query (case-insensitively) —
163
+ * the precise, pre-fuzzy test callers use to tell a true hit from the token
164
+ * fallback.
165
+ */
166
+ export function hasHighlightMatch(text: string, query: string): boolean {
167
+ const needle = normalizeSearchQuery(query).toLowerCase();
168
+ return needle.length > 0 && text.toLowerCase().includes(needle);
169
+ }
170
+
171
+ /**
172
+ * Whether `text` highlights at all for the trimmed `query`: a literal hit or,
173
+ * failing that, a word-start hit for any of the query's multi-character tokens.
174
+ * Unlike `splitHighlightParts` this never reports the whole-field fallback, so
175
+ * it pinpoints the result/section that genuinely mentions a fuzzy query rather
176
+ * than every field.
177
+ */
178
+ export function hasFuzzyHighlight(text: string, query: string): boolean {
179
+ const needle = normalizeSearchQuery(query).toLowerCase();
180
+ if (!needle || !text) return false;
181
+ const haystack = text.toLowerCase();
182
+ if (haystack.includes(needle)) return true;
183
+ return queryTokens(needle).some((token) => firstTokenStart(haystack, token, 0) !== -1);
184
+ }
@@ -0,0 +1,10 @@
1
+ export * from "./types";
2
+ export * from "./rpc";
3
+ export * from "./formatters";
4
+ export * from "./settings";
5
+ export * from "./suite-settings";
6
+ export * from "./async";
7
+ export * from "./suppressed";
8
+ export * from "./custom-pills";
9
+ export * from "./forge";
10
+ export * from "./highlight";
@@ -0,0 +1,72 @@
1
+ import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
2
+
3
+ export interface PluginRpcContract<
4
+ InputSchema extends ZodType = ZodType,
5
+ OutputSchema extends ZodType = ZodType,
6
+ > {
7
+ name: string;
8
+ input: InputSchema;
9
+ output: OutputSchema;
10
+ }
11
+
12
+ const RPC_NAME = /^[a-z][a-z0-9._-]*$/;
13
+
14
+ /**
15
+ * Define a typed Paseo RPC contract conforming to the Paseo RPC protocol.
16
+ * Implemented locally without a runtime dependency on the Paseo SDK.
17
+ */
18
+ export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
19
+ definition: { name: string; input: InputSchema; output: OutputSchema },
20
+ ): PluginRpcContract<InputSchema, OutputSchema> {
21
+ const name = definition.name.trim();
22
+ if (!RPC_NAME.test(name)) {
23
+ throw new Error(`Invalid plugin RPC method: ${definition.name}`);
24
+ }
25
+ return { ...definition, name };
26
+ }
27
+
28
+ export type RpcInput<TContract> = TContract extends PluginRpcContract<infer TInput, any>
29
+ ? ZodInput<TInput>
30
+ : unknown;
31
+
32
+ export type RpcOutput<TContract> = TContract extends PluginRpcContract<any, infer TOutput>
33
+ ? ZodOutput<TOutput>
34
+ : unknown;
35
+
36
+ export interface DefineRpcOptions<
37
+ TName extends string = string,
38
+ TInput extends ZodType = ZodType,
39
+ TOutput extends ZodType = ZodType,
40
+ > {
41
+ name: TName;
42
+ input: TInput;
43
+ output: TOutput;
44
+ description?: string;
45
+ }
46
+
47
+ /**
48
+ * Enhanced helper for defining a typed Paseo RPC contract with optional description.
49
+ */
50
+ export function defineContract<
51
+ TName extends string,
52
+ TInput extends ZodType,
53
+ TOutput extends ZodType,
54
+ >(
55
+ options: DefineRpcOptions<TName, TInput, TOutput>,
56
+ ): PluginRpcContract<TInput, TOutput> & { readonly description?: string } {
57
+ const contract = defineRpc({
58
+ name: options.name,
59
+ input: options.input,
60
+ output: options.output,
61
+ });
62
+
63
+ if (options.description) {
64
+ Object.defineProperty(contract, "description", {
65
+ value: options.description,
66
+ enumerable: true,
67
+ writable: false,
68
+ });
69
+ }
70
+
71
+ return contract as PluginRpcContract<TInput, TOutput> & { readonly description?: string };
72
+ }
@@ -0,0 +1,138 @@
1
+ import { z, type ZodType } from "zod";
2
+ import { defineContract, type PluginRpcContract } from "./rpc";
3
+
4
+ export const SettingsEmptyInputSchema = z
5
+ .union([z.void(), z.record(z.string(), z.unknown())])
6
+ .optional();
7
+ export type SettingsEmptyInput = z.infer<typeof SettingsEmptyInputSchema>;
8
+
9
+ export interface SettingsContract<TSettings extends Record<string, any>> {
10
+ readonly name: string;
11
+ readonly schema: ZodType<TSettings>;
12
+ readonly defaultSettings: TSettings;
13
+ readonly get: PluginRpcContract<ZodType<SettingsEmptyInput>, ZodType<TSettings>>;
14
+ readonly update: PluginRpcContract<ZodType<Partial<TSettings>>, ZodType<TSettings>>;
15
+ readonly reset: PluginRpcContract<ZodType<SettingsEmptyInput>, ZodType<TSettings>>;
16
+ readonly description?: string;
17
+ }
18
+
19
+ export interface DefineSettingsContractOptions<TSettings extends Record<string, any>> {
20
+ /**
21
+ * Unique name for the settings domain (e.g. "top.settings" or "myplugin.config").
22
+ * Automatically normalizes invalid RPC characters.
23
+ */
24
+ name: string;
25
+ /**
26
+ * Zod Object schema representing the full settings shape.
27
+ * Use `.default(...)` on fields to provide default values.
28
+ */
29
+ schema: ZodType<TSettings> & { partial?: () => ZodType<Partial<TSettings>> };
30
+ /**
31
+ * Optional default data override if schema fields do not all specify `.default()`.
32
+ */
33
+ defaultData?: Partial<TSettings>;
34
+ /**
35
+ * Optional human-readable description of the settings.
36
+ */
37
+ description?: string;
38
+ }
39
+
40
+ /**
41
+ * Strips `.default(...)` wrappers recursively from a Zod schema so that
42
+ * omitted fields remain undefined rather than being populated with default values
43
+ * during partial updates.
44
+ */
45
+ function stripDefaults(schema: any): any {
46
+ if (!schema || typeof schema !== "object") {
47
+ return schema;
48
+ }
49
+ if (schema instanceof z.ZodDefault) {
50
+ return stripDefaults(schema._def.innerType);
51
+ }
52
+ if (schema instanceof z.ZodOptional) {
53
+ return stripDefaults(schema._def.innerType).optional();
54
+ }
55
+ if (schema instanceof z.ZodNullable) {
56
+ return stripDefaults(schema._def.innerType).nullable();
57
+ }
58
+ // Handle ZodEffects, ZodBranded, ZodReadonly, ZodCatch
59
+ if (schema._def && schema._def.schema) {
60
+ return stripDefaults(schema._def.schema);
61
+ }
62
+ if (schema instanceof z.ZodObject) {
63
+ const shape = schema.shape;
64
+ const newShape: Record<string, any> = {};
65
+ for (const key of Object.keys(shape)) {
66
+ newShape[key] = stripDefaults(shape[key]).optional();
67
+ }
68
+ let res = z.object(newShape);
69
+ const unknownKeys = (schema._def as any)?.unknownKeys;
70
+ if (unknownKeys === "passthrough") {
71
+ res = res.passthrough() as any;
72
+ } else if (unknownKeys === "strict") {
73
+ res = res.strict() as any;
74
+ }
75
+ return res;
76
+ }
77
+ return typeof schema.optional === "function" ? schema.optional() : schema;
78
+ }
79
+
80
+ /**
81
+ * Defines a pair of typed Paseo RPC contracts (get, update, reset) for plugin settings.
82
+ */
83
+ export function defineSettingsContract<TSettings extends Record<string, any>>(
84
+ options: DefineSettingsContractOptions<TSettings>,
85
+ ): SettingsContract<TSettings> {
86
+ const { name, schema, defaultData, description } = options;
87
+
88
+ let computedDefaults: TSettings;
89
+ try {
90
+ computedDefaults = schema.parse(defaultData ?? {}) as TSettings;
91
+ } catch {
92
+ computedDefaults = (defaultData ?? {}) as TSettings;
93
+ }
94
+
95
+ const sanitizedName = name
96
+ .trim()
97
+ .toLowerCase()
98
+ .replace(/[^a-z0-9._-]/g, "_");
99
+
100
+ const partialSchema = (
101
+ schema instanceof z.ZodObject
102
+ ? stripDefaults(schema)
103
+ : typeof (schema as any).partial === "function"
104
+ ? (schema as any).partial()
105
+ : z.record(z.string(), z.unknown())
106
+ ) as ZodType<Partial<TSettings>>;
107
+
108
+ const getContract = defineContract({
109
+ name: `${sanitizedName}.get`,
110
+ input: SettingsEmptyInputSchema as ZodType<SettingsEmptyInput>,
111
+ output: schema,
112
+ description: description ? `Get ${description}` : `Get ${name} settings`,
113
+ });
114
+
115
+ const updateContract = defineContract({
116
+ name: `${sanitizedName}.update`,
117
+ input: partialSchema,
118
+ output: schema,
119
+ description: description ? `Update ${description}` : `Update ${name} settings`,
120
+ });
121
+
122
+ const resetContract = defineContract({
123
+ name: `${sanitizedName}.reset`,
124
+ input: SettingsEmptyInputSchema as ZodType<SettingsEmptyInput>,
125
+ output: schema,
126
+ description: description ? `Reset ${description}` : `Reset ${name} settings to defaults`,
127
+ });
128
+
129
+ return {
130
+ name: sanitizedName,
131
+ schema,
132
+ defaultSettings: computedDefaults,
133
+ get: getContract,
134
+ update: updateContract,
135
+ reset: resetContract,
136
+ ...(description !== undefined ? { description } : {}),
137
+ };
138
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ import { defineSettingsContract } from "./settings";
3
+
4
+ export const SuiteSettingsSchema = z.object({
5
+ suiteTitle: z.string().default("xpufx Suite"),
6
+ accentColor: z.string().default("#6366f1"),
7
+ density: z.enum(["compact", "comfortable", "spacious"]).default("comfortable"),
8
+ showSuiteTabs: z.boolean().default(true),
9
+ });
10
+
11
+ export type SuiteSettings = z.infer<typeof SuiteSettingsSchema>;
12
+
13
+ export const SuiteSettingsContract = defineSettingsContract({
14
+ name: "xpufx.suite.settings",
15
+ schema: SuiteSettingsSchema,
16
+ description: "Shared xpufx suite settings",
17
+ });