@omercnet/paseo-omp 0.2.1-next.72.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 (78) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +120 -0
  4. package/SUPPORT.md +42 -0
  5. package/TESTING.md +150 -0
  6. package/client/composer-pill-settings.tsx +157 -0
  7. package/client/hub-icon.tsx +12 -0
  8. package/client/hub-popover.tsx +132 -0
  9. package/client/hub-status.ts +29 -0
  10. package/client/mcp-authorization.tsx +168 -0
  11. package/client/mcp-popover.tsx +155 -0
  12. package/client/memory-panel.tsx +76 -0
  13. package/client/memory-popover.tsx +74 -0
  14. package/client/omp-config-surface.tsx +1433 -0
  15. package/client/omp-doc-links.ts +117 -0
  16. package/client/omp-plugin-manager.tsx +1004 -0
  17. package/client/omp-store-picker.tsx +89 -0
  18. package/client/omp-store-state.ts +45 -0
  19. package/client/provider-diagnostics-state.ts +262 -0
  20. package/client/provider-icon.tsx +27 -0
  21. package/client/provider-image.tsx +66 -0
  22. package/client/quota-popover.tsx +155 -0
  23. package/client/quota-state.ts +140 -0
  24. package/client/sessions-popover.tsx +78 -0
  25. package/docs/alpha-release-checklist.md +68 -0
  26. package/docs/configuration.md +126 -0
  27. package/docs/core-provider-issue-audit.md +108 -0
  28. package/docs/images/mcp-authorization-compact.png +0 -0
  29. package/docs/images/mcp-controls-wide.png +0 -0
  30. package/docs/images/plugin-manager.png +0 -0
  31. package/docs/images/workspace-settings.png +0 -0
  32. package/docs/installation.md +67 -0
  33. package/index.client.tsx +488 -0
  34. package/index.server.ts +81 -0
  35. package/package.json +84 -0
  36. package/paseo-plugin.json +5 -0
  37. package/scripts/prepare-dependencies.mjs +20 -0
  38. package/server/hub.ts +145 -0
  39. package/server/mcp-browser.ts +95 -0
  40. package/server/memory.ts +86 -0
  41. package/server/mutation-queue.ts +12 -0
  42. package/server/omp-config.ts +135 -0
  43. package/server/omp-plugins.ts +676 -0
  44. package/server/omp-settings.ts +499 -0
  45. package/server/paths.ts +181 -0
  46. package/server/provider/catalog.ts +172 -0
  47. package/server/provider/config-normalization.ts +148 -0
  48. package/server/provider/connection.ts +1196 -0
  49. package/server/provider/host-tools.ts +777 -0
  50. package/server/provider/image.ts +143 -0
  51. package/server/provider/mcp-transport.ts +394 -0
  52. package/server/provider/omp-rpc.ts +2806 -0
  53. package/server/provider/omp.svg +5 -0
  54. package/server/provider/profile-providers.ts +249 -0
  55. package/server/provider/provider-options.ts +27 -0
  56. package/server/provider/registration.ts +162 -0
  57. package/server/provider/security.ts +317 -0
  58. package/server/provider/session-descriptors.ts +736 -0
  59. package/server/provider/session.ts +4796 -0
  60. package/server/provider/settings.ts +78 -0
  61. package/server/provider/subsessions.ts +850 -0
  62. package/server/provider/timeline-projector.ts +1801 -0
  63. package/server/provider-diagnostics.ts +1143 -0
  64. package/server/quota.ts +55 -0
  65. package/server/sessions.ts +58 -0
  66. package/shared/composer-pill-settings.ts +28 -0
  67. package/shared/hub.ts +43 -0
  68. package/shared/mcp.ts +47 -0
  69. package/shared/memory.ts +24 -0
  70. package/shared/omp-config.ts +85 -0
  71. package/shared/omp-plugins.ts +264 -0
  72. package/shared/omp-settings.ts +214 -0
  73. package/shared/omp-store.ts +58 -0
  74. package/shared/provider-diagnostics.ts +126 -0
  75. package/shared/provider-image.ts +160 -0
  76. package/shared/quota.ts +23 -0
  77. package/shared/sessions.ts +24 -0
  78. package/tsconfig.json +16 -0
