@xpufx/paseo-mcp-tools 0.1.3

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 (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -0
  3. package/client/__snapshots__/attribution.test.tsx.snap +170 -0
  4. package/client/commands.ts +113 -0
  5. package/client/mcp-query.tsx +39 -0
  6. package/client/pill.tsx +886 -0
  7. package/client/timeline.tsx +136 -0
  8. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  9. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  10. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  11. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  12. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  13. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  14. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  15. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  16. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  17. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  18. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  19. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  20. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  21. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  22. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  23. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  24. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  25. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  26. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  27. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  28. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  29. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  30. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  31. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  32. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  33. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  34. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  35. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  36. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  37. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  38. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  39. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  40. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  41. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  42. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  43. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  44. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  45. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  46. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  47. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  48. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  49. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  50. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  51. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  52. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  53. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  54. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  55. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  56. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  57. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  58. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  59. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  60. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  61. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  62. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  63. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  64. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  65. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  66. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  67. package/docs/TEST_METHODOLOGY.md +29 -0
  68. package/package.json +41 -0
  69. package/paseo-plugin.json +4 -0
  70. package/scripts/version.mjs +4 -0
  71. package/server/discovery/extract.ts +277 -0
  72. package/server/discovery/types.ts +23 -0
  73. package/server/health/health.ts +274 -0
  74. package/server/inject.ts +32 -0
  75. package/server/mcp.ts +365 -0
  76. package/server/providers/README.md +51 -0
  77. package/server/providers/antigravity.ts +22 -0
  78. package/server/providers/catalog.ts +6 -0
  79. package/server/providers/claude.ts +21 -0
  80. package/server/providers/codex.ts +85 -0
  81. package/server/providers/family.ts +24 -0
  82. package/server/providers/index.ts +11 -0
  83. package/server/providers/opencode.ts +95 -0
  84. package/server/providers/paseo.ts +112 -0
  85. package/server/providers/pi.ts +39 -0
  86. package/server/settings.ts +19 -0
  87. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  88. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  89. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  90. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  91. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  92. package/server/vendor/paseo-plugin-helper/mcp/client.ts +349 -0
  93. package/server/vendor/paseo-plugin-helper/mcp/http-client.ts +399 -0
  94. package/server/vendor/paseo-plugin-helper/mcp/index.ts +5 -0
  95. package/server/vendor/paseo-plugin-helper/mcp/process-killer.ts +65 -0
  96. package/server/vendor/paseo-plugin-helper/mcp/ring-buffer.ts +29 -0
  97. package/server/vendor/paseo-plugin-helper/mcp/types.ts +41 -0
  98. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  99. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  100. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  101. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  102. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  103. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  104. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  105. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  106. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  107. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  108. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  109. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  110. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  111. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  112. package/shared/mcp.ts +207 -0
  113. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  114. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  115. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  116. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  117. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  118. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  119. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  120. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  121. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  122. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  123. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  124. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  125. package/shared/version.ts +2 -0
@@ -0,0 +1,169 @@
1
+ import { z } from "zod";
2
+ import type { StatusVariant } from "./types";
3
+
4
+ export const CustomPillThresholdsSchema = z.object({
5
+ warning: z.number().optional(),
6
+ danger: z.number().optional(),
7
+ /**
8
+ * If true, lower values trigger warnings/dangers instead of higher values
9
+ * (e.g. disk space remaining, battery percentage).
10
+ */
11
+ invert: z.boolean().optional(),
12
+ });
13
+
14
+ export const CustomPillModalSchema = z.object({
15
+ title: z.string().optional(),
16
+ description: z.string().optional(),
17
+ command: z.string().optional(),
18
+ /**
19
+ * Whether to format command output as monospace preformatted text (default: true).
20
+ */
21
+ preformatted: z.boolean().default(true),
22
+ });
23
+
24
+ export const CustomPillDefinitionSchema = z.object({
25
+ /**
26
+ * Unique identifier for the custom pill (e.g. "gpu-util", "docker-count").
27
+ */
28
+ id: z.string().min(1),
29
+
30
+ /**
31
+ * Title shown in the composer trackbar (e.g. "GPU", "Docker").
32
+ */
33
+ title: z.string().min(1),
34
+
35
+ /**
36
+ * Compact title shown when layout is compact (e.g. "G"). Defaults to title.
37
+ */
38
+ compactTitle: z.string().optional(),
39
+
40
+ /**
41
+ * Lucide icon name (e.g. "Cpu", "Flame", "HardDrive", "Layers").
42
+ */
43
+ icon: z.string().optional(),
44
+
45
+ /**
46
+ * Optional compact icon name. Defaults to icon.
47
+ */
48
+ compactIcon: z.string().optional(),
49
+
50
+ /**
51
+ * Shell command executed periodically to produce the pill's value.
52
+ * Can use session environment variables like $PASEO_AGENT_ID, $PASEO_WORKSPACE_ID.
53
+ */
54
+ command: z.string().min(1),
55
+
56
+ /**
57
+ * Optional prefix prepended to the output value (e.g. "$", "#").
58
+ */
59
+ prefix: z.string().optional(),
60
+
61
+ /**
62
+ * Optional suffix appended to the output value (e.g. "%", "ms", "GB").
63
+ */
64
+ suffix: z.string().optional(),
65
+
66
+ /**
67
+ * Polling interval in milliseconds. Minimum 500ms, defaults to 5000ms.
68
+ */
69
+ intervalMs: z.number().min(500).default(5000),
70
+
71
+ /**
72
+ * Execution timeout in milliseconds. Defaults to 10000ms.
73
+ */
74
+ timeoutMs: z.number().min(500).default(10000),
75
+
76
+ /**
77
+ * Optional threshold rules to automatically transition badge color to warning or danger.
78
+ */
79
+ thresholds: CustomPillThresholdsSchema.optional(),
80
+
81
+ /**
82
+ * Optional modal configuration shown when the pill is pressed.
83
+ */
84
+ modal: CustomPillModalSchema.optional(),
85
+
86
+ /**
87
+ * Whether this custom pill is enabled. Defaults to true.
88
+ */
89
+ enabled: z.boolean().default(true),
90
+
91
+ /**
92
+ * Absolute path to the config file that defined this pill (e.g.
93
+ * ~/.paseo/top/pills/disk-usage.jsonc). Injected at discovery time; not
94
+ * intended to be authored in the config file itself.
95
+ */
96
+ sourceFile: z.string().optional(),
97
+ });
98
+
99
+ export type CustomPillDefinition = z.infer<typeof CustomPillDefinitionSchema>;
100
+ export type CustomPillThresholds = z.infer<typeof CustomPillThresholdsSchema>;
101
+ export type CustomPillModal = z.infer<typeof CustomPillModalSchema>;
102
+
103
+ export interface CustomPillState {
104
+ id: string;
105
+ title: string;
106
+ compactTitle?: string;
107
+ icon?: string;
108
+ compactIcon?: string;
109
+ rawValue: string;
110
+ displayValue: string;
111
+ numericValue?: number;
112
+ status: StatusVariant;
113
+ lastUpdated: number;
114
+ error?: string;
115
+ sourceFile?: string;
116
+ modalTitle?: string;
117
+ modalDescription?: string;
118
+ modalOutput?: string;
119
+ modalError?: string;
120
+ modalLastUpdated?: number;
121
+ }
122
+
123
+ /**
124
+ * Extracts a numeric value from the raw command output string (e.g. "45.2%" -> 45.2).
125
+ */
126
+ export function parseNumericPillValue(rawValue: string): number | undefined {
127
+ const match = rawValue.match(/-?\d+(\.\d+)?/);
128
+ if (!match) return undefined;
129
+ const num = parseFloat(match[0]);
130
+ return Number.isNaN(num) ? undefined : num;
131
+ }
132
+
133
+ /**
134
+ * Resolves the status variant based on numeric value and thresholds.
135
+ */
136
+ export function resolveCustomPillStatus(
137
+ numericValue: number | undefined,
138
+ thresholds?: CustomPillThresholds,
139
+ ): StatusVariant {
140
+ if (numericValue === undefined || !thresholds) {
141
+ return "neutral";
142
+ }
143
+
144
+ const { warning, danger, invert } = thresholds;
145
+
146
+ if (invert) {
147
+ if (danger !== undefined && numericValue <= danger) return "danger";
148
+ if (warning !== undefined && numericValue <= warning) return "warning";
149
+ return "success";
150
+ }
151
+
152
+ if (danger !== undefined && numericValue >= danger) return "danger";
153
+ if (warning !== undefined && numericValue >= warning) return "warning";
154
+ return "neutral";
155
+ }
156
+
157
+ /**
158
+ * Formats the raw output string with optional prefix and suffix.
159
+ */
160
+ export function formatPillDisplay(
161
+ rawValue: string,
162
+ prefix?: string,
163
+ suffix?: string,
164
+ ): string {
165
+ const cleaned = rawValue.trim();
166
+ const pre = prefix ?? "";
167
+ const suf = suffix ?? "";
168
+ return `${pre}${cleaned}${suf}`;
169
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Shared forge brand-mark resolution.
3
+ *
4
+ * One host/kind -> mark table for every plugin that shows forge iconography,
5
+ * so plugins never carry their own per-forge icon maps. The client-side
6
+ * `<ForgeIcon>` renders this descriptor; server/shared code can consume the
7
+ * pure resolver without pulling in React.
8
+ *
9
+ * GitHub and GitLab resolve to their Lucide marks; Codeberg, Forgejo and Gitea
10
+ * have no Lucide equivalent and resolve to helper-drawn official mono marks.
11
+ */
12
+
13
+ export type ForgeKind =
14
+ | "github"
15
+ | "gitlab"
16
+ | "codeberg"
17
+ | "forgejo"
18
+ | "gitea"
19
+ | "generic";
20
+
21
+ export interface ResolvedForgeMark {
22
+ /** Stable forge identity. */
23
+ kind: ForgeKind;
24
+ /** Human-readable forge name for labels and accessibility. */
25
+ label: string;
26
+ /** Host Lucide icon name; the exact mark on hosts with SVG support, the closest fallback otherwise. */
27
+ lucideName: string;
28
+ /** True when `<ForgeIcon>` draws the official mark instead of delegating to the host icon set. */
29
+ custom: boolean;
30
+ }
31
+
32
+ export interface ForgeMarkInput {
33
+ /** Forge hostname, e.g. `codeberg.org` or `forge.example.com`. */
34
+ host?: string | null;
35
+ /** Explicit forge identity; wins over host detection when it names a known forge. */
36
+ kind?: ForgeKind | string | null;
37
+ }
38
+
39
+ const FORGE_MARKS: Record<ForgeKind, ResolvedForgeMark> = {
40
+ github: { kind: "github", label: "GitHub", lucideName: "Github", custom: false },
41
+ gitlab: { kind: "gitlab", label: "GitLab", lucideName: "Gitlab", custom: false },
42
+ codeberg: { kind: "codeberg", label: "Codeberg", lucideName: "Mountain", custom: true },
43
+ forgejo: { kind: "forgejo", label: "Forgejo", lucideName: "Hammer", custom: true },
44
+ gitea: { kind: "gitea", label: "Gitea", lucideName: "Coffee", custom: true },
45
+ generic: { kind: "generic", label: "Git forge", lucideName: "Globe", custom: false },
46
+ };
47
+
48
+ const FORGE_KINDS: ForgeKind[] = [
49
+ "github",
50
+ "gitlab",
51
+ "codeberg",
52
+ "forgejo",
53
+ "gitea",
54
+ "generic",
55
+ ];
56
+
57
+ /**
58
+ * Reduce a remote URL, `owner/repo` slug or bare hostname to a lowercase
59
+ * hostname without scheme, userinfo, port, path or a leading `www.`.
60
+ */
61
+ export function normalizeForgeHost(host: string | null | undefined): string | null {
62
+ if (!host || typeof host !== "string") return null;
63
+ let value = host.trim().toLowerCase();
64
+ if (!value) return null;
65
+ value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
66
+ value = value.replace(/^[^@/]+@/, "");
67
+ value = value.split("/")[0]!.split("?")[0]!.split("#")[0]!;
68
+ // Cuts both `host:port` and SCP-style `host:owner/repo` tails.
69
+ value = value.split(":")[0]!;
70
+ value = value.replace(/^www\./, "").replace(/\.$/, "");
71
+ return value || null;
72
+ }
73
+
74
+ // Ordered: the first match wins. Self-hosted instances routinely carry the
75
+ // project name in their hostname (e.g. `forgejo.example.com`); everything else
76
+ // is an unknown forge and gets the generic fallback.
77
+ const HOST_KIND_RULES: Array<{ matches: (host: string) => boolean; kind: ForgeKind }> = [
78
+ { matches: (h) => h === "github.com" || h.endsWith(".github.com"), kind: "github" },
79
+ { matches: (h) => h === "gitlab.com" || h.endsWith(".gitlab.com"), kind: "gitlab" },
80
+ { matches: (h) => h === "codeberg.org" || h.endsWith(".codeberg.org"), kind: "codeberg" },
81
+ { matches: (h) => h === "forgejo.org" || h.endsWith(".forgejo.org") || h.includes("forgejo"), kind: "forgejo" },
82
+ { matches: (h) => h === "gitea.com" || h === "gitea.io" || h.includes("gitea"), kind: "gitea" },
83
+ ];
84
+
85
+ export function isForgeKind(value: unknown): value is ForgeKind {
86
+ return typeof value === "string" && FORGE_KINDS.includes(value as ForgeKind);
87
+ }
88
+
89
+ export function forgeKindFromHost(host: string | null | undefined): ForgeKind {
90
+ const normalized = normalizeForgeHost(host);
91
+ if (!normalized) return "generic";
92
+ for (const rule of HOST_KIND_RULES) {
93
+ if (rule.matches(normalized)) return rule.kind;
94
+ }
95
+ return "generic";
96
+ }
97
+
98
+ /**
99
+ * Resolve a forge descriptor from a hostname and/or explicit kind. Accepts a
100
+ * bare host string for the common host-only case.
101
+ */
102
+ export function resolveForgeMark(
103
+ input: ForgeMarkInput | string | null | undefined,
104
+ ): ResolvedForgeMark {
105
+ const { host, kind } =
106
+ input !== null && typeof input === "object" ? input : { host: input, kind: null };
107
+ const explicit = typeof kind === "string" ? kind.trim().toLowerCase() : null;
108
+ const resolved = isForgeKind(explicit) ? explicit : forgeKindFromHost(host);
109
+ return FORGE_MARKS[resolved];
110
+ }
@@ -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
+ }