@xpufx/paseo-top 0.4.2 → 0.4.4

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 (43) hide show
  1. package/README.md +11 -12
  2. package/client/multi-host-view.tsx +177 -0
  3. package/client/multi-host.ts +98 -0
  4. package/client/pill-labels.ts +30 -6
  5. package/client/pill.tsx +42 -61
  6. package/client/settings-events.ts +33 -0
  7. package/client/surface.tsx +10 -9
  8. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +12 -0
  9. package/client/vendor/paseo-plugin-helper/components/Button.tsx +29 -3
  10. package/client/vendor/paseo-plugin-helper/components/Card.tsx +32 -0
  11. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +17 -1
  12. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +17 -2
  13. package/client/vendor/paseo-plugin-helper/components/InteractiveRow.tsx +142 -0
  14. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +16 -2
  15. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +7 -0
  16. package/client/vendor/paseo-plugin-helper/components/Select.tsx +131 -63
  17. package/client/vendor/paseo-plugin-helper/components/index.ts +1 -0
  18. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +4 -3
  19. package/client/vendor/paseo-plugin-helper/host.ts +12 -58
  20. package/client/vendor/paseo-plugin-helper/index.ts +2 -0
  21. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +19 -29
  22. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +24 -22
  23. package/client/vendor/paseo-plugin-helper/panel.tsx +11 -3
  24. package/client/vendor/paseo-plugin-helper/pill.tsx +135 -339
  25. package/client/vendor/paseo-plugin-helper/styles/index.ts +1 -0
  26. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +550 -0
  27. package/client/vendor/paseo-plugin-helper/surface.tsx +62 -2
  28. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +3 -39
  29. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +40 -1
  30. package/client/vendor/paseo-plugin-helper/tickets.tsx +766 -0
  31. package/index.server.ts +7 -0
  32. package/package.json +12 -3
  33. package/paseo-plugin.json +1 -1
  34. package/server/vendor/paseo-plugin-helper/index.ts +2 -0
  35. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +20 -2
  36. package/server/vendor/paseo-plugin-helper/plugins.ts +184 -19
  37. package/server/vendor/paseo-plugin-helper/tickets.ts +1281 -0
  38. package/shared/helper-version.ts +20 -0
  39. package/shared/multi-host.ts +348 -0
  40. package/shared/resources.ts +1 -1
  41. package/shared/vendor/paseo-plugin-helper/index.ts +1 -0
  42. package/shared/vendor/paseo-plugin-helper/tickets.ts +1010 -0
  43. package/shared/version.ts +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # paseo-top
2
2
 
