@omercnet/paseo-omp 0.2.1

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 (63) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +110 -0
  4. package/SUPPORT.md +40 -0
  5. package/TESTING.md +147 -0
  6. package/client/hub-icon.tsx +12 -0
  7. package/client/hub-popover.tsx +132 -0
  8. package/client/hub-status.ts +29 -0
  9. package/client/memory-panel.tsx +71 -0
  10. package/client/memory-popover.tsx +70 -0
  11. package/client/omp-config-surface.tsx +1274 -0
  12. package/client/omp-doc-links.ts +117 -0
  13. package/client/omp-plugin-manager.tsx +833 -0
  14. package/client/provider-diagnostics-state.ts +250 -0
  15. package/client/provider-icon.tsx +27 -0
  16. package/client/provider-image.tsx +66 -0
  17. package/client/quota-popover.tsx +150 -0
  18. package/client/quota-state.ts +131 -0
  19. package/client/sessions-popover.tsx +73 -0
  20. package/docs/alpha-release-checklist.md +70 -0
  21. package/docs/configuration.md +122 -0
  22. package/docs/core-provider-issue-audit.md +108 -0
  23. package/docs/installation.md +73 -0
  24. package/index.client.tsx +272 -0
  25. package/index.server.ts +51 -0
  26. package/package.json +84 -0
  27. package/paseo-plugin.json +5 -0
  28. package/server/hub.ts +145 -0
  29. package/server/memory.ts +86 -0
  30. package/server/mutation-queue.ts +12 -0
  31. package/server/omp-config.ts +126 -0
  32. package/server/omp-plugins.ts +627 -0
  33. package/server/omp-settings.ts +291 -0
  34. package/server/paths.ts +64 -0
  35. package/server/provider/catalog.ts +173 -0
  36. package/server/provider/config-normalization.ts +148 -0
  37. package/server/provider/connection.ts +992 -0
  38. package/server/provider/host-tools.ts +706 -0
  39. package/server/provider/image.ts +143 -0
  40. package/server/provider/mcp-transport.ts +394 -0
  41. package/server/provider/omp-rpc.ts +2739 -0
  42. package/server/provider/omp.svg +5 -0
  43. package/server/provider/provider-options.ts +27 -0
  44. package/server/provider/registration.ts +151 -0
  45. package/server/provider/security.ts +317 -0
  46. package/server/provider/session-descriptors.ts +431 -0
  47. package/server/provider/session.ts +4451 -0
  48. package/server/provider/settings.ts +78 -0
  49. package/server/provider/subsessions.ts +847 -0
  50. package/server/provider/timeline-projector.ts +1764 -0
  51. package/server/provider-diagnostics.ts +1057 -0
  52. package/server/quota.ts +54 -0
  53. package/server/sessions.ts +58 -0
  54. package/shared/hub.ts +43 -0
  55. package/shared/memory.ts +23 -0
  56. package/shared/omp-config.ts +81 -0
  57. package/shared/omp-plugins.ts +223 -0
  58. package/shared/omp-settings.ts +207 -0
  59. package/shared/provider-diagnostics.ts +117 -0
  60. package/shared/provider-image.ts +160 -0
  61. package/shared/quota.ts +22 -0
  62. package/shared/sessions.ts +23 -0
  63. package/tsconfig.json +16 -0
