@xpufx/paseo-slash 0.1.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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/client/commands.ts +122 -0
  4. package/client/console.tsx +484 -0
  5. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  6. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  7. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  8. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  9. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  10. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  11. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  12. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  13. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  14. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  15. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  16. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  17. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  18. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  19. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  20. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  21. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  22. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  23. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  24. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  25. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  26. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  27. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  28. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  29. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  30. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  31. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  32. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  33. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  34. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  35. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  36. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  37. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  38. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  39. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  40. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  41. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  42. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  43. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  44. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  45. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  46. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  47. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  48. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  49. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  50. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  51. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  52. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  53. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  54. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  55. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  56. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  57. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  58. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  59. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  60. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  61. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  62. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  63. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  64. package/docs/screenshots/slash-autocomplete.jpg +0 -0
  65. package/docs/screenshots/slash-console.jpg +0 -0
  66. package/package.json +27 -0
  67. package/paseo-plugin.json +4 -0
  68. package/server/orchestrate.ts +81 -0
  69. package/server/resources.ts +132 -0
  70. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  71. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  72. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  73. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  74. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  75. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  76. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  77. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  78. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  79. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  80. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  81. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  82. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  83. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  84. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  85. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  86. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  87. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  88. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  89. package/shared/resources.ts +289 -0
  90. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  91. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  92. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  93. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  94. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  95. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  96. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  97. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  98. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  99. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  100. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  101. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  102. package/shared/version.ts +2 -0