@@ -0,0 +1,89 @@
1
+ import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { Pressable, Text, View } from "react-native";
4
+ import { listOmpStores, type OmpStore, storeLabel } from "../shared/omp-store";
5
+ import { ompStoreKey } from "./omp-store-state";
6
+
7
+ export function OmpStorePicker({
8
+ theme,
9
+ store,
10
+ onChange,
11
+ disabled = false,
12
+ }: {
13
+ theme: PluginSurfaceProps["theme"];
14
+ store?: OmpStore;
15
+ onChange(store: OmpStore | undefined): void;
16
+ disabled?: boolean;
17
+ }) {
18
+ const loadStores = useRpc(listOmpStores);
19
+ const stores = useQuery({
20
+ queryKey: ["paseo-omp", "stores"],
21
+ queryFn: () => loadStores({}),
22
+ staleTime: 30_000,
23
+ });
24
+ const profiles = [
25
+ ...new Set([...(stores.data?.profiles ?? []), ...(store?.profile ? [store.profile] : [])]),
26
+ ];
27
+ const choices: Array<OmpStore | undefined> = [
28
+ undefined,
29
+ ...profiles.map((profile) => ({ profile })),
30
+ ];
31
+ if (store?.agentDir) choices.push(store);
32
+ return (
33
+ <View style={{ gap: 8 }}>
34
+ <Text style={{ color: theme.colors.foreground, fontWeight: "600" }}>OMP store</Text>
35
+ <View
36
+ accessibilityRole="radiogroup"
37
+ style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}
38
+ >
39
+ {choices.map((choice) => {
40
+ const selected = ompStoreKey(choice) === ompStoreKey(store);
41
+ return (
42
+ <Pressable
43
+ key={ompStoreKey(choice)}
44
+ accessibilityRole="radio"
45
+ accessibilityLabel={storeLabel(choice)}
46
+ accessibilityState={{ checked: selected, disabled }}
47
+ aria-checked={selected}
48
+ aria-disabled={disabled}
49
+ disabled={disabled}
50
+ onPress={() => onChange(choice)}
51
+ style={{
52
+ borderWidth: 1,
53
+ borderRadius: 8,
54
+ paddingHorizontal: 10,
55
+ paddingVertical: 8,
56
+ borderColor: selected ? theme.colors.accent : theme.colors.border,
57
+ backgroundColor: theme.colors.surface1,
58
+ opacity: disabled ? 0.5 : 1,
59
+ }}
60
+ >
61
+ <Text
62
+ style={{ color: theme.colors.foreground, fontWeight: selected ? "600" : "400" }}
63
+ >
64
+ {selected ? "✓ " : ""}
65
+ {storeLabel(choice)}
66
+ </Text>
67
+ </Pressable>
68
+ );
69
+ })}
70
+ </View>
71
+ {stores.isLoading ? (
72
+ <Text style={{ color: theme.colors.foregroundMuted }}>Loading profiles…</Text>
73
+ ) : null}
74
+ {stores.error ? (
75
+ <Text style={{ color: theme.colors.statusDanger }}>
76
+ Could not list OMP profiles.{" "}
77
+ <Text
78
+ onPress={() => {
79
+ void stores.refetch();
80
+ }}
81
+ accessibilityRole="button"
82
+ >
83
+ Retry
84
+ </Text>
85
+ </Text>
86
+ ) : null}
87
+ </View>
88
+ );
89
+ }
@@ -0,0 +1,45 @@
1
+ import { type OmpStore, storeForProvider } from "../shared/omp-store";
2
+ import type { OmpQuota } from "../shared/quota";
3
+
4
+ export function ompStoreKey(store?: OmpStore): string {
5
+ return store?.profile
6
+ ? `profile:${store.profile}`
7
+ : store?.agentDir
8
+ ? `directory:${store.agentDir}`
9
+ : "default";
10
+ }
11
+
12
+ export function isOmpPluginProvider(provider?: string): boolean {
13
+ return provider === "omp-plugin" || storeForProvider(provider) !== undefined;
14
+ }
15
+
16
+ export function isOmpProvider(provider?: string): boolean {
17
+ return provider === "omp" || isOmpPluginProvider(provider);
18
+ }
19
+
20
+ /** Each store owns its own result and in-flight read. Failed reads never become empty success. */
21
+ export function createStoreQuotaLoader(
22
+ load: (input: { store?: OmpStore }) => Promise<{ quotas: OmpQuota[] }>,
23
+ maxAgeMs: number,
24
+ now = Date.now,
25
+ ) {
26
+ const cache = new Map<string, { value: { quotas: OmpQuota[] }; at: number }>();
27
+ const pending = new Map<string, Promise<{ quotas: OmpQuota[] }>>();
28
+ return (store?: OmpStore): Promise<{ quotas: OmpQuota[] }> => {
29
+ const key = ompStoreKey(store);
30
+ const cached = cache.get(key);
31
+ if (cached && now() - cached.at < maxAgeMs) return Promise.resolve(cached.value);
32
+ const current = pending.get(key);
33
+ if (current) return current;
34
+ const request = load({ store })
35
+ .then((value) => {
36
+ cache.set(key, { value, at: now() });
37
+ return value;
38
+ })
39
+ .finally(() => {
40
+ pending.delete(key);
41
+ });
42
+ pending.set(key, request);
43
+ return request;
44
+ };
45
+ }
@@ -0,0 +1,262 @@
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
+ import { isOmpProvider } from "./omp-store-state";
13
+
14
+ export type ProviderHealthTone = "ok" | "warning" | "danger" | "muted";
15
+ export const OMP_PROVIDER_IDS = ["omp", "omp-plugin"] as const;
16
+
17
+ export function isUnsupportedHostError(error: unknown): boolean {
18
+ if (typeof error !== "object" || error === null) return false;
19
+ const candidate = error as { code?: unknown; name?: unknown };
20
+ return (
21
+ candidate.name === "PaseoUpdateHostError" ||
22
+ candidate.code === "UPDATE_HOST_REQUIRED" ||
23
+ candidate.code === "UNSUPPORTED_FEATURE"
24
+ );
25
+ }
26
+
27
+ type ProviderActions = Pick<PaseoApi["providers"], "refresh" | "snapshot" | "waitForReady">;
28
+
29
+ /** Initial and post-refresh discovery waits for a settled snapshot; only known old-host errors
30
+ * fall back to the immediate snapshot API. */
31
+ export async function loadReadyProviderSnapshot(
32
+ providers: ProviderActions,
33
+ ): Promise<PaseoProviderSnapshotResult> {
34
+ try {
35
+ return await providers.waitForReady({ timeoutMs: 60_000 });
36
+ } catch (error) {
37
+ if (!isUnsupportedHostError(error)) throw error;
38
+ return providers.snapshot({});
39
+ }
40
+ }
41
+
42
+ export interface RefreshDiagnosticsOptions {
43
+ providers: ProviderActions;
44
+ providerIds?: readonly string[];
45
+ loadForcedHealth(): Promise<OmpProviderHealth>;
46
+ cacheHealth(health: OmpProviderHealth): void;
47
+ cacheProviders(snapshot: PaseoProviderSnapshotResult): void;
48
+ }
49
+
50
+ /** Provider refresh and forced health run independently. Each successful result reaches its
51
+ * cache even when another branch fails; the boolean reports any non-suppressed partial failure. */
52
+ export async function refreshProviderDiagnostics(
53
+ options: RefreshDiagnosticsOptions,
54
+ ): Promise<{ failed: boolean }> {
55
+ const [providerRefresh, forcedHealth] = await Promise.allSettled([
56
+ options.providers.refresh({
57
+ providers: [
58
+ ...new Set([...OMP_PROVIDER_IDS, ...(options.providerIds ?? []).filter(isOmpProvider)]),
59
+ ],
60
+ }),
61
+ options.loadForcedHealth(),
62
+ ]);
63
+ if (forcedHealth.status === "fulfilled") options.cacheHealth(forcedHealth.value);
64
+
65
+ const providerSnapshot = await Promise.allSettled([loadReadyProviderSnapshot(options.providers)]);
66
+ if (providerSnapshot[0].status === "fulfilled") {
67
+ options.cacheProviders(providerSnapshot[0].value);
68
+ }
69
+ const providerRefreshFailed =
70
+ providerRefresh.status === "rejected" && !isUnsupportedHostError(providerRefresh.reason);
71
+ return {
72
+ failed:
73
+ providerRefreshFailed ||
74
+ forcedHealth.status === "rejected" ||
75
+ providerSnapshot[0].status === "rejected",
76
+ };
77
+ }
78
+
79
+ const VERSION_STATUS_LABELS: Record<OmpVersionStatus, string> = {
80
+ ok: "Installed",
81
+ "not-found": "Not installed",
82
+ unrunnable: "Found but could not run",
83
+ timeout: "Version check timed out",
84
+ "probe-failed": "Version check failed",
85
+ malformed: "Unrecognized version output",
86
+ };
87
+
88
+ const VERSION_STATUS_TONES: Record<OmpVersionStatus, ProviderHealthTone> = {
89
+ ok: "ok",
90
+ "not-found": "danger",
91
+ unrunnable: "danger",
92
+ timeout: "warning",
93
+ "probe-failed": "warning",
94
+ malformed: "warning",
95
+ };
96
+
97
+ export function formatOmpVersion(version: OmpVersion): string {
98
+ const core = `${version.major}.${version.minor}.${version.patch}`;
99
+ return version.prerelease ? `${core}-${version.prerelease}` : core;
100
+ }
101
+
102
+ export interface BinaryHealthSummary {
103
+ label: string;
104
+ tone: ProviderHealthTone;
105
+ }
106
+
107
+ /** Combines version status and the parsed version into one display-ready label and tone. */
108
+ export function summarizeBinaryHealth(binary: OmpProviderHealth["binary"]): BinaryHealthSummary {
109
+ const label =
110
+ binary.versionStatus === "ok" && binary.version
111
+ ? `${VERSION_STATUS_LABELS.ok} (${formatOmpVersion(binary.version)})`
112
+ : VERSION_STATUS_LABELS[binary.versionStatus];
113
+ return { label, tone: VERSION_STATUS_TONES[binary.versionStatus] };
114
+ }
115
+
116
+ /** Distinguishes "not supported" from "we could not tell" so the UI never overclaims. */
117
+ export function summarizeRpcUiSupport(rpcUi: OmpProviderHealth["rpcUi"]): string {
118
+ if (!rpcUi.checked) return "Unknown (omp binary unavailable)";
119
+ if (rpcUi.supported === null) return "Unknown (probe failed, empty, or truncated)";
120
+ return rpcUi.supported ? "Supported" : "Not advertised by this build";
121
+ }
122
+
123
+ export function rpcUiTone(rpcUi: OmpProviderHealth["rpcUi"]): ProviderHealthTone {
124
+ if (!rpcUi.checked || rpcUi.supported === null) return "muted";
125
+ return rpcUi.supported ? "ok" : "muted";
126
+ }
127
+
128
+ export function summarizeLspSupport(lsp: OmpLspDiagnostics): string {
129
+ if (lsp.status === "supported") return "Supported";
130
+ if (lsp.status === "not-advertised") return "Not advertised by this build";
131
+ return "Unknown (probe failed, empty, or truncated)";
132
+ }
133
+
134
+ export function lspTone(lsp: OmpLspDiagnostics): ProviderHealthTone {
135
+ return lsp.status === "supported" ? "ok" : "muted";
136
+ }
137
+
138
+ export function summarizeMcpDiagnostics(mcp: OmpMcpDiagnostics): string {
139
+ if (mcp.status === "configured") return `${mcp.serverCount ?? 0} configured`;
140
+ const label =
141
+ mcp.status === "unavailable"
142
+ ? "Unavailable"
143
+ : mcp.status === "unreadable"
144
+ ? "Unreadable"
145
+ : mcp.status === "wrong-type"
146
+ ? "Wrong type"
147
+ : "Invalid";
148
+ return `${label} (${mcp.reason ?? "no detail"})`;
149
+ }
150
+
151
+ export function mcpTone(mcp: OmpMcpDiagnostics): ProviderHealthTone {
152
+ if (mcp.status === "configured") return "ok";
153
+ if (mcp.status === "unavailable") return "muted";
154
+ return "warning";
155
+ }
156
+
157
+ export function summarizeProcessDiagnostics(diagnostics: OmpProcessDiagnostics): string {
158
+ if (diagnostics.status === "unavailable") return "No hub run directory found";
159
+ if (diagnostics.status === "unknown") return "Unknown (could not read the hub run directory)";
160
+ const count = diagnostics.trackedCount ?? 0;
161
+ const statesKnown =
162
+ diagnostics.activeCount != null &&
163
+ diagnostics.historicalCount != null &&
164
+ diagnostics.unknownCount != null;
165
+ const detail = statesKnown
166
+ ? `${diagnostics.activeCount} active-state, ${diagnostics.historicalCount} historical, ${diagnostics.unknownCount} unknown`
167
+ : "states not reported";
168
+ return `${count} metadata records (${detail}${diagnostics.status === "partial" ? "; partial access" : ""}); live processes not verified`;
169
+ }
170
+
171
+ export function processTone(diagnostics: OmpProcessDiagnostics): ProviderHealthTone {
172
+ if (diagnostics.status === "unknown") return "warning";
173
+ if (diagnostics.status === "partial") return "warning";
174
+ if (diagnostics.status === "unavailable") return "muted";
175
+ return "muted";
176
+ }
177
+
178
+ const PATH_STATE_LABELS: Record<PathState, string> = {
179
+ available: "Found",
180
+ missing: "Missing",
181
+ unreadable: "Unreadable",
182
+ invalid: "Invalid",
183
+ "wrong-type": "Wrong type on disk",
184
+ };
185
+
186
+ const PATH_STATE_TONES: Record<PathState, ProviderHealthTone> = {
187
+ available: "ok",
188
+ missing: "danger",
189
+ unreadable: "warning",
190
+ invalid: "warning",
191
+ "wrong-type": "warning",
192
+ };
193
+
194
+ export interface PathStateSummary {
195
+ label: string;
196
+ tone: ProviderHealthTone;
197
+ }
198
+
199
+ /** Never collapses unreadable/invalid/wrong-type into missing; each state gets its own label. */
200
+ export function summarizePathState(state: PathState): PathStateSummary {
201
+ return { label: PATH_STATE_LABELS[state], tone: PATH_STATE_TONES[state] };
202
+ }
203
+
204
+ /**
205
+ * A null memory backend is ambiguous on its own: the config could be genuinely unset, or simply
206
+ * unavailable/invalid/wrong-type. Only a truly "available" config licenses "Not configured";
207
+ * every other state reports its own unavailability instead of guessing.
208
+ */
209
+ export function summarizeMemoryBackend(health: OmpProviderHealth): string {
210
+ if (health.roots.configState !== "available") {
211
+ return `Unknown (config ${PATH_STATE_LABELS[health.roots.configState].toLowerCase()})`;
212
+ }
213
+ return health.memoryBackend ?? "Not configured";
214
+ }
215
+
216
+ export interface KnownOmpProviderSummary {
217
+ id: string;
218
+ label: string;
219
+ status: string;
220
+ enabled: boolean;
221
+ }
222
+
223
+ export interface ProviderStatusSummary {
224
+ label: string;
225
+ tone: ProviderHealthTone;
226
+ }
227
+
228
+ export function summarizeProviderStatus(
229
+ provider: Pick<KnownOmpProviderSummary, "enabled" | "status">,
230
+ ): ProviderStatusSummary {
231
+ if (!provider.enabled) return { label: "Disabled", tone: "muted" };
232
+ switch (provider.status) {
233
+ case "ready":
234
+ return { label: "Ready", tone: "ok" };
235
+ case "loading":
236
+ return { label: "Loading", tone: "warning" };
237
+ case "error":
238
+ return { label: "Error", tone: "danger" };
239
+ case "unavailable":
240
+ return { label: "Unavailable", tone: "danger" };
241
+ default:
242
+ return { label: "Unknown", tone: "muted" };
243
+ }
244
+ }
245
+
246
+ /** Narrows a full provider snapshot to the native and plugin OMP identities. */
247
+ export function selectKnownOmpProviders(
248
+ entries: readonly PaseoProviderSnapshotResult["entries"][number][],
249
+ ): KnownOmpProviderSummary[] {
250
+ return entries.flatMap((entry) =>
251
+ isOmpProvider(entry.provider)
252
+ ? [
253
+ {
254
+ id: entry.provider,
255
+ label: entry.label ?? entry.provider,
256
+ status: entry.status,
257
+ enabled: entry.enabled ?? true,
258
+ },
259
+ ]
260
+ : [],
261
+ );
262
+ }
@@ -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,155 @@
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 { storeForProvider, storeLabel } from "../shared/omp-store";
7
+ import { listOmpQuotas } from "../shared/quota";
8
+ import { ompStoreKey } from "./omp-store-state";
9
+ import {
10
+ type QuotaProviderGroup,
11
+ quotaProviderFromSession,
12
+ quotaProviderGroups,
13
+ quotaProviderIconName,
14
+ quotaProviderLabel,
15
+ quotaResetLabel,
16
+ quotaSeverityFromFraction,
17
+ } from "./quota-state";
18
+
19
+ const QUOTA_POLL_MS = 30_000;
20
+
21
+ export function QuotaPopover(props: PluginButtonContentProps) {
22
+ const { theme, layout } = props;
23
+ const agentId = props.context === "agent" ? props.agentId : "";
24
+ const session = useAgent(agentId, (agent) => ({ model: agent.model, provider: agent.provider }));
25
+ const currentProvider = quotaProviderFromSession(session?.provider ?? "", session?.model ?? null);
26
+ const store = storeForProvider(session?.provider);
27
+ const loadQuotas = useRpc(listOmpQuotas);
28
+ const quotas = useQuery({
29
+ queryKey: ["paseo-omp", "quotas", ompStoreKey(store)],
30
+ queryFn: () => loadQuotas({ store }),
31
+ enabled: session !== undefined && session !== null,
32
+ refetchInterval: QUOTA_POLL_MS,
33
+ });
34
+
35
+ function severityColor(severity: ReturnType<typeof quotaSeverityFromFraction>): string {
36
+ if (severity === "danger") return theme.colors.statusDanger;
37
+ if (severity === "warning") return theme.colors.statusWarning;
38
+ if (severity === "ok") return theme.colors.statusSuccess;
39
+ return theme.colors.foregroundMuted;
40
+ }
41
+
42
+ const styles = useMemo(
43
+ () => ({
44
+ root: { gap: layout.compact ? 8 : 10, minWidth: layout.compact ? undefined : 260 },
45
+ muted: { color: theme.colors.foregroundMuted, fontSize: 13 },
46
+ error: { color: theme.colors.statusDanger, fontSize: 13 },
47
+ group: (current: boolean) => ({
48
+ gap: 6,
49
+ padding: layout.compact ? 10 : 12,
50
+ borderRadius: 10,
51
+ borderWidth: current ? 2 : 1,
52
+ borderColor: current ? theme.colors.accent : theme.colors.border,
53
+ backgroundColor: theme.colors.surface1,
54
+ }),
55
+ groupHeader: { flexDirection: "row" as const, alignItems: "center" as const, gap: 6 },
56
+ groupLabel: {
57
+ color: theme.colors.foreground,
58
+ fontSize: 14,
59
+ fontWeight: "700" as const,
60
+ flex: 1,
61
+ },
62
+ badge: {
63
+ color: theme.colors.accentForeground,
64
+ backgroundColor: theme.colors.accent,
65
+ fontSize: 10,
66
+ fontWeight: "700" as const,
67
+ paddingHorizontal: 6,
68
+ paddingVertical: 2,
69
+ borderRadius: 999,
70
+ overflow: "hidden" as const,
71
+ },
72
+ row: { gap: 3 },
73
+ rowHeader: {
74
+ flexDirection: "row" as const,
75
+ justifyContent: "space-between" as const,
76
+ gap: 8,
77
+ },
78
+ rowLabel: { color: theme.colors.foreground, fontSize: 12, flex: 1 },
79
+ rowValue: { fontSize: 12, fontWeight: "600" as const },
80
+ track: {
81
+ height: 5,
82
+ borderRadius: 3,
83
+ backgroundColor: theme.colors.surface2,
84
+ overflow: "hidden" as const,
85
+ },
86
+ detail: { color: theme.colors.foregroundMuted, fontSize: 11 },
87
+ }),
88
+ [layout.compact, theme],
89
+ );
90
+
91
+ if (!session || quotas.isLoading) return <Text style={styles.muted}>Loading omp quotas…</Text>;
92
+ if (quotas.error) return <Text style={styles.error}>Could not read omp quota state.</Text>;
93
+
94
+ const groups = quotaProviderGroups(quotas.data?.quotas ?? [], currentProvider);
95
+ const hasCurrent = groups.some((group) => group.provider === currentProvider);
96
+
97
+ if (groups.length === 0) {
98
+ return <Text style={styles.muted}>No provider quota data available.</Text>;
99
+ }
100
+
101
+ return (
102
+ <View style={styles.root}>
103
+ <Text style={styles.muted}>{storeLabel(store)}</Text>
104
+ {currentProvider && !hasCurrent ? (
105
+ <Text style={styles.muted}>
106
+ {`No recorded quota yet for ${quotaProviderLabel(currentProvider)} (this session's provider).`}
107
+ </Text>
108
+ ) : null}
109
+ {groups.map((group: QuotaProviderGroup) => {
110
+ const current = group.provider === currentProvider;
111
+ return (
112
+ <View key={group.provider} style={styles.group(current)}>
113
+ <View style={styles.groupHeader}>
114
+ <Icon
115
+ name={quotaProviderIconName(group.provider)}
116
+ size={16}
117
+ color={severityColor(group.severity)}
118
+ />
119
+ <Text style={styles.groupLabel}>{quotaProviderLabel(group.provider)}</Text>
120
+ {current ? <Text style={styles.badge}>CURRENT</Text> : null}
121
+ </View>
122
+ {group.quotas.map((quota) => {
123
+ const severity = quotaSeverityFromFraction(quota.usedFraction);
124
+ const color = severityColor(severity);
125
+ const pct =
126
+ quota.usedFraction === null
127
+ ? 0
128
+ : Math.min(100, Math.round(quota.usedFraction * 100));
129
+ return (
130
+ <View key={`${quota.label}:${quota.windowLabel}`} style={styles.row}>
131
+ <View style={styles.rowHeader}>
132
+ <Text numberOfLines={1} style={styles.rowLabel}>
133
+ {quota.label}
134
+ </Text>
135
+ <Text style={[styles.rowValue, { color }]}>
136
+ {quota.usedFraction === null ? "Unknown" : `${pct}%`}
137
+ </Text>
138
+ </View>
139
+ <View style={styles.track}>
140
+ <View style={{ height: "100%", width: `${pct}%`, backgroundColor: color }} />
141
+ </View>
142
+ <Text style={styles.detail}>
143
+ {[quota.windowLabel, quotaResetLabel(quota.resetsAt)]
144
+ .filter(Boolean)
145
+ .join(" · ")}
146
+ </Text>
147
+ </View>
148
+ );
149
+ })}
150
+ </View>
151
+ );
152
+ })}
153
+ </View>
154
+ );
155
+ }