@xpufx/paseo-top 0.4.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/client/pill-labels.ts +480 -0
  4. package/client/pill.tsx +2461 -0
  5. package/client/resources-query.ts +68 -0
  6. package/client/settings-ui.tsx +50 -0
  7. package/client/surface.tsx +364 -0
  8. package/client/telemetry-copy.ts +105 -0
  9. package/client/telemetry.tsx +486 -0
  10. package/client/turn-counter.ts +151 -0
  11. package/client/turn-panel.tsx +182 -0
  12. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  13. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  14. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  15. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  16. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  17. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  18. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  19. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  20. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  21. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  22. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  23. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  24. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  25. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  26. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  27. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  28. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  29. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  30. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  31. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  32. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  33. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  34. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  35. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  36. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  37. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  38. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  39. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  40. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  41. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  42. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  43. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  44. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  45. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  46. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  47. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  48. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  49. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  50. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  51. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  52. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  53. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  54. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  55. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  56. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  57. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  58. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  59. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  60. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  61. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  62. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  63. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  64. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  65. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  66. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  67. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  68. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  69. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  70. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  71. package/examples/pills/battery.jsonc +23 -0
  72. package/examples/pills/disk-usage.jsonc +18 -0
  73. package/examples/pills/docker-containers.jsonc +22 -0
  74. package/examples/pills/git-dirty.jsonc +22 -0
  75. package/examples/pills/gpu-nvidia.jsonc +22 -0
  76. package/package.json +37 -0
  77. package/paseo-plugin.json +4 -0
  78. package/server/resources.ts +784 -0
  79. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  80. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  81. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  82. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  83. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  84. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  85. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  86. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  87. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  88. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  89. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  90. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  91. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  92. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  93. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  94. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  95. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  96. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  97. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  98. package/shared/resources.ts +686 -0
  99. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  100. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  101. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  102. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  103. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  104. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  105. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  106. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  107. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  108. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  109. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  110. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  111. package/shared/version.ts +2 -0
