@xpufx/paseo-forges 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 (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/client/active-forge.ts +47 -0
  4. package/client/board-alert.tsx +236 -0
  5. package/client/foreign-link.tsx +33 -0
  6. package/client/hook-queue-panel.tsx +412 -0
  7. package/client/issues-pill.tsx +1976 -0
  8. package/client/label-chip.tsx +116 -0
  9. package/client/linkifier.tsx +280 -0
  10. package/client/pill-label.ts +183 -0
  11. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  12. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  13. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  14. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  15. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  16. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  17. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  18. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  19. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  20. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  21. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  22. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  23. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  24. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  25. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  26. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  27. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  28. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  29. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  30. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  31. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  32. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  33. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  34. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  35. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  36. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  37. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  38. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  39. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  40. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  41. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  42. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  43. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  44. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  45. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  46. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  47. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  48. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  49. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  50. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  51. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  52. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  53. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  54. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  55. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  56. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  57. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  58. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  59. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  60. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  61. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  62. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  63. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  64. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  65. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  66. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  67. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  68. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  69. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  70. package/client/webhook-card.tsx +198 -0
  71. package/docs/specs/forge-workflow-gui.md +820 -0
  72. package/docs/workflow.md +339 -0
  73. package/examples/README.md +108 -0
  74. package/examples/hook-service/README.md +66 -0
  75. package/examples/hook-service/forge-hook.service +28 -0
  76. package/examples/hook-service/hook-server.mjs +265 -0
  77. package/examples/hook-service/hook.env.example +21 -0
  78. package/examples/labels/README.md +64 -0
  79. package/examples/labels/label-base.yaml +117 -0
  80. package/examples/skills/coding-agent/SKILL.md +262 -0
  81. package/examples/skills/coding-agent-fgjx/SKILL.md +271 -0
  82. package/examples/skills/orchestrator/SKILL.md +133 -0
  83. package/examples/skills/orchestrator-fgjx/SKILL.md +139 -0
  84. package/examples/tools/README.md +68 -0
  85. package/examples/tools/fgjx +464 -0
  86. package/package.json +38 -0
  87. package/paseo-plugin.json +4 -0
  88. package/server/forge-client.ts +484 -0
  89. package/server/forge-guard.ts +70 -0
  90. package/server/git-origin.ts +70 -0
  91. package/server/hook-queue.ts +127 -0
  92. package/server/issues.ts +542 -0
  93. package/server/settings.ts +52 -0
  94. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  95. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  96. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  97. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  98. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  99. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  100. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  101. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  102. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  103. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  104. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  105. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  106. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  107. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  108. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  109. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  110. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  111. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  112. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  113. package/shared/hook-queue.ts +140 -0
  114. package/shared/issues.ts +1750 -0
  115. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  116. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  117. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  118. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  119. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  120. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  121. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  122. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  123. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  124. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  125. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  126. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  127. package/shared/version.ts +2 -0
  128. package/shared/webhook.ts +223 -0
@@ -0,0 +1,116 @@
1
+ import React from "react";
2
+ import { View, type StyleProp, type ViewStyle } from "react-native";
3
+ import { Badge, usePluginTheme } from "paseo-plugin-helper/client";
4
+ import { planLabelChip, type ForgeLabel, type LabelChipHalf } from "../shared/issues.js";
5
+
6
+ /** Inner corners join the halves; the scope's right border becomes the divider. */
7
+ function halfEdgeStyle(side: "scope" | "value"): ViewStyle {
8
+ return side === "scope"
9
+ ? { borderTopRightRadius: 0, borderBottomRightRadius: 0 }
10
+ : { borderTopLeftRadius: 0, borderBottomLeftRadius: 0, borderLeftWidth: 0 };
11
+ }
12
+
13
+ /**
14
+ * Width guard shared by every chip. `flexShrink` lets chips surrender space to
15
+ * their neighbours on a wrap line instead of each claiming its own, and the cap
16
+ * stops one from spilling past the row. The value half is pre-truncated by
17
+ * `planLabelChip`, so this is a bound, not the primary compaction.
18
+ */
19
+ const CHIP_WIDTH_STYLE: ViewStyle = { flexShrink: 1, maxWidth: "100%" };
20
+
21
+ function ChipHalf({
22
+ half,
23
+ side,
24
+ ring,
25
+ query,
26
+ }: {
27
+ half: LabelChipHalf;
28
+ side?: "scope" | "value";
29
+ ring?: StyleProp<ViewStyle>;
30
+ query?: string;
31
+ }) {
32
+ const filled = Boolean(half.background && half.textColor);
33
+ const fillStyle: ViewStyle = filled
34
+ ? { backgroundColor: half.background, borderColor: half.background }
35
+ : {};
36
+ return (
37
+ <Badge
38
+ variant="neutral"
39
+ styleVariant={filled ? "solid" : "tinted"}
40
+ size="sm"
41
+ label={half.text}
42
+ highlightQuery={query}
43
+ style={[CHIP_WIDTH_STYLE, side ? halfEdgeStyle(side) : undefined, fillStyle, ring]}
44
+ textStyle={filled ? { color: half.textColor } : undefined}
45
+ />
46
+ );
47
+ }
48
+
49
+ /**
50
+ * One Forgejo-style label pill. A scoped name (`scope/value`) always renders as
51
+ * two segments — the scope in a darker shade of the API color, the value in the
52
+ * base color — and never as the raw slash form; without a usable color both
53
+ * segments fall back to the neutral theme chip, still split. Every label
54
+ * surface routes through this component.
55
+ */
56
+ export function LabelChip({
57
+ label,
58
+ selected = false,
59
+ query,
60
+ }: {
61
+ label: ForgeLabel;
62
+ selected?: boolean;
63
+ query?: string;
64
+ }) {
65
+ const { colors, resolveRadius } = usePluginTheme();
66
+ const ring: StyleProp<ViewStyle> = selected
67
+ ? { borderColor: colors.accent, borderWidth: 2 }
68
+ : undefined;
69
+ const plan = planLabelChip(label);
70
+ if (plan.kind === "single") {
71
+ return <ChipHalf half={plan.half} ring={ring} query={query} />;
72
+ }
73
+ return (
74
+ <View
75
+ style={[
76
+ {
77
+ flexDirection: "row",
78
+ alignSelf: "flex-start",
79
+ borderRadius: resolveRadius("pill"),
80
+ },
81
+ CHIP_WIDTH_STYLE,
82
+ ring,
83
+ ]}
84
+ >
85
+ <ChipHalf half={plan.scope} side="scope" query={query} />
86
+ <ChipHalf half={plan.value} side="value" query={query} />
87
+ </View>
88
+ );
89
+ }
90
+
91
+ /** Wrapping row the label lists share; a caller only supplies layout deltas. */
92
+ const CHIP_LIST_STYLE: ViewStyle = { flexDirection: "row", flexWrap: "wrap", gap: 4 };
93
+
94
+ /**
95
+ * The single render path for a list of labels. Surfaces supply only layout, so
96
+ * no site maps its own chips and colors always flow through `planLabelChip`.
97
+ */
98
+ export function LabelChipList({
99
+ labels,
100
+ style,
101
+ query,
102
+ }: {
103
+ labels: readonly ForgeLabel[];
104
+ style?: StyleProp<ViewStyle>;
105
+ /** Active search query; label names are highlighted when supplied. */
106
+ query?: string;
107
+ }) {
108
+ if (labels.length === 0) return null;
109
+ return (
110
+ <View style={[CHIP_LIST_STYLE, style]}>
111
+ {labels.map((label) => (
112
+ <LabelChip key={label.name} label={label} query={query} />
113
+ ))}
114
+ </View>
115
+ );
116
+ }
@@ -0,0 +1,280 @@
1
+ import React from "react";
2
+ import { Linking, Pressable, StyleSheet, Text, View } from "react-native";
3
+ import { z } from "zod";
4
+ import type {
5
+ PluginTimelineItemProps,
6
+ PluginTimelineTransformerContribution,
7
+ PluginTimelineRendererContribution,
8
+ } from "@getpaseo/plugin/client";
9
+ import type { PluginTheme } from "@getpaseo/plugin";
10
+ import { Icon } from "@getpaseo/plugin/client/react-native";
11
+ import { ForgeIcon, copyToClipboard } from "paseo-plugin-helper/client";
12
+ import {
13
+ classifyForgeLink,
14
+ classifyForgeUrl,
15
+ extractBareForgeIssueUrls,
16
+ parseMarkdownLite,
17
+ type ForgeRepoIdentity,
18
+ type ForgeIssueLink,
19
+ type MarkdownLiteSpan,
20
+ } from "../shared/issues.js";
21
+ import { useActiveForgeIdentityForAgent } from "./active-forge.js";
22
+ import { FOREIGN_LINK_HINT, ForeignInlineMark, ForeignLinkBadge } from "./foreign-link.js";
23
+
24
+ const ForgeIssueLinkSchema = z.object({
25
+ text: z.string(),
26
+ links: z.array(
27
+ z.object({
28
+ host: z.string(),
29
+ owner: z.string(),
30
+ repo: z.string(),
31
+ number: z.number(),
32
+ commentId: z.number().optional(),
33
+ url: z.string(),
34
+ }),
35
+ ),
36
+ });
37
+ type ForgeIssueLinkData = z.infer<typeof ForgeIssueLinkSchema>;
38
+
39
+ function transformTextItem<T extends { text: string }>(item: T) {
40
+ const links = extractBareForgeIssueUrls(item.text);
41
+ if (links.length === 0) return undefined;
42
+ return {
43
+ items: [
44
+ {
45
+ type: "plugin" as const,
46
+ kind: "forge-issue-link",
47
+ version: 1,
48
+ data: {
49
+ text: item.text,
50
+ links: links.map((link) => ({
51
+ host: link.host,
52
+ owner: link.owner,
53
+ repo: link.repo,
54
+ number: link.number,
55
+ ...(link.commentId != null ? { commentId: link.commentId } : {}),
56
+ url: link.url,
57
+ })),
58
+ },
59
+ },
60
+ ],
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Timeline linkifier: any chat message carrying forge issue URLs gets a
66
+ * compact link card alongside it. Both user and assistant messages are
67
+ * covered; messages without issue URLs pass through untouched.
68
+ */
69
+ export const forgeLinkUserTransformer: PluginTimelineTransformerContribution<"user_message"> = {
70
+ id: "forge-issue-link-user",
71
+ query: { itemType: "user_message" },
72
+ transform({ item }) {
73
+ return transformTextItem(item);
74
+ },
75
+ };
76
+
77
+ export const forgeLinkAssistantTransformer: PluginTimelineTransformerContribution<"assistant_message"> = {
78
+ id: "forge-issue-link-assistant",
79
+ query: { itemType: "assistant_message" },
80
+ transform({ item }) {
81
+ return transformTextItem(item);
82
+ },
83
+ };
84
+
85
+ function IssueLinkRow({
86
+ theme,
87
+ link,
88
+ foreign,
89
+ }: {
90
+ theme: PluginTheme;
91
+ link: ForgeIssueLink;
92
+ foreign: boolean;
93
+ }) {
94
+ const open = () => {
95
+ Linking.openURL(link.url).catch(() => {});
96
+ };
97
+ const copy = () => {
98
+ copyToClipboard(link.url).catch(() => {});
99
+ };
100
+ return (
101
+ <View style={styles.row}>
102
+ <ForgeIcon host={link.host} size={13} color={theme.colors.accent} />
103
+ <Pressable
104
+ style={styles.linkBody}
105
+ onPress={open}
106
+ hitSlop={8}
107
+ accessibilityRole="link"
108
+ accessibilityLabel={
109
+ foreign
110
+ ? `${link.owner}/${link.repo}#${link.number} on ${link.host} — ${FOREIGN_LINK_HINT}`
111
+ : undefined
112
+ }
113
+ >
114
+ <Text style={[styles.linkText, { color: theme.colors.accent }]}>
115
+ {link.owner}/{link.repo}#{link.number}
116
+ </Text>
117
+ <Text style={[styles.hostText, { color: theme.colors.foregroundMuted }]}>
118
+ {link.host}
119
+ </Text>
120
+ </Pressable>
121
+ {foreign ? <ForeignLinkBadge /> : null}
122
+ <Pressable accessibilityRole="button" accessibilityLabel="Copy issue link" onPress={copy} hitSlop={10}>
123
+ <Text style={[styles.copyText, { color: theme.colors.foregroundMuted }]}>⧉</Text>
124
+ </Pressable>
125
+ </View>
126
+ );
127
+ }
128
+
129
+ function FormattedSpans({
130
+ theme,
131
+ spans,
132
+ activeForge,
133
+ }: {
134
+ theme: PluginTheme;
135
+ spans: MarkdownLiteSpan[];
136
+ activeForge: ForgeRepoIdentity | null;
137
+ }) {
138
+ return (
139
+ <Text style={[styles.bodyText, { color: theme.colors.foreground }]}>
140
+ {spans.map((span, index) => {
141
+ if (span.kind === "link") {
142
+ const foreign = classifyForgeUrl(span.url, activeForge) === "foreign";
143
+ return (
144
+ <Text
145
+ key={index}
146
+ style={{ color: theme.colors.accent, fontWeight: "600" }}
147
+ onPress={() => Linking.openURL(span.url).catch(() => {})}
148
+ >
149
+ {span.text}
150
+ {foreign ? <ForeignInlineMark color={theme.colors.statusWarning} /> : null}
151
+ </Text>
152
+ );
153
+ }
154
+ if (span.kind === "code") {
155
+ return (
156
+ <Text key={index} style={{ fontFamily: "monospace" }}>
157
+ {span.text}
158
+ </Text>
159
+ );
160
+ }
161
+ if (span.kind === "bold") {
162
+ return (
163
+ <Text key={index} style={{ fontWeight: "700" }}>
164
+ {span.text}
165
+ </Text>
166
+ );
167
+ }
168
+ if (span.kind === "italic") {
169
+ return (
170
+ <Text key={index} style={{ fontStyle: "italic" }}>
171
+ {span.text}
172
+ </Text>
173
+ );
174
+ }
175
+ return <Text key={index}>{span.text}</Text>;
176
+ })}
177
+ </Text>
178
+ );
179
+ }
180
+
181
+ function ForgeIssueLinks({ theme, item, agentId }: PluginTimelineItemProps<ForgeIssueLinkData>) {
182
+ const activeForge = useActiveForgeIdentityForAgent(agentId);
183
+ const blocks = parseMarkdownLite(item.data.text);
184
+ return (
185
+ <View style={styles.card}>
186
+ {blocks.map((block, index) => {
187
+ if (block.kind === "code") {
188
+ return (
189
+ <Text
190
+ key={index}
191
+ selectable
192
+ style={[styles.bodyText, { color: theme.colors.foregroundMuted, fontFamily: "monospace" }]}
193
+ >
194
+ {block.text}
195
+ </Text>
196
+ );
197
+ }
198
+ if (block.kind === "heading") {
199
+ return <FormattedSpans key={index} theme={theme} spans={block.spans} activeForge={activeForge} />;
200
+ }
201
+ if (block.kind === "list") {
202
+ return (
203
+ <View key={index} style={{ gap: 2 }}>
204
+ {block.items.map((spans, itemIndex) => (
205
+ <View key={itemIndex} style={{ flexDirection: "row", gap: 6 }}>
206
+ <Text style={[styles.bodyText, { color: theme.colors.foregroundMuted }]}>
207
+ {block.ordered ? `${itemIndex + 1}.` : "•"}
208
+ </Text>
209
+ <View style={{ flex: 1 }}>
210
+ <FormattedSpans theme={theme} spans={spans} activeForge={activeForge} />
211
+ </View>
212
+ </View>
213
+ ))}
214
+ </View>
215
+ );
216
+ }
217
+ return <FormattedSpans key={index} theme={theme} spans={block.spans} activeForge={activeForge} />;
218
+ })}
219
+ <View style={styles.header}>
220
+ <Icon name="GitPullRequest" size={13} color={theme.colors.foregroundMuted} />
221
+ <Text style={[styles.headerText, { color: theme.colors.foregroundMuted }]}>
222
+ {item.data.links.length === 1 ? "Linked issue" : `${item.data.links.length} linked issues`}
223
+ </Text>
224
+ </View>
225
+ {item.data.links.map((link) => (
226
+ <IssueLinkRow
227
+ key={`${link.host}/${link.owner}/${link.repo}#${link.number}`}
228
+ theme={theme}
229
+ link={link}
230
+ foreign={classifyForgeLink(link, activeForge) === "foreign"}
231
+ />
232
+ ))}
233
+ </View>
234
+ );
235
+ }
236
+
237
+ export const forgeLinkRenderer: PluginTimelineRendererContribution<typeof ForgeIssueLinkSchema> = {
238
+ kind: "forge-issue-link",
239
+ version: 1,
240
+ schema: ForgeIssueLinkSchema,
241
+ Component: ForgeIssueLinks,
242
+ };
243
+
244
+ const styles = StyleSheet.create({
245
+ card: {
246
+ paddingVertical: 4,
247
+ gap: 4,
248
+ },
249
+ bodyText: {
250
+ fontSize: 13,
251
+ },
252
+ header: {
253
+ flexDirection: "row",
254
+ alignItems: "center",
255
+ gap: 6,
256
+ },
257
+ headerText: {
258
+ fontSize: 11,
259
+ fontWeight: "600",
260
+ },
261
+ row: {
262
+ flexDirection: "row",
263
+ alignItems: "center",
264
+ gap: 8,
265
+ },
266
+ linkBody: {
267
+ flex: 1,
268
+ gap: 1,
269
+ },
270
+ linkText: {
271
+ fontSize: 13,
272
+ fontWeight: "600",
273
+ },
274
+ hostText: {
275
+ fontSize: 11,
276
+ },
277
+ copyText: {
278
+ fontSize: 14,
279
+ },
280
+ });
@@ -0,0 +1,183 @@
1
+ import {
2
+ activeForgeForDirectory,
3
+ displayNameForDirectory,
4
+ forgeSettingsContract,
5
+ formatIssueCountLabel,
6
+ openIssuesContract,
7
+ workspaceNameKey,
8
+ type ForgeSettings,
9
+ type OpenIssuesOutput,
10
+ } from "../shared/issues.js";
11
+
12
+ export interface ForgePillLabelInput {
13
+ /** Workspace display name (explicit label, else the resolved repo). */
14
+ displayName?: string | null;
15
+ /** Open issue count, or null/undefined while unknown. */
16
+ count?: number | null;
17
+ /** True while the first fetch is still in flight. */
18
+ loading?: boolean;
19
+ }
20
+
21
+ /**
22
+ * Composer pill label for a workspace's open issues.
23
+ *
24
+ * Resolution order: the workspace display name while the count is unknown
25
+ * (`tea`), `name · N` once it resolves (`tea · 3`), the bare count when no
26
+ * display name exists (`3 issues`), and an ellipsis while nothing is known.
27
+ * Never a placeholder literal: the old `iss` regression came from a bare
28
+ * fallback leaking into the host-rendered button label.
29
+ */
30
+ export function forgePillLabel(input: ForgePillLabelInput = {}): string {
31
+ if (input.loading) return "...";
32
+ const displayName =
33
+ typeof input.displayName === "string" && input.displayName.trim()
34
+ ? input.displayName.trim()
35
+ : null;
36
+ const count = input.count ?? null;
37
+ if (count == null) return displayName ?? "...";
38
+ return displayName ? `${displayName} · ${count}` : formatIssueCountLabel(count);
39
+ }
40
+
41
+ /** Workspace coordinates the label resolver needs, mirroring the plugin snapshot. */
42
+ export interface ForgePillWorkspace {
43
+ directory?: string;
44
+ projectRootPath?: string;
45
+ }
46
+
47
+ export type ForgePillRpc = (contract: unknown, input: unknown) => Promise<unknown>;
48
+
49
+ /**
50
+ * Host-provided capabilities the pill label needs outside React. On
51
+ * button-shaped hosts (Paseo 0.8+) `renderPill` never mounts, so the label
52
+ * resolver must fetch its own data instead of reading a hook-populated cache.
53
+ */
54
+ export interface ForgePillRuntime {
55
+ rpc: ForgePillRpc;
56
+ resolveWorkspace: (workspaceId: string) => Promise<ForgePillWorkspace | null>;
57
+ /** Injectable clock so tests can age the caches deterministically. */
58
+ now?: () => number;
59
+ }
60
+
61
+ export interface ForgePillContext {
62
+ agentId: string;
63
+ workspaceId: string;
64
+ }
65
+
66
+ export interface ForgeLabelResolver {
67
+ /** Resolves the live pill label, refreshing the count when stale. */
68
+ resolve(ctx: ForgePillContext): Promise<string>;
69
+ }
70
+
71
+ interface CachedCount {
72
+ directory?: string;
73
+ count: number | null;
74
+ displayName: string | null;
75
+ at: number;
76
+ }
77
+
78
+ const PILL_DATA_TTL_MS = 15000;
79
+ const SETTINGS_TTL_MS = 30000;
80
+ const WORKSPACE_TTL_MS = 30000;
81
+
82
+ /**
83
+ * Builds a self-contained composer pill label resolver.
84
+ *
85
+ * On button-shaped hosts the helper resolves the label once at registration and
86
+ * then every refresh interval, so this resolver owns its data: it resolves the
87
+ * workspace directory from the host, reads the plugin settings, and queries the
88
+ * open-issues contract itself. Counts are cached per workspace with a TTL and a
89
+ * shared in-flight guard so multiple agent pills in one workspace stay cheap.
90
+ * Nothing here ever returns a placeholder literal — failures fall back to the
91
+ * workspace name or an ellipsis.
92
+ */
93
+ export function createForgeLabelResolver(runtime: ForgePillRuntime): ForgeLabelResolver {
94
+ const now = runtime.now ?? (() => Date.now());
95
+ const counts = new Map<string, CachedCount>();
96
+ const countInflight = new Map<string, Promise<void>>();
97
+ const workspaces = new Map<string, { info: ForgePillWorkspace | null; at: number }>();
98
+ const workspaceInflight = new Map<string, Promise<ForgePillWorkspace | null>>();
99
+ let settingsCache: { value: ForgeSettings; at: number } | null = null;
100
+ let settingsInflight: Promise<ForgeSettings> | null = null;
101
+
102
+ function readCachedLabel(ctx: ForgePillContext): string {
103
+ const cached = counts.get(ctx.workspaceId);
104
+ return forgePillLabel({ displayName: cached?.displayName ?? null, count: cached?.count ?? null });
105
+ }
106
+
107
+ async function resolveWorkspace(workspaceId: string): Promise<ForgePillWorkspace | null> {
108
+ const cached = workspaces.get(workspaceId);
109
+ if (cached && now() - cached.at < WORKSPACE_TTL_MS) return cached.info;
110
+ let inflight = workspaceInflight.get(workspaceId);
111
+ if (!inflight) {
112
+ inflight = runtime
113
+ .resolveWorkspace(workspaceId)
114
+ .then((info) => {
115
+ workspaces.set(workspaceId, { info, at: now() });
116
+ return info;
117
+ })
118
+ .catch(() => null)
119
+ .finally(() => {
120
+ workspaceInflight.delete(workspaceId);
121
+ });
122
+ workspaceInflight.set(workspaceId, inflight);
123
+ }
124
+ return inflight;
125
+ }
126
+
127
+ async function resolveSettings(): Promise<ForgeSettings> {
128
+ if (settingsCache && now() - settingsCache.at < SETTINGS_TTL_MS) return settingsCache.value;
129
+ if (!settingsInflight) {
130
+ settingsInflight = runtime
131
+ .rpc(forgeSettingsContract.get, {})
132
+ .then((value) => value as ForgeSettings)
133
+ .catch(() => forgeSettingsContract.defaultSettings)
134
+ .then((value) => {
135
+ settingsCache = { value, at: now() };
136
+ return value;
137
+ })
138
+ .finally(() => {
139
+ settingsInflight = null;
140
+ });
141
+ }
142
+ return settingsInflight;
143
+ }
144
+
145
+ async function refresh(ctx: ForgePillContext): Promise<void> {
146
+ const workspace = await resolveWorkspace(ctx.workspaceId);
147
+ const directory = workspace?.directory;
148
+ const cached = counts.get(ctx.workspaceId);
149
+ if (cached && cached.directory === directory && now() - cached.at < PILL_DATA_TTL_MS) return;
150
+
151
+ const key = directory ?? ctx.workspaceId;
152
+ let inflight = countInflight.get(key);
153
+ if (!inflight) {
154
+ inflight = (async () => {
155
+ const settings = await resolveSettings();
156
+ const remoteUrl = activeForgeForDirectory(settings, directory) ?? undefined;
157
+ const data = (await runtime.rpc(openIssuesContract, {
158
+ directory: directory ?? undefined,
159
+ remoteUrl: remoteUrl || undefined,
160
+ })) as OpenIssuesOutput | undefined;
161
+ const count = data && !data.error ? (data.openIssueCount ?? data.issues.length) : null;
162
+ const nameKey = workspaceNameKey(directory, workspace?.projectRootPath);
163
+ const displayName = displayNameForDirectory(settings, nameKey, data?.repo);
164
+ counts.set(ctx.workspaceId, { directory, count, displayName, at: now() });
165
+ })()
166
+ .catch(() => {
167
+ // Keep the last cached label when the forge is unreachable.
168
+ })
169
+ .finally(() => {
170
+ countInflight.delete(key);
171
+ });
172
+ countInflight.set(key, inflight);
173
+ }
174
+ await inflight;
175
+ }
176
+
177
+ return {
178
+ resolve: async (ctx) => {
179
+ await refresh(ctx);
180
+ return readCachedLabel(ctx);
181
+ },
182
+ };
183
+ }
@@ -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
+ }