3
- Live host system resource monitor and telemetry provider for [Paseo](https://github.com/getpaseo/paseo) (v0.8+).
3
+ Live host system resource monitor and telemetry provider for [Paseo](https://github.com/getpaseo/paseo).
4
4
 
5
5
  Displays real-time host metrics, session metadata, turn telemetry, and custom pill widgets directly in the composer track bar without cluttering the interface. Automatically stamps performance summaries into agent conversation timelines and provides an interactive modal dashboard with live gauges.
6
6
 
@@ -12,11 +12,11 @@ Built on [paseo-plugin-helper](https://github.com/xpufx/paseo/tree/main/packages
12
12
 
13
13
  | General Composer View | Dashboard View |
14
14
  | :---: | :---: |
15
- | [![General Composer View](screenshots/paseo-top-general-view.jpg)](screenshots/paseo-top-general-view.jpg) | [![Dashboard View](screenshots/paseo-top-dashboard.jpg)](screenshots/paseo-top-dashboard.jpg) |
15
+ | [![General Composer View](https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/paseo-top-general-view.jpg)](https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/paseo-top-general-view.jpg) | [![Dashboard View](https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/paseo-top-dashboard.jpg)](https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/paseo-top-dashboard.jpg) |
16
16
 
17
17
  | System Resources Modal | Workspace Context Modal | Settings Modal | About Modal |
18
18
  | :---: | :---: | :---: | :---: |
19
- | <img src="screenshots/1-paseo-top-modal-system.jpg" alt="System Resources Modal" width="100%" /> | <img src="screenshots/2-paseo-top-modal-workspace.jpg" alt="Workspace Context Modal" width="100%" /> | <img src="screenshots/3-paseo-top-modal-settings.jpg" alt="Settings Modal" width="100%" /> | <img src="screenshots/4-paseo-top-modal-about.jpg" alt="About Modal" width="100%" /> |
19
+ | <img src="https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/1-paseo-top-modal-system.jpg" alt="System Resources Modal" width="100%" /> | <img src="https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/2-paseo-top-modal-workspace.jpg" alt="Workspace Context Modal" width="100%" /> | <img src="https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/3-paseo-top-modal-settings.jpg" alt="Settings Modal" width="100%" /> | <img src="https://raw.githubusercontent.com/xpufx/paseo/main/plugins/top/screenshots/4-paseo-top-modal-about.jpg" alt="About Modal" width="100%" /> |
20
20
 
21
21
 
22
22
  ## Core Capabilities
@@ -45,6 +45,7 @@ Built on [paseo-plugin-helper](https://github.com/xpufx/paseo/tree/main/packages
45
45
 
46
46
  ### 4. Fleet & MCP Integration
47
47
  - **MCP Server Health**: Bridges with `mcp-tools` to monitor connected MCP servers, distinguishing healthy, degraded, or offline servers alongside latency figures.
48
+ - **Multi-Host Fleet View**: The dashboard **Fleet** tab enumerates hosts from `useHosts()` and shows one card per host with a status badge (`online` / `stale` / `offline` / `error`), probe latency, and per-host counts. Counts are summed across responsive hosts; CPU/RAM/load stay per-host and are never aggregated. Polling is bounded to one in-flight probe per host on a 15s cadence with a 4s per-host timeout that marks the host `stale` until the next tick. No remote shell or custom-command execution is performed.
48
49
 
49
50
  ### 5. Flexible Display Modes
50
51
  - **Cycle Mode**: Automatically cycles through enabled metric segments on each refresh interval.
@@ -75,24 +76,22 @@ Add user-defined metrics by dropping declarative `.jsonc` definitions into `~/.p
75
76
 
76
77
  ### 8. Native Modal Dashboard
77
78
  Clicking the pill opens a native dashboard with:
78
- - **Pinned Header Navbar**: Fixed tabs for System Resources, Activity Timeline, Custom Pills, Settings, and About. Content smoothly scrolls beneath the navbar.
79
+ - **Pinned Header Navbar**: Fixed tabs for System Resources, Fleet, Activity Timeline, Custom Pills, Settings, and About. Content smoothly scrolls beneath the navbar.
79
80
  - **Visual Gauges**: Responsive bars showing CPU and RAM distribution.
80
81
  - **Full Settings Suite**: Live toggles for every metric and pill, with persistent state managed by `PluginStorage`.
81
82
 
82
83
  ## Installation
83
84
 
84
- Install using the native Paseo monorepo subpath syntax:
85
+ Install from npm:
85
86
 
86
- ```bash
87
- paseo plugin add xpufx/paseo --path plugins/top
87
+ ```sh
88
+ paseo plugin add npm:@xpufx/paseo-top
88
89
  ```
89
90
 
90
- For local development:
91
+ Or install directly from the Git repository:
91
92
 
92
- ```bash
93
- git clone git@github.com:xpufx/paseo.git
94
- cd paseo
95
- paseo plugin add ./plugins/top
93
+ ```sh
94
+ paseo plugin add xpufx/paseo --path plugins/top
96
95
  ```
97
96
 
98
97
  ## Development
@@ -0,0 +1,177 @@
1
+ import React, { useMemo } from "react";
2
+ import { Text } from "react-native";
3
+ import {
4
+ Badge,
5
+ Card,
6
+ CardHeader,
7
+ EmptyState,
8
+ KeyValue,
9
+ KeyValueGroup,
10
+ Row,
11
+ SectionHeader,
12
+ Stack,
13
+ StatusDot,
14
+ usePluginTheme,
15
+ } from "./vendor/paseo-plugin-helper/index";
16
+ import {
17
+ aggregateFleet,
18
+ type FleetHostSnapshot,
19
+ type FleetHostStatus,
20
+ } from "../shared/multi-host";
21
+ import { useFleetPolling } from "./multi-host";
22
+
23
+ const STATUS_VARIANT: Record<
24
+ FleetHostStatus,
25
+ "success" | "warning" | "neutral" | "danger"
26
+ > = {
27
+ online: "success",
28
+ stale: "warning",
29
+ offline: "neutral",
30
+ error: "danger",
31
+ };
32
+
33
+ const STATUS_LABEL: Record<FleetHostStatus, string> = {
34
+ online: "online",
35
+ stale: "stale",
36
+ offline: "offline",
37
+ error: "error",
38
+ };
39
+
40
+ function formatLatency(latencyMs: number | null): string {
41
+ if (latencyMs === null) return "--";
42
+ if (latencyMs < 1000) return `${Math.round(latencyMs)} ms`;
43
+ return `${(latencyMs / 1000).toFixed(2)} s`;
44
+ }
45
+
46
+ function formatStaleAge(
47
+ staleAt: number | null,
48
+ now: number,
49
+ ): string | null {
50
+ if (staleAt === null) return null;
51
+ const seconds = Math.max(0, Math.round((now - staleAt) / 1000));
52
+ return `stale ${seconds}s`;
53
+ }
54
+
55
+ function FleetHostCard({
56
+ snapshot,
57
+ now,
58
+ }: {
59
+ snapshot: FleetHostSnapshot;
60
+ now: number;
61
+ }) {
62
+ const { colors } = usePluginTheme();
63
+ const variant = STATUS_VARIANT[snapshot.status];
64
+ const staleAge = formatStaleAge(snapshot.staleAt, now);
65
+ const subtitle =
66
+ snapshot.status === "online"
67
+ ? "Connected"
68
+ : (staleAge ?? snapshot.error ?? "Not connected");
69
+
70
+ return (
71
+ <Card variant="elevated">
72
+ <CardHeader
73
+ icon="Server"
74
+ title={snapshot.label}
75
+ subtitle={subtitle}
76
+ badge={
77
+ <Row gap={6} align="center">
78
+ <StatusDot
79
+ variant={snapshot.status === "online" ? "success" : variant}
80
+ pulse={snapshot.status === "online"}
81
+ />
82
+ <Badge
83
+ label={STATUS_LABEL[snapshot.status]}
84
+ variant={variant}
85
+ size="sm"
86
+ />
87
+ </Row>
88
+ }
89
+ />
90
+ <KeyValueGroup>
91
+ <KeyValue label="Host" value={snapshot.serverId} truncate="middle" copyable />
92
+ <KeyValue label="Latency" value={formatLatency(snapshot.latencyMs)} />
93
+ <KeyValue
94
+ label="Agents"
95
+ value={snapshot.counts ? String(snapshot.counts.agents) : "--"}
96
+ />
97
+ <KeyValue
98
+ label="Active agents"
99
+ value={snapshot.counts ? String(snapshot.counts.activeAgents) : "--"}
100
+ />
101
+ <KeyValue
102
+ label="Workspaces"
103
+ value={snapshot.counts ? String(snapshot.counts.workspaces) : "--"}
104
+ />
105
+ </KeyValueGroup>
106
+ {snapshot.status === "error" && snapshot.error ? (
107
+ <Text style={{ marginTop: 8, fontSize: 11, color: colors.statusDanger }}>
108
+ {snapshot.error}
109
+ </Text>
110
+ ) : null}
111
+ </Card>
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Multi-host Fleet view. Renders one card per configured host with a status
117
+ * badge and per-host counts, plus a counts-only aggregate. Local Top gauges
118
+ * (CPU/RAM) stay in the Activity tab and are never summed or averaged here.
119
+ *
120
+ * Mounting is the lifecycle: this component is rendered only while the Fleet
121
+ * tab is active, so it starts polling on mount and disposes on unmount. Local
122
+ * Top behavior is unaffected whenever the tab is not selected.
123
+ */
124
+ export function FleetView() {
125
+ const { colors } = usePluginTheme();
126
+ const snapshots = useFleetPolling();
127
+ const now = Date.now();
128
+ const aggregate = useMemo(() => aggregateFleet(snapshots), [snapshots]);
129
+
130
+ if (snapshots.length === 0) {
131
+ return (
132
+ <EmptyState
133
+ icon="Server"
134
+ title="No other hosts"
135
+ description="Configure additional hosts in Paseo to see their fleet status here."
136
+ />
137
+ );
138
+ }
139
+
140
+ return (
141
+ <Stack gap={12}>
142
+ <Text style={{ fontSize: 16, fontWeight: "700", color: colors.foreground }}>
143
+ Multi-Host Fleet
144
+ </Text>
145
+
146
+ <Card variant="elevated">
147
+ <CardHeader
148
+ title="Fleet Totals"
149
+ icon="Activity"
150
+ subtitle="Counts only. CPU/RAM/load stay per-host."
151
+ />
152
+ <KeyValueGroup>
153
+ <KeyValue label="Hosts" value={`${aggregate.responsive}/${aggregate.hosts} responsive`} />
154
+ <KeyValue
155
+ label="Online / Stale"
156
+ value={`${aggregate.online} / ${aggregate.stale}`}
157
+ />
158
+ <KeyValue label="Agents" value={String(aggregate.counts.agents)} />
159
+ <KeyValue
160
+ label="Active agents"
161
+ value={String(aggregate.counts.activeAgents)}
162
+ />
163
+ <KeyValue label="Workspaces" value={String(aggregate.counts.workspaces)} />
164
+ </KeyValueGroup>
165
+ </Card>
166
+
167
+ <SectionHeader title="Hosts" count={snapshots.length} />
168
+ {snapshots.map((snapshot) => (
169
+ <FleetHostCard
170
+ key={snapshot.serverId}
171
+ snapshot={snapshot}
172
+ now={now}
173
+ />
174
+ ))}
175
+ </Stack>
176
+ );
177
+ }
@@ -0,0 +1,98 @@
1
+ import { getPaseoClient, useHosts } from "@getpaseo/plugin/client";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import {
4
+ MultiHostPoller,
5
+ type FleetHostClient,
6
+ type FleetHostCounts,
7
+ type FleetHostSnapshot,
8
+ } from "../shared/multi-host";
9
+
10
+ /**
11
+ * Wrap a borrowed `getPaseoClient(serverId)` as a fleet client.
12
+ *
13
+ * Only counts are read. The borrowed API exposes agents/workspaces/projects/
14
+ * terminals/providers/config and no host-metrics RPC, so the fleet view cannot
15
+ * show remote CPU/RAM/load/uptime; fabricating them would be mock data.
16
+ */
17
+ export function createFleetClient(serverId: string): FleetHostClient {
18
+ // getPaseoClient throws for a non-online/unknown host; the poller only
19
+ // acquires for online hosts, and the throw is caught per host so one bad
20
+ // host cannot break the others.
21
+ const client = getPaseoClient(serverId);
22
+ return {
23
+ async readCounts(): Promise<FleetHostCounts> {
24
+ const [agents, workspaces] = await Promise.all([
25
+ client.agents.list(),
26
+ client.workspaces.list(),
27
+ ]);
28
+ const entries = agents.entries ?? [];
29
+ return {
30
+ agents: entries.length,
31
+ activeAgents: entries.filter((entry) => entry.agent?.status === "running")
32
+ .length,
33
+ workspaces: workspaces.entries?.length ?? 0,
34
+ };
35
+ },
36
+ dispose() {
37
+ return client.dispose();
38
+ },
39
+ };
40
+ }
41
+
42
+ export interface UseFleetPollingOptions {
43
+ /** Poll only while the fleet view is visible; keeps local-only paths idle. */
44
+ enabled?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Bounded multi-host fleet polling hook.
49
+ *
50
+ * Enumerates online hosts with `useHosts()`, acquires a borrowed
51
+ * `getPaseoClient(serverId)` per online host, and drives the pure
52
+ * {@link MultiHostPoller} state machine: max one in-flight probe per host,
53
+ * 15s cadence, 4s per-host timeout, stale marking, and dispose/reacquire on
54
+ * host status transitions. Local Top behavior is untouched because nothing
55
+ * starts unless the fleet view is mounted and enabled.
56
+ */
57
+ export function useFleetPolling({ enabled = true }: UseFleetPollingOptions = {}) {
58
+ const hosts = useHosts();
59
+ const [snapshots, setSnapshots] = useState<FleetHostSnapshot[]>([]);
60
+ const pollerRef = useRef<MultiHostPoller | null>(null);
61
+
62
+ const hostInputs = useMemo(
63
+ () =>
64
+ hosts.map((host) => ({
65
+ serverId: host.serverId,
66
+ label: host.label,
67
+ status: host.status,
68
+ })),
69
+ [hosts],
70
+ );
71
+
72
+ // Keep the latest host list in a ref so the create-once effect can seed from
73
+ // it without re-subscribing on every status change.
74
+ const hostInputsRef = useRef(hostInputs);
75
+ hostInputsRef.current = hostInputs;
76
+
77
+ useEffect(() => {
78
+ if (!enabled) return;
79
+ const poller = new MultiHostPoller({
80
+ acquire: createFleetClient,
81
+ onUpdate: setSnapshots,
82
+ });
83
+ pollerRef.current = poller;
84
+ poller.setHosts(hostInputsRef.current);
85
+ poller.start();
86
+ return () => {
87
+ poller.dispose();
88
+ pollerRef.current = null;
89
+ setSnapshots([]);
90
+ };
91
+ }, [enabled]);
92
+
93
+ useEffect(() => {
94
+ pollerRef.current?.setHosts(hostInputs);
95
+ }, [hostInputs]);
96
+
97
+ return snapshots;
98
+ }
@@ -56,6 +56,20 @@ export interface SegmentSnapshot {
56
56
  worktreeLocationText?: string;
57
57
  }
58
58
 
59
+ /**
60
+ * First non-blank string wins, else the fallback. `??` only guards null and
61
+ * undefined, so a present-but-empty host field (blank `title`, `provider`,
62
+ * `branch`, `agentId`) slipped through as an empty label. On a button-shaped
63
+ * host an empty label renders icon-only, which reads as the pill collapsing to
64
+ * a skinny/empty line (xpufx-org/paseo#507).
65
+ */
66
+ function firstNonBlank(fallback: string, ...values: Array<string | null | undefined>): string {
67
+ for (const value of values) {
68
+ if (typeof value === "string" && value.trim().length > 0) return value;
69
+ }
70
+ return fallback;
71
+ }
72
+
59
73
  export interface TokenMetrics {
60
74
  inputTokens?: number;
61
75
  outputTokens?: number;
@@ -262,28 +276,38 @@ export function describeSegment(
262
276
  const prefix = def?.shortLabel ? `${def.shortLabel} ` : "";
263
277
  switch (item) {
264
278
  case "branch":
265
- return { icon, iconTone: "accent", text: data?.branch ?? "--", tone: "foreground" };
279
+ return {
280
+ icon,
281
+ iconTone: "accent",
282
+ text: firstNonBlank("--", data?.branch),
283
+ tone: "foreground",
284
+ };
266
285
  case "worktree":
267
286
  return {
268
287
  icon,
269
288
  iconTone: "accent",
270
- text: worktreeLocationText || "--",
289
+ text: firstNonBlank("--", worktreeLocationText),
271
290
  tone: "foreground",
272
291
  };
273
292
  case "agent_title":
274
- return { icon, iconTone: "accent", text: agent?.title ?? "Agent", tone: "foreground" };
293
+ return {
294
+ icon,
295
+ iconTone: "accent",
296
+ text: firstNonBlank("Agent", agent?.title),
297
+ tone: "foreground",
298
+ };
275
299
  case "agent":
276
300
  return {
277
301
  icon,
278
302
  iconTone: "accent",
279
- text: agent?.model || agent?.provider || "Agent",
303
+ text: firstNonBlank("Agent", agent?.model, agent?.provider),
280
304
  tone: "foreground",
281
305
  };
282
306
  case "agent_provider":
283
307
  return {
284
308
  icon,
285
309
  iconTone: "accent",
286
- text: agent?.provider ?? "Provider",
310
+ text: firstNonBlank("Provider", agent?.provider),
287
311
  tone: "foreground",
288
312
  };
289
313
  case "agent_activity": {
@@ -299,7 +323,7 @@ export function describeSegment(
299
323
  return {
300
324
  icon,
301
325
  iconTone: "accent",
302
- text: agentId && agentId.length > 7 ? agentId.slice(0, 7) : agentId ?? "--",
326
+ text: agentId && agentId.length > 7 ? agentId.slice(0, 7) : firstNonBlank("--", agentId),
303
327
  tone: "foreground",
304
328
  };
305
329
  case "load":
package/client/pill.tsx CHANGED
@@ -28,16 +28,14 @@ import {
28
28
  Badge,
29
29
  Icon,
30
30
  AboutSection,
31
- CustomPillBody,
32
31
  CustomPillModalContent,
33
32
  useRpcQuery,
34
33
  usePluginSettings,
35
34
  usePluginTheme,
36
35
  getStatusColor,
37
36
  triggerHaptic,
38
- shouldEmitSnapshotUpdate,
37
+ type HostPillProps,
39
38
  type RenderModalProps,
40
- type RenderPillProps,
41
39
  type KeyValueProps,
42
40
  type CardHeaderProps,
43
41
  type BadgeProps,
@@ -47,8 +45,8 @@ import {
47
45
  formatUptime,
48
46
  resolveMetricStatus,
49
47
  type MetricThresholds,
50
- type CustomPillState,
51
48
  } from "../shared/vendor/paseo-plugin-helper/index";
49
+ import { PermissionAuditView } from "permission-audit/client";
52
50
  import {
53
51
  getSystemResourcesRpc,
54
52
  topSettingsContract,
@@ -72,6 +70,7 @@ import {
72
70
  } from "../shared/resources";
73
71
  import { PLUGIN_VERSION } from "../shared/version";
74
72
  import { TopDashboardSurface } from "./surface";
73
+ import { addSettingsListener, removeSettingsListener, notifySettingsChanged } from "./settings-events";
75
74
  import { useTopResourceQuery, useCustomPillsQuery } from "./resources-query";
76
75
  import { ChoiceChips } from "./settings-ui";
77
76
  import {
@@ -140,6 +139,7 @@ const MEM_THRESHOLDS: MetricThresholds = { warning: 70, danger: 85 };
140
139
  const TABS = [
141
140
  { id: "system", label: "System", shortLabel: "System", icon: "Activity" },
142
141
  { id: "context", label: "Workspace", shortLabel: "Workspace", icon: "GitBranch" },
142
+ { id: "permissions", label: "Permissions", shortLabel: "Permissions", icon: "ShieldCheck" },
143
143
  { id: "settings", label: "Settings", shortLabel: "Settings", icon: "Sliders" },
144
144
  { id: "about", label: "About", shortLabel: "About", icon: "Info" },
145
145
  ];
@@ -177,7 +177,7 @@ function getMetricColors(
177
177
  // so pill call sites keep a single import path.
178
178
  export type { PillItemType } from "./pill-labels";
179
179
 
180
- export type ModalTab = "system" | "context" | "settings" | "about";
180
+ export type ModalTab = "system" | "context" | "permissions" | "settings" | "about";
181
181
 
182
182
  export function getItemTab(item: PillItemType): "system" | "context" {
183
183
  switch (item) {
@@ -448,26 +448,8 @@ function PillItemContent({
448
448
  );
449
449
  }
450
450
 
451
- type SettingsListener = (settings: TopSettings) => void;
452
- const settingsListeners = new Set<SettingsListener>();
453
- let lastNotifiedSettings: TopSettings | null = null;
454
-
455
- export function notifySettingsChanged(settings: TopSettings) {
456
- if (!shouldEmitSnapshotUpdate(
457
- lastNotifiedSettings as unknown as Record<string, unknown> | null,
458
- settings as unknown as Record<string, unknown>,
459
- )) {
460
- return;
461
- }
462
- lastNotifiedSettings = settings;
463
- for (const listener of settingsListeners) {
464
- try {
465
- listener(settings);
466
- } catch {
467
- // Ignore listener errors
468
- }
469
- }
470
- }
451
+ export { notifySettingsChanged } from "./settings-events";
452
+ type SettingsListener = import("./settings-events").SettingsListener;
471
453
 
472
454
  function PillOffline() {
473
455
  const { colors } = usePluginTheme();
@@ -521,9 +503,8 @@ interface PillSegmentProps {
521
503
  }
522
504
 
523
505
  /**
524
- * The composer pill body is host-pressed: legacy hosts wrap `renderPill` in
525
- * their own pressable and route the tap through `resolveDefaultPayload`, so the
526
- * segment must not nest a second interaction handler.
506
+ * The segment must not nest a second interaction handler: on the host-rendered
507
+ * pill it sits inside the host's own pressable.
527
508
  */
528
509
  function PillSegment({
529
510
  item,
@@ -584,7 +565,21 @@ function AllInOnePill({
584
565
  );
585
566
  }
586
567
 
587
- export interface SingleItemPillViewProps extends RenderPillProps<ModalTab> {
568
+ /**
569
+ * Props the pill body views used to receive from the helper's `renderPill`
570
+ * option, which the Paseo 0.9 host shape removed: the host renders the pill
571
+ * body itself, so nothing wires these views any more. The shape is kept
572
+ * because `pill-render.test.tsx` still regression-tests their mobile layout
573
+ * (#507) and that coverage is worth more than the dead wiring.
574
+ */
575
+ interface PillBodyProps extends HostPillProps {
576
+ isOpen: boolean;
577
+ open(payload?: ModalTab): void;
578
+ close(): void;
579
+ toggle(payload?: ModalTab): void;
580
+ }
581
+
582
+ export interface SingleItemPillViewProps extends PillBodyProps {
588
583
  item: PillItemType;
589
584
  defaultTab?: ModalTab;
590
585
  }
@@ -669,7 +664,7 @@ export function SingleItemPillView({
669
664
  );
670
665
  }
671
666
 
672
- function PillView({ isOpen, open, workspaceId, agentId }: RenderPillProps<ModalTab>) {
667
+ function PillView({ isOpen, open, workspaceId, agentId }: PillBodyProps) {
673
668
  // No background settings poll here: the hook re-verifies on mount and
674
669
  // window focus, and mutations invalidate the shared settings cache.
675
670
  // Polling from every mounted pill/modal/surface multiplied daemon reads
@@ -1111,6 +1106,7 @@ function ResourceModal({ theme, workspaceId, agentId, initialTab, payload }: Res
1111
1106
  <ModalBody
1112
1107
  style={{ backgroundColor: colors.surface0 }}
1113
1108
  headerMode="pinned"
1109
+ scrollMode="always"
1114
1110
  refreshing={isRefetching}
1115
1111
  onRefresh={handleRefresh}
1116
1112
  header={navbar}
@@ -1135,6 +1131,7 @@ function ResourceModal({ theme, workspaceId, agentId, initialTab, payload }: Res
1135
1131
  <ModalBody
1136
1132
  style={{ backgroundColor: colors.surface0 }}
1137
1133
  headerMode="pinned"
1134
+ scrollMode="always"
1138
1135
  refreshing={isLoading || isRefetching}
1139
1136
  onRefresh={handleRefresh}
1140
1137
  header={navbar}
@@ -1683,6 +1680,10 @@ function ResourceModal({ theme, workspaceId, agentId, initialTab, payload }: Res
1683
1680
  </>
1684
1681
  )}
1685
1682
 
1683
+ {activeTab === "permissions" && (
1684
+ <PermissionAuditView variant="compact" />
1685
+ )}
1686
+
1686
1687
  {activeTab === "about" && (
1687
1688
  <AboutSection
1688
1689
  name="paseo-top"
@@ -1729,17 +1730,6 @@ function ResourceModal({ theme, workspaceId, agentId, initialTab, payload }: Res
1729
1730
  );
1730
1731
  }
1731
1732
 
1732
- interface LiveCustomPillViewProps {
1733
- pillId: string;
1734
- initial: CustomPillState;
1735
- }
1736
-
1737
- function LiveCustomPillView({ pillId, initial }: LiveCustomPillViewProps) {
1738
- const { data } = useCustomPillsQuery();
1739
- const liveState = data?.pills.find((p) => p.id === pillId) ?? initial;
1740
- return <CustomPillBody state={liveState} />;
1741
- }
1742
-
1743
1733
  interface LiveCustomPillModalProps {
1744
1734
  pillId: string;
1745
1735
  initial: CustomPillStateOutput;
@@ -1854,12 +1844,6 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
1854
1844
  modalTitle: "Host System Resources",
1855
1845
  modalIcon: "Activity",
1856
1846
  icon: "Cpu",
1857
- resolveDefaultPayload: ({ agentId }) => {
1858
- if (latestSettings.pillMode === "all") {
1859
- return latestSettings.defaultTab;
1860
- }
1861
- return currentCycleTabByAgent.get(agentId) ?? latestSettings.defaultTab;
1862
- },
1863
1847
  resolveLabel: async (ctx) => {
1864
1848
  const snap = await liveSnapshotFor(ctx);
1865
1849
  const items = enabledItemsForSettings(latestSettings);
@@ -1876,7 +1860,6 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
1876
1860
  };
1877
1861
  },
1878
1862
  refreshIntervalMs: 3000,
1879
- renderPill: (props) => <PillView {...props} />,
1880
1863
  renderModal: (props) => <ResourceModal {...props} />,
1881
1864
  });
1882
1865
  activePills.set("paseo-top", cleanup);
@@ -2081,16 +2064,8 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
2081
2064
  modalTitle: pillDef.modalTitle,
2082
2065
  icon: pillDef.icon,
2083
2066
  modalIcon: pillDef.icon,
2084
- resolveDefaultPayload: () => pillDef.defaultTab,
2085
2067
  resolveLabel: singleItemLabelResolver(pillDef.item),
2086
2068
  refreshIntervalMs: 5000,
2087
- renderPill: (props) => (
2088
- <SingleItemPillView
2089
- item={pillDef.item}
2090
- defaultTab={pillDef.defaultTab}
2091
- {...props}
2092
- />
2093
- ),
2094
2069
  renderModal: (props) => (
2095
2070
  <ResourceModal initialTab={pillDef.defaultTab} {...props} />
2096
2071
  ),
@@ -2102,7 +2077,7 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
2102
2077
  }
2103
2078
 
2104
2079
  // Register settings listener
2105
- settingsListeners.add(syncPills);
2080
+ addSettingsListener(syncPills);
2106
2081
 
2107
2082
  // Initial sync synchronously
2108
2083
  syncPills(topSettingsContract.defaultSettings);
@@ -2183,7 +2158,6 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
2183
2158
  );
2184
2159
  },
2185
2160
  refreshIntervalMs: 5000,
2186
- renderPill: () => <LiveCustomPillView pillId={pill.id} initial={pill} />,
2187
2161
  renderModal: () => (
2188
2162
  <LiveCustomPillModal pillId={pill.id} initial={pill} />
2189
2163
  ),
@@ -2204,7 +2178,7 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
2204
2178
  const onSettingsChanged = () => {
2205
2179
  void syncCustomPills();
2206
2180
  };
2207
- settingsListeners.add(onSettingsChanged);
2181
+ addSettingsListener(onSettingsChanged);
2208
2182
 
2209
2183
  let disposed = false;
2210
2184
  const cleanup = () => {
@@ -2216,8 +2190,8 @@ export function contributeClient(client: ComposerPillRegistrar | PluginClientCon
2216
2190
  cleanupSidebar = null;
2217
2191
  }
2218
2192
  clearInterval(customPillInterval);
2219
- settingsListeners.delete(syncPills);
2220
- settingsListeners.delete(onSettingsChanged);
2193
+ removeSettingsListener(syncPills);
2194
+ removeSettingsListener(onSettingsChanged);
2221
2195
  for (const cleanup of activePills.values()) {
2222
2196
  cleanup();
2223
2197
  }
@@ -2250,6 +2224,11 @@ const styles = {
2250
2224
  overflow: "hidden",
2251
2225
  flexShrink: 1,
2252
2226
  minWidth: 0,
2227
+ // The pill label is one short line; without a height floor a body that
2228
+ // momentarily renders no text (loading, or a blank host field) collapses to
2229
+ // an unreadable skinny line on mobile (xpufx-org/paseo#507). The host
2230
+ // composer pill is 32pt; keep the inner body at least a line tall.
2231
+ minHeight: 20,
2253
2232
  },
2254
2233
  pillText: {
2255
2234
  fontSize: 11,
@@ -2415,6 +2394,8 @@ const styles = {
2415
2394
  overflow: "hidden",
2416
2395
  flexShrink: 1,
2417
2396
  minWidth: 0,
2397
+ // See pillContainer: the combined pill must keep a line-height floor too.
2398
+ minHeight: 20,
2418
2399
  },
2419
2400
  dividerText: {
2420
2401
  fontSize: 10,