@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.
@@ -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
+ }
@@ -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
+ }