@@ -0,0 +1,884 @@
1
+ import React, {
2
+ useEffect,
3
+ useLayoutEffect,
4
+ useMemo,
5
+ useState,
6
+ useSyncExternalStore,
7
+ type ComponentType,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { StyleSheet, Text, useWindowDimensions, View } from "react-native";
11
+ import {
12
+ getClientHost,
13
+ type ComposerPillRegistrar,
14
+ type ComposerPillRegistration,
15
+ type ComposerPillRegistrationHandle,
16
+ type HostLayout,
17
+ type HostPillProps,
18
+ type HostTheme,
19
+ type PluginCleanup,
20
+ } from "./host";
21
+ import { PluginThemeProvider } from "./theme/provider";
22
+ import { useResponsive } from "./theme/useResponsive";
23
+ import type { VisualFlair } from "./theme/flair";
24
+ import { ModalBodyScrollOwnerContext } from "./layout/ModalBody";
25
+ import { reportSuppressed } from "../../../shared/vendor/paseo-plugin-helper/suppressed";
26
+
27
+ export interface RenderPillProps<TPayload = any> extends HostPillProps {
28
+ isOpen: boolean;
29
+ open: (payload?: TPayload) => void;
30
+ close: () => void;
31
+ toggle: (payload?: TPayload) => void;
32
+ }
33
+
34
+ export interface RenderModalProps<TPayload = any> extends HostPillProps {
35
+ close: () => void;
36
+ payload?: TPayload;
37
+ }
38
+
39
+ export interface PillLiveContext {
40
+ agentId: string;
41
+ workspaceId: string;
42
+ }
43
+
44
+ export interface PillLivePayload {
45
+ label?: string;
46
+ icon?: string;
47
+ }
48
+
49
+ export type PillLabelResolver = (
50
+ context: PillLiveContext,
51
+ ) =>
52
+ | string
53
+ | PillLivePayload
54
+ | undefined
55
+ | Promise<string | PillLivePayload | undefined>;
56
+
57
+ export type PillIconResolver = (
58
+ context: PillLiveContext,
59
+ ) => string | undefined | Promise<string | undefined>;
60
+
61
+ /**
62
+ * Single precedence rule for pill modal scroll ownership (#219), shared by
63
+ * the centered and legacy modal wrappers so they cannot disagree.
64
+ * `true` delegates the scroller to the host `<Modal.Content>`; `false`
65
+ * (default) keeps the legacy bounded dialog for `ModalBody`-based content.
66
+ * Either way the wrapper renders exactly one `<Modal.Content>`.
67
+ */
68
+ export function resolvePillModalScrollable(hostScroll?: boolean): boolean {
69
+ return hostScroll === true;
70
+ }
71
+
72
+ let probeSequence = 0;
73
+
74
+ const NOOP_SUBSCRIBE = (): (() => void) => () => {};
75
+
76
+ export interface RegisterComposerPillOptions<TPayload = any> {
77
+ /**
78
+ * Unique ID for the pill (e.g. "paseo-top", "mcp-monitor").
79
+ */
80
+ id: string;
81
+
82
+ /**
83
+ * Title shown in the composer trackbar (keep concise, e.g. "top", "CPU 12%").
84
+ */
85
+ title: string;
86
+
87
+ /**
88
+ * Optional compact title shown in the composer trackbar when screen or track is narrow/mobile
89
+ * (when `layout.compact` is true). Defaults to `title`.\
90
+ */
91
+ compactTitle?: string;
92
+
93
+ /**
94
+ * Optional custom title shown in the modal header (defaults to `title`).
95
+ * Useful when the modal needs a full descriptive title (e.g. "Host System Resources").
96
+ */
97
+ modalTitle?: string;
98
+
99
+ /**
100
+ * Lucide icon name for the pill (e.g. "Cpu", "Server", "MessageSquare").
101
+ */
102
+ icon?: string;
103
+
104
+ /**
105
+ * Optional compact Lucide icon name shown when in compact mode. Defaults to `icon`.
106
+ */
107
+ compactIcon?: string;
108
+
109
+ /**
110
+ * Optional custom icon for the modal header. Can be a Lucide icon name string or a JSX element.
111
+ * If omitted, falls back to `icon`.
112
+ */
113
+ modalIcon?: string | ReactNode;
114
+
115
+ /**
116
+ * Optional visual flair preset or overrides for the plugin's theme.
117
+ */
118
+ flair?: Partial<VisualFlair>;
119
+
120
+ /**
121
+ * Optional custom badge text shown inside the default pill (e.g. "LIVE", "3").
122
+ */
123
+ badgeText?: string;
124
+
125
+ /**
126
+ * Optional compact badge text shown inside the default pill in compact mode. Defaults to `badgeText`.
127
+ */
128
+ compactBadgeText?: string;
129
+
130
+ /**
131
+ * Optional callback to resolve default payload when the outer host pill is clicked.
132
+ * Receives agentId and workspaceId.
133
+ */
134
+ resolveDefaultPayload?: (context: { agentId: string; workspaceId: string }) => TPayload | undefined;
135
+
136
+ /**
137
+ * Custom pill body renderer if you want to replace the default pill layout.
138
+ * Receives `isOpen`, `open`, `close`, and `toggle` along with standard pill props.\
139
+ */
140
+ renderPill?: (props: RenderPillProps<TPayload>) => ReactNode;
141
+
142
+ /**
143
+ * Resolves the live pill label (and optionally icon) on button-shaped hosts (Paseo 0.8+), where the
144
+ * pill body is host-rendered from a static `label` and `icon` string and `renderPill`
145
+ * never mounts. Called once at registration and then every
146
+ * `refreshIntervalMs`. Keep it cheap and synchronous when possible; async
147
+ * resolvers are awaited. Returning `undefined` leaves the current label/icon.
148
+ * Can return a plain string (label) or an object `{ label?: string; icon?: string }`.
149
+ * Cycle modes can advance rotation state on each call.
150
+ */
151
+ resolveLabel?: PillLabelResolver;
152
+
153
+ /**
154
+ * Optional standalone resolver for the button icon on button-shaped hosts (Paseo 0.8+).
155
+ * Evaluated alongside `resolveLabel` on each tick.
156
+ */
157
+ resolveIcon?: PillIconResolver;
158
+
159
+ /**
160
+ * Poll interval for `resolveLabel` on button-shaped hosts. Defaults to 5000ms
161
+ * when `resolveLabel` or `resolveIcon` is set. Set to 0 to resolve once at registration.
162
+ * Ignored on legacy hosts (their `renderPill` re-renders via React state).
163
+ */
164
+ refreshIntervalMs?: number;
165
+
166
+ /**
167
+ * Fixed width, in pixels, for the anchored popover on non-compact hosts.
168
+ *
169
+ * Without it the popover is sized from its content, so any content that
170
+ * reflows (a measured table, a responsive group, a gauge that settles) can
171
+ * resize the surface under the pointer. Pinning the width makes the frame the
172
+ * authority and lets the content overflow into its own scroll/clip instead.
173
+ *
174
+ * Ignored on compact hosts, where the same content renders in a full-bleed
175
+ * bottom sheet. Clamped to the window width so a fixed width cannot overflow
176
+ * a narrow viewport.
177
+ */
178
+ popoverWidth?: number;
179
+
180
+ /**
181
+ * Renders the content inside the controlled modal.
182
+ * Automatically wrapped with PluginThemeProvider and supplied with a `close()` helper and optional payload.
183
+ * On button-shaped hosts (Paseo 0.8+) the modal is replaced by an anchored
184
+ * popover rendering this same content at the host surface width (expect a
185
+ * narrow column, not a wide modal); keep content vertically stacked and
186
+ * reflowing. `open`/`toggle` from `renderPill` cannot drive host-owned
187
+ * popovers, so live pill text comes from `resolveLabel` instead.
188
+ *
189
+ * The wrapper renders exactly one host `<Modal.Content>` around this output
190
+ * on the modal paths (legacy + centered). Never render another
191
+ * `<Modal.Content>` here — use `HostModalSection` from
192
+ * `paseo-plugin-helper/ui` for fluid content. (`HostModalContent` is only
193
+ * for plugins that open their OWN host `<Modal>`.)
194
+ */
195
+ renderModal?: (props: RenderModalProps<TPayload>) => ReactNode;
196
+
197
+ /**
198
+ * Host-owned scroll for the pill modal paths (#219).
199
+ * - `false` (default, legacy): the wrapper renders
200
+ * `<Modal.Content scrollable={false}>` (bounded dialog) and
201
+ * `ModalBody`-based content owns the one scroller.
202
+ * - `true`: the wrapper renders `<Modal.Content scrollable={true}>` so the
203
+ * host scrolls, and `renderModal` must provide fluid content with NO
204
+ * nested `<Modal.Content>` or scroller (`HostModalSection`).
205
+ * Exactly one `<Modal.Content>` is rendered in both modes. The 0.8 popover
206
+ * path is unaffected (plain host-owned container either way).
207
+ */
208
+ hostScroll?: boolean;
209
+
210
+ /**
211
+ * Makes the pill an action button instead of a tethered popover: pressing it
212
+ * calls this and the host never mounts a popover. Use it to open a plugin
213
+ * surface (`openSurface(id)`), which the host renders outside the composer —
214
+ * an agent-stream re-render of the composer then cannot remount it. Ignored
215
+ * when unset, in which case `renderModal` renders the popover.
216
+ */
217
+ onPress?: () => void | Promise<void>;
218
+
219
+ /**
220
+ * How an open pill presents.
221
+ * - `"popover"` (default): the host anchors `renderModal` to the pill. The
222
+ * host may remount that subtree on every composer re-render, which can tear
223
+ * an open popover down.
224
+ * - `"centered"`: the host `Modal` is rendered from the pill's always-mounted
225
+ * icon and toggled by the pill press, so the surface is not a child of the
226
+ * composer popover and a composer re-render does not unmount it.
227
+ */
228
+ presentation?: "popover" | "centered";
229
+
230
+ /**
231
+ * Called when a pill cannot be registered on the current host (for example
232
+ * a host API mismatch). Reporting instead of throwing keeps the rest of the
233
+ * plugin client alive; render the message in your own panel to make it visible.
234
+ */
235
+ onError?: (info: { agentId: string; workspaceId: string; error: Error }) => void;
236
+ }
237
+
238
+ /**
239
+ * Registers an agent-scoped composer pill and modal lifecycle.
240
+ * Manages agent subscription events, unmount cleanup, and pill-to-modal activation.
241
+ *
242
+ * Works against both host generations: legacy `{Component, onPress}` pills
243
+ * (Paseo 0.7 and beta apps) and `button`-descriptor pills (Paseo 0.8+), detected
244
+ * once per call with a throwaway probe registration that is removed immediately.
245
+ */
246
+ export function registerComposerPill<TPayload = any>(
247
+ client: ComposerPillRegistrar,
248
+ options: RegisterComposerPillOptions<TPayload>,
249
+ ): PluginCleanup {
250
+ const { Icon, Modal } = getClientHost();
251
+ const openers = new Map<string, (payload?: TPayload) => void>();
252
+ const pills = new Map<
253
+ string,
254
+ {
255
+ dispose: () => void;
256
+ workspaceId: string;
257
+ registration?: ComposerPillRegistrationHandle;
258
+ }
259
+ >();
260
+ const pushedDescriptors = new WeakMap<object, { label?: string; icon?: string }>();
261
+ // A button-shaped pill can only be resolved while the host actually renders
262
+ // it. The host mounts the pill's custom icon component for every visible
263
+ // composer pill, and that mount is the plugin's only visibility signal: its
264
+ // mount/unmount brackets the pill's on-screen lifetime. Polling every
265
+ // registered agent regardless of that signal ran one ticker per agent in the
266
+ // install, which stampedes the daemon on a host with many agents.
267
+ const visiblePillMounts = new Map<string, number>();
268
+ // Agent ids whose anchored popover is currently mounted. While a popover is
269
+ // open the host re-renders the whole composer on every agent-stream message;
270
+ // rewriting the button entry at the same time makes the host remount the open
271
+ // popover. `mcp-tools` never updates its entry and its popover is stable, so
272
+ // keep the entry static while the popover is up — the label and icon are
273
+ // hidden behind it anyway. Updates resume from the next tick after it closes.
274
+ const openPopoverAgents = new Set<string>();
275
+ const iconValueByAgent = new Map<string, string>();
276
+ const iconListenersByAgent = new Map<string, Set<() => void>>();
277
+ // Open state for `presentation: "centered"`. It lives outside React so the
278
+ // centered Modal, which is rendered from the pill icon, survives any remount
279
+ // of the composer subtree the host might do.
280
+ const centeredOpenAgents = new Set<string>();
281
+ const centeredListenersByAgent = new Map<string, Set<() => void>>();
282
+ let sharedLabelTimer: ReturnType<typeof setInterval> | null = null;
283
+ let detectedShape: "button" | "legacy" | null = null;
284
+ let disposed = false;
285
+
286
+ function defaultPillIcon(): string {
287
+ if (typeof options.icon === "string") return options.icon;
288
+ if (typeof options.modalIcon === "string") return options.modalIcon;
289
+ return "Activity";
290
+ }
291
+
292
+ function readPillIcon(agentId: string): string {
293
+ return iconValueByAgent.get(agentId) ?? defaultPillIcon();
294
+ }
295
+
296
+ function subscribePillIcon(agentId: string): (listener: () => void) => () => void {
297
+ return (listener: () => void) => {
298
+ let listeners = iconListenersByAgent.get(agentId);
299
+ if (!listeners) {
300
+ listeners = new Set();
301
+ iconListenersByAgent.set(agentId, listeners);
302
+ }
303
+ listeners.add(listener);
304
+ return () => {
305
+ listeners?.delete(listener);
306
+ if (listeners && listeners.size === 0) iconListenersByAgent.delete(agentId);
307
+ };
308
+ };
309
+ }
310
+
311
+ function setPillIcon(agentId: string, icon: string): void {
312
+ if (iconValueByAgent.get(agentId) === icon) return;
313
+ iconValueByAgent.set(agentId, icon);
314
+ for (const listener of iconListenersByAgent.get(agentId) ?? []) listener();
315
+ }
316
+
317
+ function subscribeCenteredOpen(agentId: string): (listener: () => void) => () => void {
318
+ return (listener: () => void) => {
319
+ let listeners = centeredListenersByAgent.get(agentId);
320
+ if (!listeners) {
321
+ listeners = new Set();
322
+ centeredListenersByAgent.set(agentId, listeners);
323
+ }
324
+ listeners.add(listener);
325
+ return () => {
326
+ listeners?.delete(listener);
327
+ if (listeners && listeners.size === 0) centeredListenersByAgent.delete(agentId);
328
+ };
329
+ };
330
+ }
331
+
332
+ function setCenteredOpen(agentId: string, open: boolean): void {
333
+ if (centeredOpenAgents.has(agentId) === open) return;
334
+ if (open) centeredOpenAgents.add(agentId);
335
+ else centeredOpenAgents.delete(agentId);
336
+ for (const listener of centeredListenersByAgent.get(agentId) ?? []) listener();
337
+ }
338
+
339
+ function visibleAgentIds(): string[] {
340
+ const ids: string[] = [];
341
+ for (const [agentId, count] of visiblePillMounts) {
342
+ if (count > 0) ids.push(agentId);
343
+ }
344
+ return ids;
345
+ }
346
+
347
+ function syncSharedLabelTimer(): void {
348
+ const intervalMs = options.refreshIntervalMs ?? 5000;
349
+ const hasPoller = Boolean(options.resolveLabel || options.resolveIcon);
350
+ const shouldRun =
351
+ hasPoller && intervalMs > 0 && visibleAgentIds().some((agentId) => pills.has(agentId));
352
+ if (!shouldRun) {
353
+ if (sharedLabelTimer) {
354
+ clearInterval(sharedLabelTimer);
355
+ sharedLabelTimer = null;
356
+ }
357
+ return;
358
+ }
359
+ if (sharedLabelTimer) return;
360
+ sharedLabelTimer = setInterval(() => {
361
+ for (const agentId of visibleAgentIds()) {
362
+ const entry = pills.get(agentId);
363
+ if (entry?.registration) resolveAndPushLabel(agentId, entry.workspaceId, entry.registration);
364
+ }
365
+ }, intervalMs);
366
+ }
367
+
368
+ function markPillVisible(agentId: string): void {
369
+ visiblePillMounts.set(agentId, (visiblePillMounts.get(agentId) ?? 0) + 1);
370
+ const entry = pills.get(agentId);
371
+ if (entry?.registration) resolveAndPushLabel(agentId, entry.workspaceId, entry.registration);
372
+ syncSharedLabelTimer();
373
+ }
374
+
375
+ function markPillHidden(agentId: string): void {
376
+ const next = (visiblePillMounts.get(agentId) ?? 1) - 1;
377
+ if (next > 0) visiblePillMounts.set(agentId, next);
378
+ else visiblePillMounts.delete(agentId);
379
+ syncSharedLabelTimer();
380
+ }
381
+
382
+ /**
383
+ * The host-rendered trigger glyph doubles as the pill's visibility probe.
384
+ * Swapping the live icon is routed through `setPillIcon` rather than
385
+ * `registration.update({ icon })`, so the probe component never gets
386
+ * replaced by a plain string and the mount signal survives every icon swap.
387
+ */
388
+ function makePillIcon(agentId: string): ComponentType<{
389
+ size?: number;
390
+ color?: string;
391
+ theme?: HostTheme;
392
+ layout?: HostLayout;
393
+ host?: { id: string; label: string };
394
+ workspaceId?: string;
395
+ }> {
396
+ return function PillVisibilityIcon(props: {
397
+ size?: number;
398
+ color?: string;
399
+ theme?: HostTheme;
400
+ layout?: HostLayout;
401
+ host?: { id: string; label: string };
402
+ workspaceId?: string;
403
+ }) {
404
+ const subscribe = useMemo(() => subscribePillIcon(agentId), [agentId]);
405
+ const name = useSyncExternalStore(
406
+ subscribe,
407
+ () => readPillIcon(agentId),
408
+ () => readPillIcon(agentId),
409
+ );
410
+ const centered = options.presentation === "centered";
411
+ const subscribeCentered = useMemo(
412
+ () => (centered ? subscribeCenteredOpen(agentId) : NOOP_SUBSCRIBE),
413
+ [centered, agentId],
414
+ );
415
+ const centeredOpen = useSyncExternalStore(
416
+ subscribeCentered,
417
+ () => (centered ? centeredOpenAgents.has(agentId) : false),
418
+ () => (centered ? centeredOpenAgents.has(agentId) : false),
419
+ );
420
+ useEffect(() => {
421
+ markPillVisible(agentId);
422
+ return () => markPillHidden(agentId);
423
+ // agentId is fixed per pill; the icon component identity never changes.
424
+ // eslint-disable-next-line react-hooks/exhaustive-deps
425
+ }, [agentId]);
426
+
427
+ const icon = <Icon name={name} size={props.size ?? 14} color={props.color ?? ""} />;
428
+ const { theme, layout } = props;
429
+ if (!centered || !options.renderModal || !theme || !layout) return icon;
430
+
431
+ const modalIconElement = React.isValidElement(options.modalIcon) ? (
432
+ options.modalIcon
433
+ ) : (
434
+ <Icon
435
+ name={
436
+ typeof options.modalIcon === "string" ? options.modalIcon : (options.icon ?? "Activity")
437
+ }
438
+ size={16}
439
+ color={theme.colors.foreground}
440
+ />
441
+ );
442
+ return (
443
+ <>
444
+ {icon}
445
+ <Modal
446
+ title={options.modalTitle ?? options.title}
447
+ icon={modalIconElement}
448
+ open={centeredOpen}
449
+ onOpenChange={(nextOpen: boolean) => setCenteredOpen(agentId, nextOpen)}
450
+ >
451
+ <Modal.Content scrollable={resolvePillModalScrollable(options.hostScroll)}>
452
+ {centeredOpen ? (
453
+ <PluginThemeProvider theme={theme} layout={layout} flair={options.flair}>
454
+ {options.renderModal({
455
+ agentId,
456
+ workspaceId: props.workspaceId ?? "",
457
+ theme,
458
+ layout,
459
+ host: props.host ?? { id: "", label: "" },
460
+ close: () => setCenteredOpen(agentId, false),
461
+ })}
462
+ </PluginThemeProvider>
463
+ ) : null}
464
+ </Modal.Content>
465
+ </Modal>
466
+ </>
467
+ );
468
+ };
469
+ }
470
+
471
+ // 0.8 popover scroll ownership: Paseo's MenuSurface/FloatingScrollView or
472
+ // BottomSheetScrollView already owns the viewport. Keep this wrapper plain,
473
+ // unconstrained, and mark the subtree so ModalBody does not add a second
474
+ // ScrollView. Fixed heights or overflow clipping here cut off mobile content.
475
+ function PillPopoverContent(props: {
476
+ agentId: string;
477
+ workspaceId: string;
478
+ theme: HostTheme;
479
+ layout: HostLayout;
480
+ host?: { id: string; label: string };
481
+ close: () => void;
482
+ }) {
483
+ const { width: windowWidth } = useWindowDimensions();
484
+ // The anchored popover is otherwise sized from its content, so reflowing
485
+ // content can move the frame under the pointer. Pin it to an explicit width
486
+ // (clamped to the viewport) and let children overflow into their own
487
+ // scroll/clip. Compact hosts present a full-bleed sheet, so this is skipped.
488
+ const frameWidth =
489
+ !props.layout.compact && options.popoverWidth
490
+ ? Math.max(0, Math.min(options.popoverWidth, windowWidth - 24))
491
+ : undefined;
492
+ // Layout effect, not effect: the flag must be set in the same commit the
493
+ // popover mounts, before any label timer can interleave. An effect runs
494
+ // after paint, leaving a window where one tick can still publish and
495
+ // remount the popover.
496
+ useLayoutEffect(() => {
497
+ openPopoverAgents.add(props.agentId);
498
+ return () => {
499
+ openPopoverAgents.delete(props.agentId);
500
+ };
501
+ }, [props.agentId]);
502
+ const pillProps: HostPillProps = {
503
+ agentId: props.agentId,
504
+ workspaceId: props.workspaceId,
505
+ theme: props.theme,
506
+ layout: props.layout,
507
+ host: props.host ?? { id: "", label: "" },
508
+ };
509
+ return (
510
+ <PluginThemeProvider theme={props.theme} layout={props.layout} flair={options.flair}>
511
+ <View style={[styles.popoverContainer, frameWidth ? { width: frameWidth } : null]}>
512
+ <ModalBodyScrollOwnerContext.Provider value="host">
513
+ {options.renderModal?.({ ...pillProps, close: props.close })}
514
+ </ModalBodyScrollOwnerContext.Provider>
515
+ </View>
516
+ </PluginThemeProvider>
517
+ );
518
+ }
519
+
520
+ function PillHost(props: HostPillProps) {
521
+ const [open, setOpen] = useState(false);
522
+ const [payload, setPayload] = useState<TPayload | undefined>(undefined);
523
+
524
+ useEffect(() => {
525
+ openers.set(props.agentId, (incomingPayload?: TPayload) => {
526
+ setPayload(incomingPayload);
527
+ setOpen(true);
528
+ });
529
+ return () => {
530
+ openers.delete(props.agentId);
531
+ };
532
+ }, [props.agentId]);
533
+
534
+ const effectiveModalTitle = options.modalTitle ?? options.title;
535
+
536
+ const modalIconElement = useMemo(() => {
537
+ if (React.isValidElement(options.modalIcon)) {
538
+ return options.modalIcon;
539
+ }
540
+ const iconName =
541
+ typeof options.modalIcon === "string" ? options.modalIcon : options.icon;
542
+ if (iconName) {
543
+ return <Icon name={iconName} size={16} color={props.theme.colors.foreground} />;
544
+ }
545
+ return undefined;
546
+ }, [options.modalIcon, options.icon, props.theme.colors.foreground]);
547
+
548
+ const renderPillProps: RenderPillProps<TPayload> = {
549
+ ...props,
550
+ isOpen: open,
551
+ open: (customPayload?: TPayload) => {
552
+ setPayload(customPayload);
553
+ setOpen(true);
554
+ },
555
+ close: () => setOpen(false),
556
+ toggle: (customPayload?: TPayload) => {
557
+ setPayload(customPayload);
558
+ setOpen((prev) => !prev);
559
+ },
560
+ };
561
+
562
+ return (
563
+ <PluginThemeProvider theme={props.theme} layout={props.layout} flair={options.flair}>
564
+ {options.renderPill ? (
565
+ options.renderPill(renderPillProps)
566
+ ) : (
567
+ <DefaultPillBody
568
+ title={options.title}
569
+ compactTitle={options.compactTitle}
570
+ icon={options.icon}
571
+ compactIcon={options.compactIcon}
572
+ badgeText={options.badgeText}
573
+ compactBadgeText={options.compactBadgeText}
574
+ theme={props.theme}
575
+ />
576
+ )}
577
+
578
+ <Modal
579
+ title={effectiveModalTitle}
580
+ icon={modalIconElement}
581
+ open={open}
582
+ onOpenChange={(nextOpen) => {
583
+ setOpen(nextOpen);
584
+ if (!nextOpen) {
585
+ setPayload(undefined);
586
+ }
587
+ }}
588
+ >
589
+ <Modal.Content scrollable={resolvePillModalScrollable(options.hostScroll)}>
590
+ {open ? (
591
+ <PluginThemeProvider theme={props.theme} layout={props.layout} flair={options.flair}>
592
+ {options.renderModal?.({
593
+ ...props,
594
+ close: () => setOpen(false),
595
+ payload,
596
+ })}
597
+ </PluginThemeProvider>
598
+ ) : null}
599
+ </Modal.Content>
600
+
601
+ </Modal>
602
+ </PluginThemeProvider>
603
+ );
604
+ }
605
+
606
+ function toCleanup(registration: ComposerPillRegistration): PluginCleanup {
607
+ if (typeof registration === "function") return registration;
608
+ return () => registration.remove();
609
+ }
610
+
611
+ function reportError(agentId: string, workspaceId: string, error: unknown): void {
612
+ pills.set(agentId, {
613
+ dispose: () => {},
614
+ workspaceId,
615
+ });
616
+ // Surface even when the plugin wired no onError: a swallowed registration
617
+ // failure leaves the pill missing with no trace anywhere.
618
+ reportSuppressed(console, `registerComposerPill registration failed (agent ${agentId})`, error, "warn");
619
+ options.onError?.({
620
+ agentId,
621
+ workspaceId,
622
+ error: error instanceof Error ? error : new Error(String(error)),
623
+ });
624
+ }
625
+
626
+ function resolveAndPushLabel(
627
+ agentId: string,
628
+ workspaceId: string,
629
+ registration: ComposerPillRegistration,
630
+ ): void {
631
+ if (typeof registration === "function") return;
632
+ if (!options.resolveLabel && !options.resolveIcon) return;
633
+ const ctx = { agentId, workspaceId };
634
+ Promise.all([
635
+ options.resolveLabel ? Promise.resolve(options.resolveLabel(ctx)) : Promise.resolve(undefined),
636
+ options.resolveIcon ? Promise.resolve(options.resolveIcon(ctx)) : Promise.resolve(undefined),
637
+ ])
638
+ .then(([labelResult, iconResult]) => {
639
+ if (!pills.has(agentId)) return;
640
+ if (openPopoverAgents.has(agentId)) return;
641
+ const patch: { label?: string; icon?: string } = {};
642
+ if (typeof labelResult === "string") {
643
+ patch.label = labelResult;
644
+ } else if (labelResult && typeof labelResult === "object") {
645
+ if (labelResult.label !== undefined) patch.label = labelResult.label;
646
+ if (labelResult.icon !== undefined) patch.icon = labelResult.icon;
647
+ }
648
+ if (iconResult !== undefined) {
649
+ patch.icon = iconResult;
650
+ }
651
+ const previous = pushedDescriptors.get(registration as object) ?? {};
652
+ const labelChanged = patch.label !== undefined && patch.label !== previous.label;
653
+ const iconChanged = patch.icon !== undefined && patch.icon !== previous.icon;
654
+ // Icon updates go through the mount probe, never `button.icon`: replacing
655
+ // the probe with a string would unmount it and lose the visibility signal.
656
+ if (iconChanged) setPillIcon(agentId, patch.icon as string);
657
+ if (labelChanged) registration.update({ label: patch.label });
658
+ if (labelChanged || iconChanged) {
659
+ pushedDescriptors.set(registration as object, {
660
+ label: patch.label ?? previous.label,
661
+ icon: patch.icon ?? previous.icon,
662
+ });
663
+ }
664
+ })
665
+ .catch((error) => {
666
+ // resolveLabel/resolveIcon usually perform an RPC; a rejection here is
667
+ // the pill's live data failing, so it must not vanish when onError is unset.
668
+ reportSuppressed(
669
+ console,
670
+ `registerComposerPill resolveLabel/resolveIcon failed (agent ${agentId})`,
671
+ error,
672
+ );
673
+ options.onError?.({
674
+ agentId,
675
+ workspaceId,
676
+ error: error instanceof Error ? error : new Error(String(error)),
677
+ });
678
+ });
679
+ }
680
+
681
+ function detectShape(agentId: string, workspaceId: string): "button" | "legacy" {
682
+ probeSequence += 1;
683
+ const probeId = `php-probe-${probeSequence}`;
684
+ try {
685
+ const registration = client.addComposerPill({
686
+ id: probeId,
687
+ workspaceId,
688
+ agentId,
689
+ button: {
690
+ title: "probe",
691
+ icon: "Activity",
692
+ behavior: {
693
+ kind: "action",
694
+ onPress() {},
695
+ },
696
+ },
697
+ });
698
+ toCleanup(registration)();
699
+ return "button";
700
+ } catch {
701
+ return "legacy";
702
+ }
703
+ }
704
+
705
+ function addPill(agentId: string, workspaceId: string) {
706
+ if (disposed || pills.has(agentId)) return;
707
+ try {
708
+ if (!detectedShape) {
709
+ detectedShape = detectShape(agentId, workspaceId);
710
+ }
711
+ if (detectedShape === "button") {
712
+ const registration = client.addComposerPill({
713
+ id: options.id,
714
+ workspaceId,
715
+ agentId,
716
+ button: {
717
+ title: options.title,
718
+ // The probe is the visibility signal and the live-icon renderer.
719
+ icon: makePillIcon(agentId),
720
+ label: options.title,
721
+ behavior: options.onPress
722
+ ? { kind: "action", onPress: () => void options.onPress?.() }
723
+ : options.presentation === "centered"
724
+ ? {
725
+ kind: "action",
726
+ onPress: () => setCenteredOpen(agentId, !centeredOpenAgents.has(agentId)),
727
+ }
728
+ : {
729
+ kind: "popover",
730
+ Content: PillPopoverContent as ComponentType<any>,
731
+ },
732
+ },
733
+ });
734
+ const handle = typeof registration === "function" ? undefined : registration;
735
+ pills.set(agentId, { dispose: toCleanup(registration), workspaceId, registration: handle });
736
+ // Resolve once so the label is correct even before the probe mounts;
737
+ // ongoing polling is owned by the shared, visibility-gated timer.
738
+ if ((options.resolveLabel || options.resolveIcon) && handle) {
739
+ resolveAndPushLabel(agentId, workspaceId, handle);
740
+ }
741
+ return;
742
+ }
743
+ const cleanup = client.addComposerPill({
744
+ id: options.id,
745
+ title: options.title,
746
+ workspaceId,
747
+ agentId,
748
+ Component: PillHost,
749
+ onPress() {
750
+ const opener = openers.get(agentId);
751
+ if (opener) {
752
+ const defaultPayload = options.resolveDefaultPayload?.({ agentId, workspaceId });
753
+ opener(defaultPayload);
754
+ }
755
+ },
756
+ });
757
+ pills.set(agentId, { dispose: toCleanup(cleanup), workspaceId });
758
+ } catch (error) {
759
+ reportError(agentId, workspaceId, error);
760
+ }
761
+ }
762
+
763
+ function removePill(agentId: string) {
764
+ const entry = pills.get(agentId);
765
+ entry?.dispose();
766
+ pills.delete(agentId);
767
+ visiblePillMounts.delete(agentId);
768
+ iconValueByAgent.delete(agentId);
769
+ iconListenersByAgent.delete(agentId);
770
+ openers.delete(agentId);
771
+ syncSharedLabelTimer();
772
+ }
773
+
774
+ const unsubscribe = client.paseo.agents.subscribe((update) => {
775
+ if (disposed) return;
776
+ if ("agentId" in update && update.kind === "remove") {
777
+ removePill(update.agentId);
778
+ return;
779
+ }
780
+ if ("agent" in update) {
781
+ const { id, workspaceId } = update.agent;
782
+ if (workspaceId) addPill(id, workspaceId);
783
+ }
784
+ });
785
+
786
+ client.paseo.agents
787
+ .list()
788
+ .then((result) => {
789
+ if (disposed) return;
790
+ for (const { agent } of result.entries) {
791
+ if (agent.workspaceId) addPill(agent.id, agent.workspaceId);
792
+ }
793
+ })
794
+ .catch((error) => {
795
+ // Silently dropping this leaves a plugin with zero pills and no clue why.
796
+ reportSuppressed(console, "registerComposerPill agents.list() failed", error, "warn");
797
+ });
798
+
799
+ return () => {
800
+ if (disposed) return;
801
+ disposed = true;
802
+ unsubscribe();
803
+ if (sharedLabelTimer) {
804
+ clearInterval(sharedLabelTimer);
805
+ sharedLabelTimer = null;
806
+ }
807
+ for (const entry of pills.values()) entry.dispose();
808
+ pills.clear();
809
+ visiblePillMounts.clear();
810
+ iconValueByAgent.clear();
811
+ iconListenersByAgent.clear();
812
+ openers.clear();
813
+ };
814
+ }
815
+
816
+ interface DefaultPillBodyProps {
817
+ title: string;
818
+ compactTitle?: string;
819
+ icon?: string;
820
+ compactIcon?: string;
821
+ badgeText?: string;
822
+ compactBadgeText?: string;
823
+ theme: HostPillProps["theme"];
824
+ }
825
+
826
+ function DefaultPillBody({
827
+ title,
828
+ compactTitle,
829
+ icon,
830
+ compactIcon,
831
+ badgeText,
832
+ compactBadgeText,
833
+ theme,
834
+ }: DefaultPillBodyProps) {
835
+ const { Icon } = getClientHost();
836
+ const { isCompact } = useResponsive();
837
+
838
+ const effectiveTitle = isCompact && compactTitle ? compactTitle : title;
839
+ const effectiveIcon = isCompact && compactIcon ? compactIcon : icon;
840
+ const effectiveBadge =
841
+ isCompact && compactBadgeText !== undefined ? compactBadgeText : badgeText;
842
+
843
+ return (
844
+ <View style={styles.pillContainer}>
845
+ {effectiveIcon && <Icon name={effectiveIcon} size={13} color={theme.colors.foreground} />}
846
+ {effectiveTitle ? (
847
+ <Text style={[styles.title, { color: theme.colors.foreground }]}>{effectiveTitle}</Text>
848
+ ) : null}
849
+ {effectiveBadge && (
850
+ <View style={[styles.badge, { backgroundColor: theme.colors.surface1 }]}>
851
+ <Text style={[styles.badgeText, { color: theme.colors.foregroundMuted }]}>
852
+ {effectiveBadge}
853
+ </Text>
854
+ </View>
855
+ )}
856
+ </View>
857
+ );
858
+ }
859
+
860
+ const styles = StyleSheet.create({
861
+ popoverContainer: {
862
+ width: "100%",
863
+ },
864
+ pillContainer: {
865
+ flexDirection: "row",
866
+ alignItems: "center",
867
+ gap: 6,
868
+ paddingHorizontal: 8,
869
+ paddingVertical: 3,
870
+ },
871
+ title: {
872
+ fontSize: 12,
873
+ fontWeight: "500",
874
+ },
875
+ badge: {
876
+ borderRadius: 999,
877
+ paddingHorizontal: 5,
878
+ paddingVertical: 1,
879
+ },
880
+ badgeText: {
881
+ fontSize: 10,
882
+ fontWeight: "600",
883
+ },
884
+ });