@@ -0,0 +1,182 @@
1
+ import React, { useEffect, useMemo } from "react";
2
+ import { Text } from "react-native";
3
+ import type { PluginAgentPanelProps } from "@getpaseo/plugin/client";
4
+ import { useAgent, usePaseo } from "@getpaseo/plugin/client";
5
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
6
+ import {
7
+ Badge,
8
+ Card,
9
+ CardHeader,
10
+ EmptyState,
11
+ KeyValue,
12
+ KeyValueGroup,
13
+ ModalBody,
14
+ PluginThemeProvider,
15
+ Row,
16
+ SectionHeader,
17
+ Stack,
18
+ usePluginTheme,
19
+ } from "paseo-plugin-helper/client";
20
+ import {
21
+ currentTurn,
22
+ fetchAllTurns,
23
+ formatTurnAddress,
24
+ type TurnRef,
25
+ } from "./turn-counter";
26
+ import type { TopAgentSnapshot } from "./pill-labels";
27
+
28
+ function previewLine(text: string): string {
29
+ const first = text.split("\n", 1)[0]?.trim() ?? "";
30
+ return first.length > 0 ? first : "(empty message)";
31
+ }
32
+
33
+ /**
34
+ * A conversation's turns are ordered by the canonical timeline `seq`, and a
35
+ * user_message row maps 1:1 to an entry (not identity-merged), so the 1-based
36
+ * index of the sorted rows is the same turn number the agent resolves when it
37
+ * reads its own timeline. `Turn #N (seq S)` is therefore addressable by both
38
+ * sides. Refresh is event-driven (agent upserts) plus a slow poll while the
39
+ * panel is open; full history is paged, so the count does not reset to the
40
+ * tail window on long conversations.
41
+ *
42
+ * Registered with the raw `addWorkspacePanel`, which does not inject the helper
43
+ * theme provider (unlike `registerWorkspacePanel`), so the body is wrapped here.
44
+ */
45
+ export function TurnCounterPanel({ theme, layout, agentId }: PluginAgentPanelProps) {
46
+ return (
47
+ <PluginThemeProvider theme={theme} layout={layout}>
48
+ <TurnCounterBody agentId={agentId} />
49
+ </PluginThemeProvider>
50
+ );
51
+ }
52
+
53
+ function TurnCounterBody({ agentId }: { agentId: string }) {
54
+ const { colors, padding } = usePluginTheme();
55
+ const paseo = usePaseo();
56
+ const queryClient = useQueryClient();
57
+
58
+ const agent = useAgent(agentId, (a: TopAgentSnapshot) => ({
59
+ title: a?.title ?? null,
60
+ status: a?.status ?? null,
61
+ }));
62
+
63
+ const turnsQuery = useQuery({
64
+ queryKey: ["top-turn-counter", agentId],
65
+ queryFn: () => fetchAllTurns(paseo, agentId),
66
+ refetchInterval: 5000,
67
+ refetchOnMount: "always",
68
+ });
69
+
70
+ // Turn boundaries land as agent upserts; refetch immediately instead of
71
+ // waiting out the poll interval.
72
+ useEffect(() => {
73
+ const unsubscribe = paseo.agents.subscribe((update) => {
74
+ if (update.kind === "upsert" && update.agent.id === agentId) {
75
+ void queryClient.invalidateQueries({ queryKey: ["top-turn-counter", agentId] });
76
+ }
77
+ });
78
+ return unsubscribe;
79
+ }, [paseo, agentId, queryClient]);
80
+
81
+ const turns: TurnRef[] = turnsQuery.data ?? [];
82
+ const latest = currentTurn(turns);
83
+
84
+ // Newest first, so the last turn is the first row the operator sees.
85
+ const ordered = useMemo(() => [...turns].reverse(), [turns]);
86
+
87
+ return (
88
+ <ModalBody
89
+ size="large"
90
+ headerMode="pinned"
91
+ refreshing={turnsQuery.isFetching}
92
+ onRefresh={() => void turnsQuery.refetch()}
93
+ header={
94
+ <Row justify="space-between" align="center" gap="sm">
95
+ <Text style={{ color: colors.foreground, fontSize: 18, fontWeight: "700" }}>
96
+ Turn Counter
97
+ </Text>
98
+ {agent?.status ? (
99
+ <Badge
100
+ label={agent.status}
101
+ variant={agent.status === "running" ? "success" : "neutral"}
102
+ size="sm"
103
+ dot
104
+ />
105
+ ) : null}
106
+ </Row>
107
+ }
108
+ headerStyle={{
109
+ backgroundColor: colors.surface0,
110
+ paddingHorizontal: padding.horizontal,
111
+ paddingTop: padding.vertical,
112
+ paddingBottom: padding.gap,
113
+ }}
114
+ contentContainerStyle={{
115
+ gap: padding.gap,
116
+ paddingHorizontal: padding.horizontal,
117
+ paddingBottom: padding.vertical,
118
+ }}
119
+ >
120
+ <Card variant="elevated">
121
+ <CardHeader
122
+ title="Current turn"
123
+ icon="Repeat"
124
+ subtitle="Deterministic from the timeline seq shared with the agent"
125
+ value={
126
+ <Badge
127
+ label={latest ? formatTurnAddress(latest.turn, latest.seq) : "-"}
128
+ variant={latest ? "accent" : "neutral"}
129
+ size="sm"
130
+ />
131
+ }
132
+ />
133
+ {latest ? (
134
+ <KeyValue
135
+ label="Address"
136
+ value={formatTurnAddress(latest.turn, latest.seq)}
137
+ subValue={previewLine(latest.preview)}
138
+ copyable
139
+ mono
140
+ />
141
+ ) : (
142
+ <Text style={{ color: colors.foregroundMuted, fontSize: 12 }}>
143
+ {turnsQuery.isPending ? "Reading timeline…" : "No user turns recorded yet."}
144
+ </Text>
145
+ )}
146
+ </Card>
147
+
148
+ {turnsQuery.error ? (
149
+ <Text selectable style={{ color: colors.statusDanger, fontSize: 12 }}>
150
+ Failed to read timeline: {String(turnsQuery.error)}
151
+ </Text>
152
+ ) : null}
153
+
154
+ {ordered.length > 0 ? (
155
+ <>
156
+ <SectionHeader title="Turns" count={ordered.length} />
157
+ <Stack gap="sm">
158
+ {ordered.map((turn) => (
159
+ <Card key={turn.seq} variant="elevated">
160
+ <KeyValueGroup columns={1}>
161
+ <KeyValue
162
+ label={formatTurnAddress(turn.turn, turn.seq)}
163
+ value={previewLine(turn.preview)}
164
+ subValue={`seq ${turn.seq}`}
165
+ copyable
166
+ mono
167
+ />
168
+ </KeyValueGroup>
169
+ </Card>
170
+ ))}
171
+ </Stack>
172
+ </>
173
+ ) : !turnsQuery.isPending && !turnsQuery.error ? (
174
+ <EmptyState
175
+ icon="MessageSquare"
176
+ title="No turns yet"
177
+ description="Send the agent a message and its first turn will appear here."
178
+ />
179
+ ) : null}
180
+ </ModalBody>
181
+ );
182
+ }
@@ -0,0 +1,43 @@
1
+ import type { PluginCleanup } from "./host";
2
+
3
+ /**
4
+ * Structural registrar interface satisfied by both Paseo v0.7 PluginContext
5
+ * and Paseo v0.8 PluginClientContext.
6
+ */
7
+ export interface CommandCenterItemRegistrar {
8
+ addCommandCenterItem(contribution: any): any;
9
+ }
10
+
11
+ export type CommandCenterContext = "global" | "workspace" | "agent";
12
+
13
+ /**
14
+ * Capabilities the host passes to a command-center item's `onSelect`. Mirrors
15
+ * the subset of `PluginCommandCapabilities` the helper needs; the real host
16
+ * context carries additional fields (paseo, rpc, workspace, agent) that a
17
+ * handler may read off its own typed contribution.
18
+ */
19
+ export interface CommandCenterCapabilities {
20
+ openSurface(id: string): void;
21
+ openSettings(id: string): void;
22
+ }
23
+
24
+ export interface CommandCenterItemContribution {
25
+ id: string;
26
+ title: string;
27
+ icon: string;
28
+ keywords?: readonly string[];
29
+ context: CommandCenterContext;
30
+ onSelect(context: CommandCenterCapabilities): void | Promise<void>;
31
+ }
32
+
33
+ /**
34
+ * Registers a command-center palette item — the entry the host Ctrl+K command
35
+ * center lists. Thin pass-through that keeps plugins on the helper seam and
36
+ * works with both Paseo v0.7 PluginContext and Paseo v0.8 PluginClientContext.
37
+ */
38
+ export function registerCommandCenterItem(
39
+ plugin: CommandCenterItemRegistrar,
40
+ contribution: CommandCenterItemContribution,
41
+ ): PluginCleanup {
42
+ return plugin.addCommandCenterItem(contribution);
43
+ }
@@ -0,0 +1,493 @@
1
+ import React, { useState, type ReactNode } from "react";
2
+ import {
3
+ Image,
4
+ Linking,
5
+ StyleSheet,
6
+ Text,
7
+ View,
8
+ type ImageSourcePropType,
9
+ type StyleProp,
10
+ type ViewStyle,
11
+ } from "react-native";
12
+ import { getClientHost } from "../host";
13
+ import { usePluginTheme } from "../theme/provider";
14
+ import { useResponsive } from "../theme/useResponsive";
15
+ import { Card } from "./Card";
16
+ import { Badge } from "./Badge";
17
+ import { Button } from "./Button";
18
+ import { KeyValue, KeyValueGroup } from "./KeyValue";
19
+ import { copyToClipboard } from "../utils/clipboard";
20
+ import { triggerHaptic } from "../utils/haptics";
21
+
22
+ export interface AboutLink {
23
+ label: string;
24
+ url: string;
25
+ icon?: string;
26
+ }
27
+
28
+ export interface AboutSectionProps {
29
+ /**
30
+ * Name of the plugin.
31
+ */
32
+ name: string;
33
+
34
+ /**
35
+ * Short description or tagline.
36
+ */
37
+ description?: string;
38
+
39
+ /**
40
+ * Semantic version string (e.g. from `PLUGIN_VERSION` or package.json).
41
+ */
42
+ version: string;
43
+
44
+ /**
45
+ * Author or organization name.
46
+ */
47
+ author?: string;
48
+
49
+ /**
50
+ * Logo or icon to display.
51
+ * - A React Native image source object: `{ uri: "https://..." }` or `require("./assets/logo.png")`
52
+ * - A string starting with "http" (e.g. avatar/logo URL)
53
+ * - A Paseo Lucide icon name (e.g. "Cpu", "Layers", "Sparkles")
54
+ * - If omitted and `repository` or `author` is a GitHub link/username, defaults to the GitHub avatar!
55
+ */
56
+ logo?: ImageSourcePropType | string;
57
+
58
+ /**
59
+ * Repository URL (e.g. "https://github.com/xpufx/paseo-top").
60
+ */
61
+ repository?: string;
62
+
63
+ /**
64
+ * Issue tracker URL (e.g. "https://github.com/xpufx/paseo-top/issues").
65
+ */
66
+ issues?: string;
67
+
68
+ /**
69
+ * Documentation website or wiki URL.
70
+ */
71
+ homepage?: string;
72
+
73
+ /**
74
+ * License identifier (e.g. "MIT", "Apache-2.0").
75
+ */
76
+ license?: string;
77
+
78
+ /**
79
+ * Custom additional links.
80
+ */
81
+ links?: AboutLink[];
82
+
83
+ /**
84
+ * Extra diagnostic or environmental items to display in the details section.
85
+ */
86
+ extraItems?: Array<{
87
+ label: string;
88
+ value: string;
89
+ subValue?: string;
90
+ copyable?: boolean;
91
+ }>;
92
+
93
+ /**
94
+ * Whether to display the "Copy Diagnostics" button. Default: true.
95
+ */
96
+ showDiagnosticsCopy?: boolean;
97
+
98
+ /**
99
+ * Optional custom container style.
100
+ */
101
+ style?: StyleProp<ViewStyle>;
102
+
103
+ /**
104
+ * Visual density. `"tiny"` scales all fonts to the smallest readable
105
+ * size for dense About pages. Default: `"default"`.
106
+ */
107
+ density?: "default" | "compact" | "tiny";
108
+ }
109
+
110
+ /**
111
+ * Extracts a GitHub owner/organization from a repository URL or author name.
112
+ */
113
+ function resolveGitHubAvatarUrl(repo?: string, author?: string): string | null {
114
+ if (repo) {
115
+ const match = repo.match(/github\.com[/:]([a-zA-Z0-9_-]+)/);
116
+ if (match?.[1]) {
117
+ return `https://github.com/${match[1]}.png?size=128`;
118
+ }
119
+ }
120
+ if (author && !author.includes(" ") && !author.includes("@")) {
121
+ return `https://github.com/${author}.png?size=128`;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * `<AboutSection>` provides a standardized, responsive plugin information and diagnostics view.
128
+ *
129
+ * Features:
130
+ * - Displays plugin branding, author, description, version, and license badges.
131
+ * - Supports custom logo images, local asset requires, Lucide icons, or auto-resolved GitHub avatars.
132
+ * - One-click "Copy Diagnostics" button formatting system info for GitHub issue triage.
133
+ * - Pre-styled external links with native browser launch via React Native `Linking`.
134
+ */
135
+ export function AboutSection({
136
+ name,
137
+ description,
138
+ version,
139
+ author,
140
+ logo,
141
+ repository,
142
+ issues,
143
+ homepage,
144
+ license = "MIT",
145
+ links = [],
146
+ extraItems = [],
147
+ showDiagnosticsCopy = true,
148
+ style,
149
+ density = "default",
150
+ }: AboutSectionProps) {
151
+ const { Icon } = getClientHost();
152
+ const { colors, resolveRadius, typography } = usePluginTheme();
153
+ const { isCompact, platform } = useResponsive();
154
+ const [copied, setCopied] = useState(false);
155
+ const isTiny = density === "tiny";
156
+
157
+ // 1. Resolve Logo / Avatar
158
+ let resolvedLogoNode: ReactNode = null;
159
+ const radius = resolveRadius("md");
160
+
161
+ if (logo) {
162
+ if (typeof logo === "string") {
163
+ if (logo.startsWith("http://") || logo.startsWith("https://")) {
164
+ resolvedLogoNode = (
165
+ <Image
166
+ source={{ uri: logo }}
167
+ style={[styles.logoImage, { borderRadius: radius }]}
168
+ />
169
+ );
170
+ } else {
171
+ resolvedLogoNode = (
172
+ <View
173
+ style={[
174
+ styles.logoIconFallback,
175
+ {
176
+ backgroundColor: colors.surface2,
177
+ borderRadius: radius,
178
+ borderColor: colors.border,
179
+ },
180
+ ]}
181
+ >
182
+ <Icon name={logo} size={26} color={colors.accent} />
183
+ </View>
184
+ );
185
+ }
186
+ } else {
187
+ resolvedLogoNode = (
188
+ <Image
189
+ source={logo}
190
+ style={[styles.logoImage, { borderRadius: radius }]}
191
+ />
192
+ );
193
+ }
194
+ } else {
195
+ // Attempt automatic GitHub avatar resolution
196
+ const githubAvatar = resolveGitHubAvatarUrl(repository, author);
197
+ if (githubAvatar) {
198
+ resolvedLogoNode = (
199
+ <Image
200
+ source={{ uri: githubAvatar }}
201
+ style={[styles.logoImage, { borderRadius: radius }]}
202
+ />
203
+ );
204
+ } else {
205
+ resolvedLogoNode = (
206
+ <View
207
+ style={[
208
+ styles.logoIconFallback,
209
+ {
210
+ backgroundColor: colors.surface2,
211
+ borderRadius: radius,
212
+ borderColor: colors.border,
213
+ },
214
+ ]}
215
+ >
216
+ <Icon name="Layers" size={26} color={colors.accent} />
217
+ </View>
218
+ );
219
+ }
220
+ }
221
+
222
+ // 2. Open URL helper
223
+ const handleOpenUrl = async (url: string) => {
224
+ try {
225
+ triggerHaptic("light");
226
+ const canOpen = await Linking.canOpenURL(url);
227
+ if (canOpen) {
228
+ await Linking.openURL(url);
229
+ }
230
+ } catch {
231
+ // Ignore linking errors
232
+ }
233
+ };
234
+
235
+ // 3. Diagnostics copy helper
236
+ const handleCopyDiagnostics = async () => {
237
+ triggerHaptic("success");
238
+ const lines = [
239
+ `Plugin: ${name} v${version}`,
240
+ author ? `Author: ${author}` : null,
241
+ `License: ${license}`,
242
+ `Platform: ${platform} (${isCompact ? "compact" : "regular"})`,
243
+ repository ? `Repository: ${repository}` : null,
244
+ ...extraItems.map((item) => `${item.label}: ${item.value}`),
245
+ ].filter(Boolean);
246
+
247
+ await copyToClipboard(lines.join("\n"));
248
+ setCopied(true);
249
+ setTimeout(() => setCopied(false), 2500);
250
+ };
251
+
252
+ // 4. Assemble external links
253
+ const allLinks: AboutLink[] = [
254
+ ...(repository ? [{ label: "Repository", url: repository, icon: "ExternalLink" }] : []),
255
+ ...(issues ? [{ label: "Report Issue", url: issues, icon: "Bug" }] : []),
256
+ ...(homepage ? [{ label: "Documentation", url: homepage, icon: "BookOpen" }] : []),
257
+ ...links,
258
+ ];
259
+
260
+ return (
261
+ <View style={[styles.container, isTiny && tinyStyles.container, style]}>
262
+ {/* Header Banner Card */}
263
+ <Card variant="elevated" style={isTiny ? tinyStyles.card : undefined}>
264
+ <View style={[styles.headerRow, isTiny && tinyStyles.headerRow]}>
265
+ {resolvedLogoNode}
266
+
267
+ <View style={[styles.metaColumn, isTiny && tinyStyles.metaColumn]}>
268
+ <View style={styles.identityTitleRow}>
269
+ <Text
270
+ numberOfLines={2}
271
+ style={[
272
+ styles.identityTitle,
273
+ {
274
+ color: colors.foreground,
275
+ ...typography.heading,
276
+ },
277
+ isTiny && tinyStyles.title,
278
+ ]}
279
+ >
280
+ {name}
281
+ </Text>
282
+ <Badge
283
+ variant="accent"
284
+ label={`v${version}`}
285
+ textStyle={isTiny ? tinyStyles.text9 : undefined}
286
+ />
287
+ </View>
288
+
289
+ {description ? (
290
+ <Text
291
+ numberOfLines={3}
292
+ style={[
293
+ styles.identityDescription,
294
+ { color: colors.foregroundMuted, ...typography.body },
295
+ isTiny && tinyStyles.text9,
296
+ ]}
297
+ >
298
+ {description}
299
+ </Text>
300
+ ) : null}
301
+
302
+ {author ? (
303
+ <Text
304
+ style={[
305
+ styles.authorText,
306
+ { color: colors.foregroundMuted, ...typography.caption },
307
+ isTiny && tinyStyles.text9,
308
+ ]}
309
+ >
310
+ by {author}
311
+ </Text>
312
+ ) : null}
313
+
314
+ {license ? (
315
+ <View style={styles.titleRow}>
316
+ <Badge
317
+ variant="neutral"
318
+ label={license}
319
+ textStyle={isTiny ? tinyStyles.text9 : undefined}
320
+ />
321
+ </View>
322
+ ) : null}
323
+ </View>
324
+ </View>
325
+
326
+ {/* Action Buttons: External Links & Diagnostics */}
327
+ <View style={[styles.actionsRow, isTiny && tinyStyles.actionsRow]}>
328
+ {allLinks.map((link) => (
329
+ <Button
330
+ key={link.url}
331
+ size="sm"
332
+ variant="secondary"
333
+ icon={link.icon ?? "ExternalLink"}
334
+ label={link.label}
335
+ onPress={() => handleOpenUrl(link.url)}
336
+ textStyle={isTiny ? tinyStyles.text9 : undefined}
337
+ />
338
+ ))}
339
+
340
+ {showDiagnosticsCopy && (
341
+ <Button
342
+ size="sm"
343
+ variant={copied ? "primary" : "ghost"}
344
+ icon={copied ? "Check" : "Copy"}
345
+ label={copied ? "Diagnostics Copied!" : "Copy Diagnostics"}
346
+ onPress={handleCopyDiagnostics}
347
+ textStyle={isTiny ? tinyStyles.text9 : undefined}
348
+ />
349
+ )}
350
+ </View>
351
+ </Card>
352
+
353
+ {/* Environment & Extra Diagnostics Details */}
354
+ <Card variant="elevated" style={isTiny ? tinyStyles.card : undefined}>
355
+ <Card.Header
356
+ title="Runtime Environment"
357
+ subtitle="Diagnostics for issue reports and system verification"
358
+ titleStyle={isTiny ? tinyStyles.title : undefined}
359
+ subtitleStyle={isTiny ? tinyStyles.text9 : undefined}
360
+ />
361
+ <KeyValueGroup columns={isCompact ? 1 : 2} gap={isTiny ? 6 : undefined}>
362
+ <KeyValue
363
+ label="Plugin Version"
364
+ value={`v${version}`}
365
+ copyable
366
+ labelStyle={isTiny ? tinyStyles.text9 : undefined}
367
+ valueStyle={isTiny ? tinyStyles.text9 : undefined}
368
+ />
369
+ <KeyValue
370
+ label="Client Platform"
371
+ value={platform}
372
+ labelStyle={isTiny ? tinyStyles.text9 : undefined}
373
+ valueStyle={isTiny ? tinyStyles.text9 : undefined}
374
+ />
375
+ {author ? (
376
+ <KeyValue
377
+ label="Author"
378
+ value={author}
379
+ labelStyle={isTiny ? tinyStyles.text9 : undefined}
380
+ valueStyle={isTiny ? tinyStyles.text9 : undefined}
381
+ />
382
+ ) : null}
383
+ {license ? (
384
+ <KeyValue
385
+ label="License"
386
+ value={license}
387
+ labelStyle={isTiny ? tinyStyles.text9 : undefined}
388
+ valueStyle={isTiny ? tinyStyles.text9 : undefined}
389
+ />
390
+ ) : null}
391
+ {extraItems.map((item, idx) => (
392
+ <KeyValue
393
+ key={idx}
394
+ label={item.label}
395
+ value={item.value}
396
+ subValue={item.subValue}
397
+ copyable={item.copyable}
398
+ labelStyle={isTiny ? tinyStyles.text9 : undefined}
399
+ valueStyle={isTiny ? tinyStyles.text9 : undefined}
400
+ />
401
+ ))}
402
+ </KeyValueGroup>
403
+ </Card>
404
+ </View>
405
+ );
406
+ }
407
+
408
+ const styles = StyleSheet.create({
409
+ container: {
410
+ gap: 12,
411
+ },
412
+ headerRow: {
413
+ flexDirection: "row",
414
+ alignItems: "center",
415
+ gap: 14,
416
+ },
417
+ logoImage: {
418
+ width: 48,
419
+ height: 48,
420
+ resizeMode: "cover",
421
+ },
422
+ logoIconFallback: {
423
+ width: 48,
424
+ height: 48,
425
+ alignItems: "center",
426
+ justifyContent: "center",
427
+ borderWidth: 1,
428
+ },
429
+ metaColumn: {
430
+ flex: 1,
431
+ gap: 5,
432
+ minWidth: 0,
433
+ },
434
+ identityTitleRow: {
435
+ flexDirection: "row",
436
+ alignItems: "center",
437
+ flexWrap: "wrap",
438
+ gap: 6,
439
+ },
440
+ identityTitle: {
441
+ flexShrink: 1,
442
+ },
443
+ identityDescription: {
444
+ flexShrink: 1,
445
+ },
446
+ titleRow: {
447
+ flexDirection: "row",
448
+ alignItems: "center",
449
+ flexWrap: "wrap",
450
+ gap: 6,
451
+ },
452
+ authorText: {
453
+ flexShrink: 1,
454
+ },
455
+ actionsRow: {
456
+ flexDirection: "row",
457
+ alignItems: "center",
458
+ flexWrap: "wrap",
459
+ gap: 8,
460
+ marginTop: 12,
461
+ paddingTop: 10,
462
+ borderTopWidth: StyleSheet.hairlineWidth,
463
+ borderTopColor: "rgba(128, 128, 128, 0.2)",
464
+ },
465
+ });
466
+
467
+ const tinyStyles = StyleSheet.create({
468
+ container: {
469
+ gap: 6,
470
+ },
471
+ card: {
472
+ paddingHorizontal: 8,
473
+ paddingVertical: 8,
474
+ gap: 6,
475
+ },
476
+ title: {
477
+ fontSize: 11,
478
+ },
479
+ text9: {
480
+ fontSize: 9,
481
+ },
482
+ headerRow: {
483
+ gap: 8,
484
+ },
485
+ metaColumn: {
486
+ gap: 4,
487
+ },
488
+ actionsRow: {
489
+ gap: 6,
490
+ marginTop: 8,
491
+ paddingTop: 6,
492
+ },
493
+ });