@gpambrozio/paseo-skills 0.1.2
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.
- package/CHANGELOG.md +53 -0
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/client/panel.tsx +542 -0
- package/client/pill.tsx +116 -0
- package/client/skills-query.tsx +29 -0
- package/index.client.tsx +27 -0
- package/index.server.ts +10 -0
- package/package.json +44 -0
- package/paseo-plugin.json +7 -0
- package/server/resolve/claude.ts +101 -0
- package/server/resolve/codex.ts +50 -0
- package/server/resolve/frontmatter.ts +88 -0
- package/server/resolve/repo-root.ts +43 -0
- package/server/resolve/reported.ts +80 -0
- package/server/resolve/skill-directory.ts +93 -0
- package/server/resolve/skill-entry.ts +39 -0
- package/server/sdk-types.ts +24 -0
- package/server/skills.ts +152 -0
- package/shared/skills.ts +67 -0
package/client/panel.tsx
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { type PluginAgentPanelProps, useAgent, usePaseo, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { copyText, useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { useCallback, useMemo, useState } from "react";
|
|
5
|
+
import {
|
|
6
|
+
ActivityIndicator,
|
|
7
|
+
Platform,
|
|
8
|
+
Pressable,
|
|
9
|
+
ScrollView,
|
|
10
|
+
Text,
|
|
11
|
+
TextInput,
|
|
12
|
+
View,
|
|
13
|
+
} from "react-native";
|
|
14
|
+
import type { z } from "zod";
|
|
15
|
+
|
|
16
|
+
import { useSkillsQuery } from "./skills-query";
|
|
17
|
+
import { readSkill, ReportedSkillSchema, SkillEntrySchema } from "../shared/skills";
|
|
18
|
+
|
|
19
|
+
type Skill = z.infer<typeof SkillEntrySchema>;
|
|
20
|
+
type ReportedSkill = z.infer<typeof ReportedSkillSchema>;
|
|
21
|
+
|
|
22
|
+
type Selection = { kind: "discovered"; id: string } | { kind: "reported"; name: string };
|
|
23
|
+
|
|
24
|
+
// Menlo does not exist on Android, where an unknown family silently falls back
|
|
25
|
+
// to the default proportional font.
|
|
26
|
+
const MONO = Platform.select({ ios: "Menlo", default: "monospace" });
|
|
27
|
+
|
|
28
|
+
// Groups render in resolver-precedence order, not in whatever order the
|
|
29
|
+
// alphabetically-sorted skill list happens to introduce them.
|
|
30
|
+
const SOURCE_ORDER: ReadonlyArray<Skill["source"]["kind"]> = [
|
|
31
|
+
"project",
|
|
32
|
+
"repo",
|
|
33
|
+
"personal",
|
|
34
|
+
"admin",
|
|
35
|
+
"plugin",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function groupBySource(skills: Skill[]): Array<{ label: string; skills: Skill[] }> {
|
|
39
|
+
const groups = new Map<string, { kind: Skill["source"]["kind"]; skills: Skill[] }>();
|
|
40
|
+
for (const skill of skills) {
|
|
41
|
+
const existing = groups.get(skill.source.label);
|
|
42
|
+
if (existing) existing.skills.push(skill);
|
|
43
|
+
else groups.set(skill.source.label, { kind: skill.source.kind, skills: [skill] });
|
|
44
|
+
}
|
|
45
|
+
return [...groups]
|
|
46
|
+
.sort(([labelA, groupA], [labelB, groupB]) => {
|
|
47
|
+
const rank = SOURCE_ORDER.indexOf(groupA.kind) - SOURCE_ORDER.indexOf(groupB.kind);
|
|
48
|
+
return rank !== 0 ? rank : labelA.localeCompare(labelB);
|
|
49
|
+
})
|
|
50
|
+
.map(([label, group]) => ({ label, skills: group.skills }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function detailStyles(theme: PluginAgentPanelProps["theme"], padding: number) {
|
|
54
|
+
return {
|
|
55
|
+
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
|
|
56
|
+
content: { padding },
|
|
57
|
+
back: { color: theme.colors.accent, marginBottom: padding },
|
|
58
|
+
name: { color: theme.colors.foreground, fontSize: 18, marginBottom: 4 },
|
|
59
|
+
description: { color: theme.colors.foregroundMuted, marginBottom: padding },
|
|
60
|
+
path: { color: theme.colors.foregroundMuted, fontSize: 12, fontFamily: MONO },
|
|
61
|
+
copy: { color: theme.colors.accent, marginTop: 4, marginBottom: padding },
|
|
62
|
+
argsInput: {
|
|
63
|
+
color: theme.colors.foreground,
|
|
64
|
+
borderWidth: 1,
|
|
65
|
+
borderColor: theme.colors.foregroundMuted,
|
|
66
|
+
borderRadius: 8,
|
|
67
|
+
paddingHorizontal: 12,
|
|
68
|
+
paddingVertical: 8,
|
|
69
|
+
marginBottom: 8,
|
|
70
|
+
},
|
|
71
|
+
invoke: {
|
|
72
|
+
backgroundColor: theme.colors.accent,
|
|
73
|
+
borderRadius: 8,
|
|
74
|
+
paddingVertical: 10,
|
|
75
|
+
alignItems: "center" as const,
|
|
76
|
+
marginBottom: padding,
|
|
77
|
+
},
|
|
78
|
+
invokeLabel: { color: theme.colors.accentForeground, fontSize: 15 },
|
|
79
|
+
body: { color: theme.colors.foreground, fontFamily: MONO, fontSize: 12 },
|
|
80
|
+
error: { color: theme.colors.statusDanger, marginBottom: padding },
|
|
81
|
+
notInvocable: { color: theme.colors.foregroundMuted, marginBottom: padding },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type DetailStyles = ReturnType<typeof detailStyles>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Owns the arguments field and the send. The re-entrancy guard lives here rather
|
|
89
|
+
* than in each detail screen: a double tap on a slow connection would otherwise
|
|
90
|
+
* invoke the skill twice on the user's live agent.
|
|
91
|
+
*
|
|
92
|
+
* `onInvoked` is deliberately not `onBack`. Both used to be the same callback,
|
|
93
|
+
* which meant a successful invoke left the user on the Skills tab watching the
|
|
94
|
+
* list — the turn it had just started was one tab away, with nothing saying so.
|
|
95
|
+
*/
|
|
96
|
+
function useInvoke(agentId: string, onInvoked: () => void) {
|
|
97
|
+
const paseo = usePaseo();
|
|
98
|
+
const [args, setArgs] = useState("");
|
|
99
|
+
const [invokeError, setInvokeError] = useState<string | null>(null);
|
|
100
|
+
const [isInvoking, setIsInvoking] = useState(false);
|
|
101
|
+
|
|
102
|
+
async function invoke(name: string) {
|
|
103
|
+
if (isInvoking) return;
|
|
104
|
+
setIsInvoking(true);
|
|
105
|
+
setInvokeError(null);
|
|
106
|
+
const trimmed = args.trim();
|
|
107
|
+
try {
|
|
108
|
+
await paseo.agents.ref(agentId).send(trimmed ? `/${name} ${trimmed}` : `/${name}`);
|
|
109
|
+
onInvoked();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
setInvokeError(error instanceof Error ? error.message : String(error));
|
|
112
|
+
setIsInvoking(false);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { args, setArgs, invoke, invokeError, isInvoking };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The arguments field, error line, and Invoke button, shared by both detail screens. */
|
|
120
|
+
function InvokeControls({
|
|
121
|
+
styles,
|
|
122
|
+
theme,
|
|
123
|
+
name,
|
|
124
|
+
controls,
|
|
125
|
+
}: {
|
|
126
|
+
styles: DetailStyles;
|
|
127
|
+
theme: PluginAgentPanelProps["theme"];
|
|
128
|
+
name: string;
|
|
129
|
+
controls: ReturnType<typeof useInvoke>;
|
|
130
|
+
}) {
|
|
131
|
+
return (
|
|
132
|
+
<>
|
|
133
|
+
<TextInput
|
|
134
|
+
style={styles.argsInput}
|
|
135
|
+
value={controls.args}
|
|
136
|
+
onChangeText={controls.setArgs}
|
|
137
|
+
placeholder="Arguments (optional)"
|
|
138
|
+
placeholderTextColor={theme.colors.foregroundMuted}
|
|
139
|
+
autoCorrect={false}
|
|
140
|
+
/>
|
|
141
|
+
{controls.invokeError ? <Text style={styles.error}>{controls.invokeError}</Text> : null}
|
|
142
|
+
<Pressable
|
|
143
|
+
style={styles.invoke}
|
|
144
|
+
disabled={controls.isInvoking}
|
|
145
|
+
onPress={() => void controls.invoke(name)}
|
|
146
|
+
>
|
|
147
|
+
<Text style={styles.invokeLabel}>{controls.isInvoking ? "Sending…" : "Invoke"}</Text>
|
|
148
|
+
</Pressable>
|
|
149
|
+
</>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function SkillDetail({
|
|
154
|
+
theme,
|
|
155
|
+
layout,
|
|
156
|
+
agentId,
|
|
157
|
+
skillId,
|
|
158
|
+
userInvocable,
|
|
159
|
+
onBack,
|
|
160
|
+
onInvoked,
|
|
161
|
+
}: {
|
|
162
|
+
theme: PluginAgentPanelProps["theme"];
|
|
163
|
+
layout: PluginAgentPanelProps["layout"];
|
|
164
|
+
agentId: string;
|
|
165
|
+
skillId: string;
|
|
166
|
+
userInvocable: boolean;
|
|
167
|
+
onBack: () => void;
|
|
168
|
+
onInvoked: () => void;
|
|
169
|
+
}) {
|
|
170
|
+
const callReadSkill = useRpc(readSkill);
|
|
171
|
+
const toast = useToast();
|
|
172
|
+
const controls = useInvoke(agentId, onInvoked);
|
|
173
|
+
|
|
174
|
+
const query = useQuery({
|
|
175
|
+
queryKey: ["skill", agentId, skillId],
|
|
176
|
+
queryFn: () => callReadSkill({ agentId, skillId }),
|
|
177
|
+
retry: false,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const padding = layout.compact ? 12 : 20;
|
|
181
|
+
const styles = useMemo(() => detailStyles(theme, padding), [theme, padding]);
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
|
|
185
|
+
<Pressable onPress={onBack}>
|
|
186
|
+
<Text style={styles.back}>← All skills</Text>
|
|
187
|
+
</Pressable>
|
|
188
|
+
{query.isPending ? (
|
|
189
|
+
<ActivityIndicator color={theme.colors.foregroundMuted} />
|
|
190
|
+
) : query.isError ? (
|
|
191
|
+
<Text style={styles.error}>{(query.error as Error).message}</Text>
|
|
192
|
+
) : (
|
|
193
|
+
<>
|
|
194
|
+
<Text style={styles.name}>{query.data.name}</Text>
|
|
195
|
+
<Text style={styles.description}>{query.data.description}</Text>
|
|
196
|
+
<Text style={styles.path} selectable>
|
|
197
|
+
{query.data.path}
|
|
198
|
+
</Text>
|
|
199
|
+
<Pressable
|
|
200
|
+
onPress={() => {
|
|
201
|
+
// The path is `selectable` above, so a platform that denies
|
|
202
|
+
// programmatic copying still leaves long-press and OS Copy.
|
|
203
|
+
const path = query.data.path;
|
|
204
|
+
void copyText(path).then(
|
|
205
|
+
() => toast.show("Path copied", { variant: "success" }),
|
|
206
|
+
() => toast.error("Could not copy. Select the path and use Copy."),
|
|
207
|
+
);
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
<Text style={styles.copy}>Copy path</Text>
|
|
211
|
+
</Pressable>
|
|
212
|
+
{userInvocable ? (
|
|
213
|
+
<InvokeControls
|
|
214
|
+
styles={styles}
|
|
215
|
+
theme={theme}
|
|
216
|
+
name={query.data.name}
|
|
217
|
+
controls={controls}
|
|
218
|
+
/>
|
|
219
|
+
) : (
|
|
220
|
+
<Text style={styles.notInvocable}>
|
|
221
|
+
This skill is model-invoked only. The agent can use it, but it cannot be run as a
|
|
222
|
+
command.
|
|
223
|
+
</Text>
|
|
224
|
+
)}
|
|
225
|
+
<Text style={styles.body}>{query.data.body}</Text>
|
|
226
|
+
</>
|
|
227
|
+
)}
|
|
228
|
+
</ScrollView>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Detail for an entry the session reported. There is no `SKILL.md` behind it, so
|
|
234
|
+
* there is no path, no body, and no `skills.read` call — but the description
|
|
235
|
+
* renders in full here rather than clipped to two lines, and the agent can still
|
|
236
|
+
* be asked to run it.
|
|
237
|
+
*/
|
|
238
|
+
function ReportedDetail({
|
|
239
|
+
theme,
|
|
240
|
+
layout,
|
|
241
|
+
agentId,
|
|
242
|
+
entry,
|
|
243
|
+
onBack,
|
|
244
|
+
onInvoked,
|
|
245
|
+
}: {
|
|
246
|
+
theme: PluginAgentPanelProps["theme"];
|
|
247
|
+
layout: PluginAgentPanelProps["layout"];
|
|
248
|
+
agentId: string;
|
|
249
|
+
entry: ReportedSkill;
|
|
250
|
+
onBack: () => void;
|
|
251
|
+
onInvoked: () => void;
|
|
252
|
+
}) {
|
|
253
|
+
const controls = useInvoke(agentId, onInvoked);
|
|
254
|
+
const padding = layout.compact ? 12 : 20;
|
|
255
|
+
const styles = useMemo(() => detailStyles(theme, padding), [theme, padding]);
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
|
|
259
|
+
<Pressable onPress={onBack}>
|
|
260
|
+
<Text style={styles.back}>← All skills</Text>
|
|
261
|
+
</Pressable>
|
|
262
|
+
<Text style={styles.name}>{entry.name}</Text>
|
|
263
|
+
{entry.argumentHint ? <Text style={styles.path}>{entry.argumentHint}</Text> : null}
|
|
264
|
+
<Text style={[styles.description, { marginTop: 8 }]} selectable>
|
|
265
|
+
{entry.description}
|
|
266
|
+
</Text>
|
|
267
|
+
<InvokeControls styles={styles} theme={theme} name={entry.name} controls={controls} />
|
|
268
|
+
<Text style={styles.notInvocable}>
|
|
269
|
+
The agent reported this itself. It has no SKILL.md on disk, so there is nothing further to
|
|
270
|
+
show.
|
|
271
|
+
</Text>
|
|
272
|
+
</ScrollView>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function listStyles(theme: PluginAgentPanelProps["theme"], padding: number) {
|
|
277
|
+
return {
|
|
278
|
+
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
|
|
279
|
+
content: { padding },
|
|
280
|
+
search: {
|
|
281
|
+
color: theme.colors.foreground,
|
|
282
|
+
backgroundColor: theme.colors.surface0,
|
|
283
|
+
borderWidth: 1,
|
|
284
|
+
borderColor: theme.colors.foregroundMuted,
|
|
285
|
+
borderRadius: 8,
|
|
286
|
+
paddingHorizontal: 12,
|
|
287
|
+
paddingVertical: 8,
|
|
288
|
+
marginBottom: padding,
|
|
289
|
+
},
|
|
290
|
+
groupLabel: {
|
|
291
|
+
color: theme.colors.foregroundMuted,
|
|
292
|
+
fontSize: 12,
|
|
293
|
+
textTransform: "uppercase" as const,
|
|
294
|
+
marginTop: padding,
|
|
295
|
+
marginBottom: 6,
|
|
296
|
+
},
|
|
297
|
+
row: { paddingVertical: 10 },
|
|
298
|
+
name: { color: theme.colors.foreground, fontSize: 15 },
|
|
299
|
+
hint: { color: theme.colors.foregroundMuted, fontSize: 13, fontFamily: MONO },
|
|
300
|
+
description: { color: theme.colors.foregroundMuted, fontSize: 13, marginTop: 2 },
|
|
301
|
+
groupNote: { color: theme.colors.foregroundMuted, fontSize: 12, marginBottom: 6 },
|
|
302
|
+
message: { color: theme.colors.foregroundMuted },
|
|
303
|
+
error: { color: theme.colors.statusDanger },
|
|
304
|
+
groupError: { color: theme.colors.statusDanger, fontSize: 13, marginBottom: 6 },
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
type ListStyles = ReturnType<typeof listStyles>;
|
|
309
|
+
|
|
310
|
+
export function SkillsPanel({ theme, layout, agentId, navigation }: PluginAgentPanelProps) {
|
|
311
|
+
// Only a liveness check — the panel reads everything else off the query, whose
|
|
312
|
+
// own hook is what tracks `cwd`.
|
|
313
|
+
const agentCwd = useAgent(agentId, (snapshot) => snapshot.cwd);
|
|
314
|
+
const [search, setSearch] = useState("");
|
|
315
|
+
// Discovered entries are addressed by their stable id; reported ones have no id
|
|
316
|
+
// and are addressed by name, which the server guarantees is unique across both
|
|
317
|
+
// reported buckets.
|
|
318
|
+
const [selected, setSelected] = useState<Selection | null>(null);
|
|
319
|
+
|
|
320
|
+
const query = useSkillsQuery(agentId);
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Invoking sent a message to the user's live agent, so the agent's own tab is
|
|
324
|
+
* where the result appears — leaving the user on the Skills tab hides the
|
|
325
|
+
* thing they just asked for. `openAgent` runs the app's own `navigateToAgent`
|
|
326
|
+
* against this host, which is the same call `github-board` makes after a send.
|
|
327
|
+
*
|
|
328
|
+
* The selection is cleared first, so the panel is back on its list when the
|
|
329
|
+
* user returns to this tab rather than on the detail of a skill already run.
|
|
330
|
+
*
|
|
331
|
+
* `navigation` is typed optional because hosts before 0.7.0-beta.3 passed
|
|
332
|
+
* nothing; this plugin requires Paseo >=0.8.0 and each app checks that against
|
|
333
|
+
* its own version before evaluating this bundle, so every client that can run
|
|
334
|
+
* this code passes it. The `undefined` branch is the old behaviour — back to
|
|
335
|
+
* the list, still on this tab — rather than a broken one.
|
|
336
|
+
*/
|
|
337
|
+
const handleInvoked = useCallback(
|
|
338
|
+
function handleInvoked() {
|
|
339
|
+
setSelected(null);
|
|
340
|
+
navigation?.openAgent({ agentId });
|
|
341
|
+
},
|
|
342
|
+
[agentId, navigation],
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const padding = layout.compact ? 12 : 20;
|
|
346
|
+
const styles = useMemo(() => listStyles(theme, padding), [theme, padding]);
|
|
347
|
+
|
|
348
|
+
const term = search.trim().toLowerCase();
|
|
349
|
+
|
|
350
|
+
const matches = (skill: { name: string; description: string }) =>
|
|
351
|
+
term.length === 0 ||
|
|
352
|
+
skill.name.toLowerCase().includes(term) ||
|
|
353
|
+
skill.description.toLowerCase().includes(term);
|
|
354
|
+
|
|
355
|
+
const filtered = useMemo(
|
|
356
|
+
() => (query.data?.skills ?? []).filter(matches),
|
|
357
|
+
[query.data, term],
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const filteredReportedSkills = useMemo(
|
|
361
|
+
() => (query.data?.reported.skills ?? []).filter(matches),
|
|
362
|
+
[query.data, term],
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const filteredReportedCommands = useMemo(
|
|
366
|
+
() => (query.data?.reported.commands ?? []).filter(matches),
|
|
367
|
+
[query.data, term],
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
if (selected?.kind === "discovered") {
|
|
371
|
+
const entry = (query.data?.skills ?? []).find((skill) => skill.id === selected.id);
|
|
372
|
+
return (
|
|
373
|
+
<SkillDetail
|
|
374
|
+
theme={theme}
|
|
375
|
+
layout={layout}
|
|
376
|
+
agentId={agentId}
|
|
377
|
+
skillId={selected.id}
|
|
378
|
+
userInvocable={entry?.userInvocable ?? true}
|
|
379
|
+
onBack={() => setSelected(null)}
|
|
380
|
+
onInvoked={handleInvoked}
|
|
381
|
+
/>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (selected?.kind === "reported") {
|
|
386
|
+
const reportedEntry = [
|
|
387
|
+
...(query.data?.reported.skills ?? []),
|
|
388
|
+
...(query.data?.reported.commands ?? []),
|
|
389
|
+
].find((entry) => entry.name === selected.name);
|
|
390
|
+
// A refetch can drop an entry the session no longer reports. Fall back to the
|
|
391
|
+
// list rather than rendering a detail screen for something that is gone.
|
|
392
|
+
if (!reportedEntry) {
|
|
393
|
+
setSelected(null);
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
return (
|
|
397
|
+
<ReportedDetail
|
|
398
|
+
theme={theme}
|
|
399
|
+
layout={layout}
|
|
400
|
+
agentId={agentId}
|
|
401
|
+
entry={reportedEntry}
|
|
402
|
+
onBack={() => setSelected(null)}
|
|
403
|
+
onInvoked={handleInvoked}
|
|
404
|
+
/>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (agentCwd == null) {
|
|
409
|
+
return (
|
|
410
|
+
<View style={[styles.screen, { padding }]}>
|
|
411
|
+
<Text style={styles.message}>This agent is no longer available.</Text>
|
|
412
|
+
</View>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (query.isPending) {
|
|
417
|
+
return (
|
|
418
|
+
<View style={[styles.screen, { padding }]}>
|
|
419
|
+
<ActivityIndicator color={theme.colors.foregroundMuted} />
|
|
420
|
+
</View>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (query.isError) {
|
|
425
|
+
return (
|
|
426
|
+
<View style={[styles.screen, { padding }]}>
|
|
427
|
+
<Text style={styles.error}>{(query.error as Error).message}</Text>
|
|
428
|
+
</View>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const reported = query.data?.reported;
|
|
433
|
+
// Every provider reaches this screen. A provider whose skill directories
|
|
434
|
+
// nobody has documented still reports what its session loaded, so the panel
|
|
435
|
+
// renders that and says where it came from rather than refusing outright.
|
|
436
|
+
const scanned = query.data?.scanned ?? false;
|
|
437
|
+
|
|
438
|
+
return (
|
|
439
|
+
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
|
|
440
|
+
<TextInput
|
|
441
|
+
style={styles.search}
|
|
442
|
+
value={search}
|
|
443
|
+
onChangeText={setSearch}
|
|
444
|
+
placeholder="Search skills"
|
|
445
|
+
placeholderTextColor={theme.colors.foregroundMuted}
|
|
446
|
+
autoCorrect={false}
|
|
447
|
+
autoCapitalize="none"
|
|
448
|
+
/>
|
|
449
|
+
{scanned ? null : (
|
|
450
|
+
<Text style={styles.groupNote}>
|
|
451
|
+
Skill files are only scanned for Claude and Codex. Everything below is what the{" "}
|
|
452
|
+
{query.data?.provider ?? "provider"} session reported.
|
|
453
|
+
</Text>
|
|
454
|
+
)}
|
|
455
|
+
{filtered.length === 0 &&
|
|
456
|
+
filteredReportedSkills.length === 0 &&
|
|
457
|
+
filteredReportedCommands.length === 0 &&
|
|
458
|
+
reported?.error == null ? (
|
|
459
|
+
<Text style={styles.message}>
|
|
460
|
+
{term.length > 0 ? "No skills match that search." : "No skills found."}
|
|
461
|
+
</Text>
|
|
462
|
+
) : null}
|
|
463
|
+
{groupBySource(filtered).map((group) => (
|
|
464
|
+
<View key={group.label}>
|
|
465
|
+
<Text style={styles.groupLabel}>{group.label}</Text>
|
|
466
|
+
{group.skills.map((skill) => (
|
|
467
|
+
<Pressable
|
|
468
|
+
key={skill.id}
|
|
469
|
+
style={styles.row}
|
|
470
|
+
onPress={() => setSelected({ kind: "discovered", id: skill.id })}
|
|
471
|
+
>
|
|
472
|
+
<Text style={styles.name}>{skill.name}</Text>
|
|
473
|
+
<Text style={styles.description} numberOfLines={2}>
|
|
474
|
+
{skill.description}
|
|
475
|
+
</Text>
|
|
476
|
+
</Pressable>
|
|
477
|
+
))}
|
|
478
|
+
</View>
|
|
479
|
+
))}
|
|
480
|
+
{reported?.error ? (
|
|
481
|
+
<View>
|
|
482
|
+
<Text style={styles.groupLabel}>Reported by the agent</Text>
|
|
483
|
+
<Text style={styles.groupError}>{reported.error}</Text>
|
|
484
|
+
</View>
|
|
485
|
+
) : null}
|
|
486
|
+
{filteredReportedSkills.length > 0 ? (
|
|
487
|
+
<ReportedGroup
|
|
488
|
+
styles={styles}
|
|
489
|
+
label="Built-in skills"
|
|
490
|
+
note="Loaded by the running session. These have no SKILL.md to read."
|
|
491
|
+
entries={filteredReportedSkills}
|
|
492
|
+
onSelect={(name) => setSelected({ kind: "reported", name })}
|
|
493
|
+
/>
|
|
494
|
+
) : null}
|
|
495
|
+
{filteredReportedCommands.length > 0 ? (
|
|
496
|
+
<ReportedGroup
|
|
497
|
+
styles={styles}
|
|
498
|
+
label="Built-in commands"
|
|
499
|
+
note="Session controls the agent reported, not skills."
|
|
500
|
+
entries={filteredReportedCommands}
|
|
501
|
+
onSelect={(name) => setSelected({ kind: "reported", name })}
|
|
502
|
+
/>
|
|
503
|
+
) : null}
|
|
504
|
+
</ScrollView>
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Rows open a reduced detail screen: no path and no body, but the full
|
|
510
|
+
* description and an Invoke button.
|
|
511
|
+
*/
|
|
512
|
+
function ReportedGroup({
|
|
513
|
+
styles,
|
|
514
|
+
label,
|
|
515
|
+
note,
|
|
516
|
+
entries,
|
|
517
|
+
onSelect,
|
|
518
|
+
}: {
|
|
519
|
+
styles: ListStyles;
|
|
520
|
+
label: string;
|
|
521
|
+
note: string;
|
|
522
|
+
entries: ReportedSkill[];
|
|
523
|
+
onSelect: (name: string) => void;
|
|
524
|
+
}) {
|
|
525
|
+
return (
|
|
526
|
+
<View>
|
|
527
|
+
<Text style={styles.groupLabel}>{label}</Text>
|
|
528
|
+
<Text style={styles.groupNote}>{note}</Text>
|
|
529
|
+
{entries.map((entry) => (
|
|
530
|
+
<Pressable key={entry.name} style={styles.row} onPress={() => onSelect(entry.name)}>
|
|
531
|
+
<Text style={styles.name}>
|
|
532
|
+
{entry.name}
|
|
533
|
+
{entry.argumentHint ? <Text style={styles.hint}> {entry.argumentHint}</Text> : null}
|
|
534
|
+
</Text>
|
|
535
|
+
<Text style={styles.description} numberOfLines={2}>
|
|
536
|
+
{entry.description}
|
|
537
|
+
</Text>
|
|
538
|
+
</Pressable>
|
|
539
|
+
))}
|
|
540
|
+
</View>
|
|
541
|
+
);
|
|
542
|
+
}
|
package/client/pill.tsx
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type PluginButtonIconProps,
|
|
3
|
+
type PluginButtonRegistration,
|
|
4
|
+
type PluginClientContext,
|
|
5
|
+
} from "@getpaseo/plugin/client";
|
|
6
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
7
|
+
import { useEffect } from "react";
|
|
8
|
+
|
|
9
|
+
import { countEntries, useSkillsQuery } from "./skills-query";
|
|
10
|
+
|
|
11
|
+
/** What the pill reads before the count is known, and the accessible name throughout. */
|
|
12
|
+
const TITLE = "Skills";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Paseo draws the pill from a plain description — an icon, a label and a press
|
|
16
|
+
* behaviour — so the count cannot come from a render. It comes from here: the
|
|
17
|
+
* icon is the one part of the description that *is* a component, so it runs the
|
|
18
|
+
* query and pushes the answer back into the label.
|
|
19
|
+
*
|
|
20
|
+
* That indirection buys the property the count needs. `contributePills`
|
|
21
|
+
* registers a pill for every agent on the host, but the host only mounts one
|
|
22
|
+
* when that agent's composer is on screen, so the scan stays bounded to the
|
|
23
|
+
* agents the user is looking at rather than to every agent that exists.
|
|
24
|
+
*
|
|
25
|
+
* The label is deliberately `Skills` until the query answers — a pill that
|
|
26
|
+
* reads `Skills 0` for a second on every mount is worse than one that reads
|
|
27
|
+
* `Skills` and then gains a number.
|
|
28
|
+
*/
|
|
29
|
+
function createPillIcon(agentId: string, pill: { current?: PluginButtonRegistration }) {
|
|
30
|
+
return function SkillsPillIcon({ size, color }: PluginButtonIconProps) {
|
|
31
|
+
const query = useSkillsQuery(agentId);
|
|
32
|
+
const label = query.data ? `${TITLE} ${countEntries(query.data)}` : TITLE;
|
|
33
|
+
|
|
34
|
+
// Updating an already-removed registration is a documented no-op, so an
|
|
35
|
+
// agent that goes away mid-query needs no teardown here.
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
pill.current?.update({ label });
|
|
38
|
+
}, [pill, label]);
|
|
39
|
+
|
|
40
|
+
return <Icon name="Sparkles" size={size} color={color} />;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One pill per agent, opening that agent's Skills panel.
|
|
46
|
+
*
|
|
47
|
+
* The client entry runs once per installation per connected app, so this owns
|
|
48
|
+
* the whole set: it seeds from the agents that already exist, follows the update
|
|
49
|
+
* stream for the rest, and removes every registration on teardown.
|
|
50
|
+
*/
|
|
51
|
+
export function contributePills(client: PluginClientContext) {
|
|
52
|
+
const pills = new Map<string, PluginButtonRegistration>();
|
|
53
|
+
|
|
54
|
+
function addPill(agentId: string, workspaceId: string) {
|
|
55
|
+
// Agent updates fire on every turn of every agent. Nothing in the pill
|
|
56
|
+
// depends on the snapshot, so re-registering would only unmount the icon
|
|
57
|
+
// and refire its query — and Paseo rejects a duplicate id outright.
|
|
58
|
+
if (pills.has(agentId)) return;
|
|
59
|
+
|
|
60
|
+
// The icon needs the registration that is about to be created from it, so
|
|
61
|
+
// it reaches the registration through this box rather than through a prop.
|
|
62
|
+
const pill: { current?: PluginButtonRegistration } = {};
|
|
63
|
+
pill.current = client.addComposerPill({
|
|
64
|
+
id: "skills",
|
|
65
|
+
workspaceId,
|
|
66
|
+
agentId,
|
|
67
|
+
button: {
|
|
68
|
+
title: TITLE,
|
|
69
|
+
label: TITLE,
|
|
70
|
+
icon: createPillIcon(agentId, pill),
|
|
71
|
+
behavior: {
|
|
72
|
+
kind: "action",
|
|
73
|
+
onPress() {
|
|
74
|
+
client.openPanel("skills", { workspaceId, agentId });
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
pills.set(agentId, pill.current);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function removePill(agentId: string) {
|
|
83
|
+
pills.get(agentId)?.remove();
|
|
84
|
+
pills.delete(agentId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
|
88
|
+
if (update.kind === "remove") {
|
|
89
|
+
removePill(update.agentId);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const { id, workspaceId } = update.agent;
|
|
93
|
+
if (workspaceId) addPill(id, workspaceId);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// `subscribe` only reports change. Without this seed, an agent that was
|
|
97
|
+
// already sitting idle when the app connected would have no pill until it
|
|
98
|
+
// next did something.
|
|
99
|
+
client.paseo.agents
|
|
100
|
+
.list()
|
|
101
|
+
.then((result) => {
|
|
102
|
+
// A `for…of` body would capture the loop binding, not the entry.
|
|
103
|
+
result.entries.forEach(({ agent }) => {
|
|
104
|
+
if (agent.workspaceId) addPill(agent.id, agent.workspaceId);
|
|
105
|
+
});
|
|
106
|
+
})
|
|
107
|
+
.catch((error: unknown) => {
|
|
108
|
+
console.error("skills: could not seed composer pills", error);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
unsubscribe();
|
|
113
|
+
pills.forEach((pill) => pill.remove());
|
|
114
|
+
pills.clear();
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useAgent, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import { listSkills } from "../shared/skills";
|
|
6
|
+
|
|
7
|
+
type SkillsResult = z.infer<(typeof listSkills)["output"]>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The panel and the composer pill ask the same question, so they share a query
|
|
11
|
+
* key and answer each other's cache: opening the panel from a pill that has
|
|
12
|
+
* already counted costs no second scan. `cwd` is in the key because a rebased or
|
|
13
|
+
* moved workspace changes what discovery finds.
|
|
14
|
+
*/
|
|
15
|
+
export function useSkillsQuery(agentId: string) {
|
|
16
|
+
const cwd = useAgent(agentId, (snapshot) => snapshot.cwd);
|
|
17
|
+
const callListSkills = useRpc(listSkills);
|
|
18
|
+
return useQuery({
|
|
19
|
+
queryKey: ["skills", agentId, cwd],
|
|
20
|
+
queryFn: () => callListSkills({ agentId }),
|
|
21
|
+
enabled: cwd != null,
|
|
22
|
+
retry: false,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Everything the panel lists, which is what the pill's badge promises. */
|
|
27
|
+
export function countEntries(data: SkillsResult): number {
|
|
28
|
+
return data.skills.length + data.reported.skills.length + data.reported.commands.length;
|
|
29
|
+
}
|