@@ -0,0 +1,250 @@
1
+ import type { PaseoApi, PaseoProviderSnapshotResult } from "@getpaseo/client";
2
+ import type {
3
+ OmpLspDiagnostics,
4
+ OmpMcpDiagnostics,
5
+ OmpProcessDiagnostics,
6
+ OmpProviderHealth,
7
+ OmpVersion,
8
+ OmpVersionStatus,
9
+ PathState,
10
+ } from "../shared/provider-diagnostics";
11
+
12
+ export type ProviderHealthTone = "ok" | "warning" | "danger" | "muted";
13
+ export const OMP_PROVIDER_IDS = ["omp", "omp-plugin"] as const;
14
+ const OMP_PROVIDER_ID_SET: ReadonlySet<string> = new Set(OMP_PROVIDER_IDS);
15
+
16
+ export function isUnsupportedHostError(error: unknown): boolean {
17
+ if (typeof error !== "object" || error === null) return false;
18
+ const candidate = error as { code?: unknown; name?: unknown };
19
+ return (
20
+ candidate.name === "PaseoUpdateHostError" ||
21
+ candidate.code === "UPDATE_HOST_REQUIRED" ||
22
+ candidate.code === "UNSUPPORTED_FEATURE"
23
+ );
24
+ }
25
+
26
+ type ProviderActions = Pick<PaseoApi["providers"], "refresh" | "snapshot" | "waitForReady">;
27
+
28
+ /** Initial and post-refresh discovery waits for a settled snapshot; only known old-host errors
29
+ * fall back to the immediate snapshot API. */
30
+ export async function loadReadyProviderSnapshot(
31
+ providers: ProviderActions,
32
+ ): Promise<PaseoProviderSnapshotResult> {
33
+ try {
34
+ return await providers.waitForReady({ timeoutMs: 60_000 });
35
+ } catch (error) {
36
+ if (!isUnsupportedHostError(error)) throw error;
37
+ return providers.snapshot({});
38
+ }
39
+ }
40
+
41
+ export interface RefreshDiagnosticsOptions {
42
+ providers: ProviderActions;
43
+ loadForcedHealth(): Promise<OmpProviderHealth>;
44
+ cacheHealth(health: OmpProviderHealth): void;
45
+ cacheProviders(snapshot: PaseoProviderSnapshotResult): void;
46
+ }
47
+
48
+ /** Provider refresh and forced health run independently. Each successful result reaches its
49
+ * cache even when another branch fails; the boolean reports any non-suppressed partial failure. */
50
+ export async function refreshProviderDiagnostics(
51
+ options: RefreshDiagnosticsOptions,
52
+ ): Promise<{ failed: boolean }> {
53
+ const [providerRefresh, forcedHealth] = await Promise.allSettled([
54
+ options.providers.refresh({ providers: [...OMP_PROVIDER_IDS] }),
55
+ options.loadForcedHealth(),
56
+ ]);
57
+ if (forcedHealth.status === "fulfilled") options.cacheHealth(forcedHealth.value);
58
+
59
+ const providerSnapshot = await Promise.allSettled([loadReadyProviderSnapshot(options.providers)]);
60
+ if (providerSnapshot[0].status === "fulfilled") {
61
+ options.cacheProviders(providerSnapshot[0].value);
62
+ }
63
+ const providerRefreshFailed =
64
+ providerRefresh.status === "rejected" && !isUnsupportedHostError(providerRefresh.reason);
65
+ return {
66
+ failed:
67
+ providerRefreshFailed ||
68
+ forcedHealth.status === "rejected" ||
69
+ providerSnapshot[0].status === "rejected",
70
+ };
71
+ }
72
+
73
+ const VERSION_STATUS_LABELS: Record<OmpVersionStatus, string> = {
74
+ ok: "Installed",
75
+ "not-found": "Not installed",
76
+ unrunnable: "Found but could not run",
77
+ timeout: "Version check timed out",
78
+ "probe-failed": "Version check failed",
79
+ malformed: "Unrecognized version output",
80
+ };
81
+
82
+ const VERSION_STATUS_TONES: Record<OmpVersionStatus, ProviderHealthTone> = {
83
+ ok: "ok",
84
+ "not-found": "danger",
85
+ unrunnable: "danger",
86
+ timeout: "warning",
87
+ "probe-failed": "warning",
88
+ malformed: "warning",
89
+ };
90
+
91
+ export function formatOmpVersion(version: OmpVersion): string {
92
+ const core = `${version.major}.${version.minor}.${version.patch}`;
93
+ return version.prerelease ? `${core}-${version.prerelease}` : core;
94
+ }
95
+
96
+ export interface BinaryHealthSummary {
97
+ label: string;
98
+ tone: ProviderHealthTone;
99
+ }
100
+
101
+ /** Combines version status and the parsed version into one display-ready label and tone. */
102
+ export function summarizeBinaryHealth(binary: OmpProviderHealth["binary"]): BinaryHealthSummary {
103
+ const label =
104
+ binary.versionStatus === "ok" && binary.version
105
+ ? `${VERSION_STATUS_LABELS.ok} (${formatOmpVersion(binary.version)})`
106
+ : VERSION_STATUS_LABELS[binary.versionStatus];
107
+ return { label, tone: VERSION_STATUS_TONES[binary.versionStatus] };
108
+ }
109
+
110
+ /** Distinguishes "not supported" from "we could not tell" so the UI never overclaims. */
111
+ export function summarizeRpcUiSupport(rpcUi: OmpProviderHealth["rpcUi"]): string {
112
+ if (!rpcUi.checked) return "Unknown (omp binary unavailable)";
113
+ if (rpcUi.supported === null) return "Unknown (probe failed, empty, or truncated)";
114
+ return rpcUi.supported ? "Supported" : "Not advertised by this build";
115
+ }
116
+
117
+ export function rpcUiTone(rpcUi: OmpProviderHealth["rpcUi"]): ProviderHealthTone {
118
+ if (!rpcUi.checked || rpcUi.supported === null) return "muted";
119
+ return rpcUi.supported ? "ok" : "muted";
120
+ }
121
+
122
+ export function summarizeLspSupport(lsp: OmpLspDiagnostics): string {
123
+ if (lsp.status === "supported") return "Supported";
124
+ if (lsp.status === "not-advertised") return "Not advertised by this build";
125
+ return "Unknown (probe failed, empty, or truncated)";
126
+ }
127
+
128
+ export function lspTone(lsp: OmpLspDiagnostics): ProviderHealthTone {
129
+ return lsp.status === "supported" ? "ok" : "muted";
130
+ }
131
+
132
+ export function summarizeMcpDiagnostics(mcp: OmpMcpDiagnostics): string {
133
+ if (mcp.status === "configured") return `${mcp.serverCount ?? 0} configured`;
134
+ const label =
135
+ mcp.status === "unavailable"
136
+ ? "Unavailable"
137
+ : mcp.status === "unreadable"
138
+ ? "Unreadable"
139
+ : mcp.status === "wrong-type"
140
+ ? "Wrong type"
141
+ : "Invalid";
142
+ return `${label} (${mcp.reason ?? "no detail"})`;
143
+ }
144
+
145
+ export function mcpTone(mcp: OmpMcpDiagnostics): ProviderHealthTone {
146
+ if (mcp.status === "configured") return "ok";
147
+ if (mcp.status === "unavailable") return "muted";
148
+ return "warning";
149
+ }
150
+
151
+ export function summarizeProcessDiagnostics(diagnostics: OmpProcessDiagnostics): string {
152
+ if (diagnostics.status === "unavailable") return "No hub run directory found";
153
+ if (diagnostics.status === "unknown") return "Unknown (could not read the hub run directory)";
154
+ const count = diagnostics.trackedCount ?? 0;
155
+ if (diagnostics.status === "partial") return `${count} tracked (partial: some inaccessible)`;
156
+ return `${count} tracked`;
157
+ }
158
+
159
+ export function processTone(diagnostics: OmpProcessDiagnostics): ProviderHealthTone {
160
+ if (diagnostics.status === "unknown") return "warning";
161
+ if (diagnostics.status === "partial") return "warning";
162
+ if (diagnostics.status === "unavailable") return "muted";
163
+ return diagnostics.trackedCount && diagnostics.trackedCount > 0 ? "ok" : "muted";
164
+ }
165
+
166
+ const PATH_STATE_LABELS: Record<PathState, string> = {
167
+ available: "Found",
168
+ missing: "Missing",
169
+ unreadable: "Unreadable",
170
+ invalid: "Invalid",
171
+ "wrong-type": "Wrong type on disk",
172
+ };
173
+
174
+ const PATH_STATE_TONES: Record<PathState, ProviderHealthTone> = {
175
+ available: "ok",
176
+ missing: "danger",
177
+ unreadable: "warning",
178
+ invalid: "warning",
179
+ "wrong-type": "warning",
180
+ };
181
+
182
+ export interface PathStateSummary {
183
+ label: string;
184
+ tone: ProviderHealthTone;
185
+ }
186
+
187
+ /** Never collapses unreadable/invalid/wrong-type into missing; each state gets its own label. */
188
+ export function summarizePathState(state: PathState): PathStateSummary {
189
+ return { label: PATH_STATE_LABELS[state], tone: PATH_STATE_TONES[state] };
190
+ }
191
+
192
+ /**
193
+ * A null memory backend is ambiguous on its own: the config could be genuinely unset, or simply
194
+ * unavailable/invalid/wrong-type. Only a truly "available" config licenses "Not configured";
195
+ * every other state reports its own unavailability instead of guessing.
196
+ */
197
+ export function summarizeMemoryBackend(health: OmpProviderHealth): string {
198
+ if (health.roots.configState !== "available") {
199
+ return `Unknown (config ${PATH_STATE_LABELS[health.roots.configState].toLowerCase()})`;
200
+ }
201
+ return health.memoryBackend ?? "Not configured";
202
+ }
203
+
204
+ export interface KnownOmpProviderSummary {
205
+ id: string;
206
+ label: string;
207
+ status: string;
208
+ enabled: boolean;
209
+ }
210
+
211
+ export interface ProviderStatusSummary {
212
+ label: string;
213
+ tone: ProviderHealthTone;
214
+ }
215
+
216
+ export function summarizeProviderStatus(
217
+ provider: Pick<KnownOmpProviderSummary, "enabled" | "status">,
218
+ ): ProviderStatusSummary {
219
+ if (!provider.enabled) return { label: "Disabled", tone: "muted" };
220
+ switch (provider.status) {
221
+ case "ready":
222
+ return { label: "Ready", tone: "ok" };
223
+ case "loading":
224
+ return { label: "Loading", tone: "warning" };
225
+ case "error":
226
+ return { label: "Error", tone: "danger" };
227
+ case "unavailable":
228
+ return { label: "Unavailable", tone: "danger" };
229
+ default:
230
+ return { label: "Unknown", tone: "muted" };
231
+ }
232
+ }
233
+
234
+ /** Narrows a full provider snapshot to the native and plugin OMP identities. */
235
+ export function selectKnownOmpProviders(
236
+ entries: readonly PaseoProviderSnapshotResult["entries"][number][],
237
+ ): KnownOmpProviderSummary[] {
238
+ return entries.flatMap((entry) =>
239
+ OMP_PROVIDER_ID_SET.has(entry.provider)
240
+ ? [
241
+ {
242
+ id: entry.provider,
243
+ label: entry.label ?? entry.provider,
244
+ status: entry.status,
245
+ enabled: entry.enabled ?? true,
246
+ },
247
+ ]
248
+ : [],
249
+ );
250
+ }
@@ -0,0 +1,27 @@
1
+ import type { PluginButtonIconProps } from "@getpaseo/plugin/client";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import type { ComponentType } from "react";
4
+ import { View } from "react-native";
5
+ import { type QuotaSeverity, quotaProviderIconName } from "./quota-state";
6
+
7
+ export function quotaProviderIcon(
8
+ provider: string | null,
9
+ severity: QuotaSeverity,
10
+ ): ComponentType<PluginButtonIconProps> {
11
+ function ProviderIcon({ size, color, theme }: PluginButtonIconProps) {
12
+ const severityColor =
13
+ severity === "danger"
14
+ ? theme.colors.statusDanger
15
+ : severity === "warning"
16
+ ? theme.colors.statusWarning
17
+ : severity === "ok"
18
+ ? theme.colors.statusSuccess
19
+ : color;
20
+ return (
21
+ <View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
22
+ <Icon name={quotaProviderIconName(provider)} size={size} color={severityColor} />
23
+ </View>
24
+ );
25
+ }
26
+ return ProviderIcon;
27
+ }
@@ -0,0 +1,66 @@
1
+ import type { PluginTimelineItemProps } from "@getpaseo/plugin/client";
2
+ import { useMemo, useState } from "react";
3
+ import { Image, Text, View } from "react-native";
4
+ import { type OmpImageTimelineData, visibleOmpImageText } from "../shared/provider-image";
5
+
6
+ export function OmpImageTimeline({ item, theme }: PluginTimelineItemProps<OmpImageTimelineData>) {
7
+ const styles = useMemo(
8
+ () => ({
9
+ root: { gap: 8 },
10
+ label: { color: theme.colors.foregroundMuted, fontSize: 12, fontWeight: "600" as const },
11
+ text: { color: theme.colors.foreground, fontSize: 13 },
12
+ image: {
13
+ width: "100%" as const,
14
+ minHeight: 220,
15
+ aspectRatio: 16 / 9,
16
+ borderRadius: 8,
17
+ backgroundColor: theme.colors.surface1,
18
+ },
19
+ imageFallback: {
20
+ alignItems: "center" as const,
21
+ justifyContent: "center" as const,
22
+ padding: 16,
23
+ borderWidth: 1,
24
+ borderColor: theme.colors.border,
25
+ },
26
+ imageFallbackText: {
27
+ color: theme.colors.foregroundMuted,
28
+ fontSize: 12,
29
+ textAlign: "center" as const,
30
+ },
31
+ }),
32
+ [theme],
33
+ );
34
+ const [failedImageIds, setFailedImageIds] = useState<ReadonlySet<string>>(() => new Set());
35
+ const visibleText = useMemo(() => visibleOmpImageText(item.data.text), [item.data.text]);
36
+ return (
37
+ <View style={styles.root}>
38
+ <Text style={styles.label}>{item.data.label}</Text>
39
+ {visibleText ? <Text style={styles.text}>{visibleText}</Text> : null}
40
+ {item.data.images.map((image, index) =>
41
+ failedImageIds.has(image.id) ? (
42
+ <View key={image.id} style={[styles.image, styles.imageFallback]}>
43
+ <Text style={styles.imageFallbackText}>
44
+ {image.mimeType.replace("image/", "").toUpperCase()} image could not be rendered on
45
+ this Paseo client.
46
+ </Text>
47
+ </View>
48
+ ) : (
49
+ <Image
50
+ key={image.id}
51
+ source={{ uri: `data:${image.mimeType};base64,${image.data}` }}
52
+ resizeMode="contain"
53
+ accessibilityLabel={`${item.data.label} image ${index + 1}`}
54
+ onError={() =>
55
+ setFailedImageIds((current) => {
56
+ if (current.has(image.id)) return current;
57
+ return new Set([...current, image.id]);
58
+ })
59
+ }
60
+ style={styles.image}
61
+ />
62
+ ),
63
+ )}
64
+ </View>
65
+ );
66
+ }
@@ -0,0 +1,150 @@
1
+ import { type PluginButtonContentProps, useAgent, useRpc } from "@getpaseo/plugin/client";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { useMemo } from "react";
5
+ import { Text, View } from "react-native";
6
+ import { listOmpQuotas } from "../shared/quota";
7
+ import {
8
+ type QuotaProviderGroup,
9
+ quotaProviderFromSession,
10
+ quotaProviderGroups,
11
+ quotaProviderIconName,
12
+ quotaProviderLabel,
13
+ quotaResetLabel,
14
+ quotaSeverityFromFraction,
15
+ } from "./quota-state";
16
+
17
+ const QUOTA_POLL_MS = 30_000;
18
+
19
+ export function QuotaPopover(props: PluginButtonContentProps) {
20
+ const { theme, layout } = props;
21
+ const agentId = props.context === "agent" ? props.agentId : "";
22
+ const session = useAgent(agentId, (agent) => ({ model: agent.model, provider: agent.provider }));
23
+ const currentProvider = quotaProviderFromSession(session?.provider ?? "", session?.model ?? null);
24
+ const loadQuotas = useRpc(listOmpQuotas);
25
+ const quotas = useQuery({
26
+ queryKey: ["paseo-omp", "quotas"],
27
+ queryFn: () => loadQuotas({}),
28
+ refetchInterval: QUOTA_POLL_MS,
29
+ });
30
+
31
+ function severityColor(severity: ReturnType<typeof quotaSeverityFromFraction>): string {
32
+ if (severity === "danger") return theme.colors.statusDanger;
33
+ if (severity === "warning") return theme.colors.statusWarning;
34
+ if (severity === "ok") return theme.colors.statusSuccess;
35
+ return theme.colors.foregroundMuted;
36
+ }
37
+
38
+ const styles = useMemo(
39
+ () => ({
40
+ root: { gap: layout.compact ? 8 : 10, minWidth: layout.compact ? undefined : 260 },
41
+ muted: { color: theme.colors.foregroundMuted, fontSize: 13 },
42
+ error: { color: theme.colors.statusDanger, fontSize: 13 },
43
+ group: (current: boolean) => ({
44
+ gap: 6,
45
+ padding: layout.compact ? 10 : 12,
46
+ borderRadius: 10,
47
+ borderWidth: current ? 2 : 1,
48
+ borderColor: current ? theme.colors.accent : theme.colors.border,
49
+ backgroundColor: theme.colors.surface1,
50
+ }),
51
+ groupHeader: { flexDirection: "row" as const, alignItems: "center" as const, gap: 6 },
52
+ groupLabel: {
53
+ color: theme.colors.foreground,
54
+ fontSize: 14,
55
+ fontWeight: "700" as const,
56
+ flex: 1,
57
+ },
58
+ badge: {
59
+ color: theme.colors.accentForeground,
60
+ backgroundColor: theme.colors.accent,
61
+ fontSize: 10,
62
+ fontWeight: "700" as const,
63
+ paddingHorizontal: 6,
64
+ paddingVertical: 2,
65
+ borderRadius: 999,
66
+ overflow: "hidden" as const,
67
+ },
68
+ row: { gap: 3 },
69
+ rowHeader: {
70
+ flexDirection: "row" as const,
71
+ justifyContent: "space-between" as const,
72
+ gap: 8,
73
+ },
74
+ rowLabel: { color: theme.colors.foreground, fontSize: 12, flex: 1 },
75
+ rowValue: { fontSize: 12, fontWeight: "600" as const },
76
+ track: {
77
+ height: 5,
78
+ borderRadius: 3,
79
+ backgroundColor: theme.colors.surface2,
80
+ overflow: "hidden" as const,
81
+ },
82
+ detail: { color: theme.colors.foregroundMuted, fontSize: 11 },
83
+ }),
84
+ [layout.compact, theme],
85
+ );
86
+
87
+ if (quotas.isLoading) return <Text style={styles.muted}>Loading omp quotas…</Text>;
88
+ if (quotas.error) return <Text style={styles.error}>Could not read omp quota state.</Text>;
89
+
90
+ const groups = quotaProviderGroups(quotas.data?.quotas ?? [], currentProvider);
91
+ const hasCurrent = groups.some((group) => group.provider === currentProvider);
92
+
93
+ if (groups.length === 0) {
94
+ return <Text style={styles.muted}>No provider quota data available.</Text>;
95
+ }
96
+
97
+ return (
98
+ <View style={styles.root}>
99
+ {currentProvider && !hasCurrent ? (
100
+ <Text style={styles.muted}>
101
+ {`No recorded quota yet for ${quotaProviderLabel(currentProvider)} (this session's provider).`}
102
+ </Text>
103
+ ) : null}
104
+ {groups.map((group: QuotaProviderGroup) => {
105
+ const current = group.provider === currentProvider;
106
+ return (
107
+ <View key={group.provider} style={styles.group(current)}>
108
+ <View style={styles.groupHeader}>
109
+ <Icon
110
+ name={quotaProviderIconName(group.provider)}
111
+ size={16}
112
+ color={severityColor(group.severity)}
113
+ />
114
+ <Text style={styles.groupLabel}>{quotaProviderLabel(group.provider)}</Text>
115
+ {current ? <Text style={styles.badge}>CURRENT</Text> : null}
116
+ </View>
117
+ {group.quotas.map((quota) => {
118
+ const severity = quotaSeverityFromFraction(quota.usedFraction);
119
+ const color = severityColor(severity);
120
+ const pct =
121
+ quota.usedFraction === null
122
+ ? 0
123
+ : Math.min(100, Math.round(quota.usedFraction * 100));
124
+ return (
125
+ <View key={`${quota.label}:${quota.windowLabel}`} style={styles.row}>
126
+ <View style={styles.rowHeader}>
127
+ <Text numberOfLines={1} style={styles.rowLabel}>
128
+ {quota.label}
129
+ </Text>
130
+ <Text style={[styles.rowValue, { color }]}>
131
+ {quota.usedFraction === null ? "Unknown" : `${pct}%`}
132
+ </Text>
133
+ </View>
134
+ <View style={styles.track}>
135
+ <View style={{ height: "100%", width: `${pct}%`, backgroundColor: color }} />
136
+ </View>
137
+ <Text style={styles.detail}>
138
+ {[quota.windowLabel, quotaResetLabel(quota.resetsAt)]
139
+ .filter(Boolean)
140
+ .join(" · ")}
141
+ </Text>
142
+ </View>
143
+ );
144
+ })}
145
+ </View>
146
+ );
147
+ })}
148
+ </View>
149
+ );
150
+ }
@@ -0,0 +1,131 @@
1
+ import type { OmpQuota } from "../shared/quota";
2
+
3
+ const PROVIDER_LABELS: Record<string, string> = {
4
+ anthropic: "Anthropic",
5
+ cursor: "Cursor",
6
+ "google-antigravity": "Google",
7
+ "openai-codex": "OpenAI",
8
+ };
9
+
10
+ // Paseo's own provider brand icons (@getpaseo/protocol names such as "claude" or "omp") are
11
+ // host-internal: passing one as a button `icon` string fails host validation, and rendering it
12
+ // through `Icon` draws nothing. Plugin icons resolve Lucide names only, so each provider maps
13
+ // to the Lucide vector closest to its mark.
14
+ const PROVIDER_ICON_NAMES: Record<string, string> = {
15
+ anthropic: "Asterisk",
16
+ azure: "Cloud",
17
+ cursor: "MousePointer2",
18
+ "google-antigravity": "Gem",
19
+ openai: "Atom",
20
+ "openai-codex": "Atom",
21
+ };
22
+
23
+ export function quotaProviderIconName(provider: string | null): string {
24
+ return (provider ? PROVIDER_ICON_NAMES[provider] : undefined) ?? "Gauge";
25
+ }
26
+
27
+ export type QuotaSeverity = "ok" | "warning" | "danger" | "unknown";
28
+
29
+ export function quotaSeverityFromFraction(fraction: number | null): QuotaSeverity {
30
+ if (fraction === null) return "unknown";
31
+ if (fraction >= 0.9) return "danger";
32
+ if (fraction >= 0.7) return "warning";
33
+ return "ok";
34
+ }
35
+ export function quotaProviderFromSession(
36
+ provider: string,
37
+ model: string | null = null,
38
+ ): string | null {
39
+ const [runtime, modelProvider] = provider.split("/");
40
+ if (runtime !== "omp") return null;
41
+ if (modelProvider) return modelProvider;
42
+ return model?.split("/")[0] ?? null;
43
+ }
44
+
45
+ export function quotaProviderLabel(provider: string | null): string {
46
+ return provider
47
+ ? (PROVIDER_LABELS[provider] ?? `${provider.slice(0, 1).toUpperCase()}${provider.slice(1)}`)
48
+ : "Provider";
49
+ }
50
+
51
+ export function quotasForProvider(
52
+ quotas: readonly OmpQuota[],
53
+ provider: string | null,
54
+ ): OmpQuota[] {
55
+ return provider ? quotas.filter((quota) => quota.provider === provider) : [];
56
+ }
57
+
58
+ export function quotaSummaryForProvider(
59
+ quotas: readonly OmpQuota[],
60
+ provider: string | null,
61
+ ): { visible: boolean; label: string } {
62
+ const matching = quotasForProvider(quotas, provider);
63
+ const used = matching.flatMap((quota) =>
64
+ quota.usedFraction === null ? [] : [quota.usedFraction],
65
+ );
66
+ if (used.length === 0) {
67
+ return provider
68
+ ? { visible: true, label: `${quotaProviderLabel(provider)} · —` }
69
+ : { visible: false, label: "Quota" };
70
+ }
71
+ const peak = Math.round(Math.max(...used) * 100);
72
+ return { visible: true, label: `${quotaProviderLabel(provider)} · ${peak}%` };
73
+ }
74
+
75
+ export function quotaSeverityForProvider(
76
+ quotas: readonly OmpQuota[],
77
+ provider: string | null,
78
+ ): QuotaSeverity {
79
+ const used = quotasForProvider(quotas, provider).flatMap((quota) =>
80
+ quota.usedFraction === null ? [] : [quota.usedFraction],
81
+ );
82
+ return used.length === 0 ? "unknown" : quotaSeverityFromFraction(Math.max(...used));
83
+ }
84
+
85
+ export type QuotaProviderGroup = {
86
+ provider: string;
87
+ quotas: OmpQuota[];
88
+ peakFraction: number | null;
89
+ severity: QuotaSeverity;
90
+ };
91
+
92
+ /** Groups every recorded provider (not just the active session's) so a popover can show the
93
+ * full comparison a user needs to decide which provider to switch to. The active session's
94
+ * provider always sorts first; the rest fall back to worst-quota-first. */
95
+ export function quotaProviderGroups(
96
+ quotas: readonly OmpQuota[],
97
+ currentProvider: string | null,
98
+ ): QuotaProviderGroup[] {
99
+ const byProvider = new Map<string, OmpQuota[]>();
100
+ for (const quota of quotas) {
101
+ const group = byProvider.get(quota.provider);
102
+ if (group) group.push(quota);
103
+ else byProvider.set(quota.provider, [quota]);
104
+ }
105
+ const groups = [...byProvider.entries()].map(([provider, providerQuotas]) => {
106
+ const used = providerQuotas.flatMap((quota) =>
107
+ quota.usedFraction === null ? [] : [quota.usedFraction],
108
+ );
109
+ const peakFraction = used.length === 0 ? null : Math.max(...used);
110
+ return {
111
+ provider,
112
+ quotas: [...providerQuotas].sort((a, b) => (b.usedFraction ?? -1) - (a.usedFraction ?? -1)),
113
+ peakFraction,
114
+ severity: quotaSeverityFromFraction(peakFraction),
115
+ };
116
+ });
117
+ groups.sort((a, b) => {
118
+ if (a.provider === currentProvider) return -1;
119
+ if (b.provider === currentProvider) return 1;
120
+ return (b.peakFraction ?? -1) - (a.peakFraction ?? -1);
121
+ });
122
+ return groups;
123
+ }
124
+
125
+ export function quotaResetLabel(resetsAtMs: number | null, nowMs: number = Date.now()): string {
126
+ if (resetsAtMs === null) return "";
127
+ const remainingMs = Math.max(0, resetsAtMs - nowMs);
128
+ const hours = Math.floor(remainingMs / 3_600_000);
129
+ const minutes = Math.floor((remainingMs % 3_600_000) / 60_000);
130
+ return hours > 0 ? `resets ${hours}h ${minutes}m` : `resets ${minutes}m`;
131
+ }