@gpambrozio/paseo-skills 0.2.1 → 0.3.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.
- package/CHANGELOG.md +11 -0
- package/README.md +7 -4
- package/client/browser.tsx +633 -0
- package/client/invoke.ts +36 -0
- package/client/panel.tsx +11 -518
- package/client/pill.tsx +5 -7
- package/client/popover.tsx +104 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Every version
|
|
|
7
7
|
as `@gpambrozio/paseo-skills` and tagged here, so a version is something to install and a line to
|
|
8
8
|
read before you move.
|
|
9
9
|
|
|
10
|
+
## [0.3.0] — 2026-09-26
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **The Skills pill opens a popover above the composer instead of a new tab.** It lists the same
|
|
15
|
+
skills the Skills tab does, with the same search. Pick one to see a short summary of it, type any
|
|
16
|
+
arguments and press **Invoke** — it is sent exactly as it is from the tab, and the popover closes
|
|
17
|
+
so you can watch the agent start. **← All skills** goes back to the list, and **Open tab** at the
|
|
18
|
+
top right opens the full Skills tab as the pill used to, where the whole skill is shown. The pill
|
|
19
|
+
still shows how many skills there are.
|
|
20
|
+
|
|
10
21
|
## [0.2.1] — 2026-09-23
|
|
11
22
|
|
|
12
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -49,9 +49,10 @@ plugin.
|
|
|
49
49
|
|
|
50
50
|
## Use
|
|
51
51
|
|
|
52
|
-
Press the **Skills** pill above an agent's composer — its badge counts what
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
Press the **Skills** pill above an agent's composer — its badge counts what it will list. It opens
|
|
53
|
+
a popover over the composer with the agent's skills: pick one to read it and invoke it with
|
|
54
|
+
arguments, or press **Open tab** at its top right for the full Skills panel. The Command Center
|
|
55
|
+
reaches the same panel: focus a workspace tab holding an agent, press ⌘K, and pick **Skills**.
|
|
55
56
|
|
|
56
57
|
## Demo
|
|
57
58
|
|
|
@@ -87,7 +88,9 @@ A failed reload stays failed; Paseo does not restore the previous code.
|
|
|
87
88
|
| `server/resolve/skill-entry.ts` | Entry types, skill id construction, first-wins dedupe. |
|
|
88
89
|
| `server/resolve/frontmatter.ts` | `SKILL.md` frontmatter parsing. |
|
|
89
90
|
| `server/resolve/reported.ts` | Splits session-reported entries discovery did not find. |
|
|
90
|
-
| `client/
|
|
91
|
+
| `client/browser.tsx` | List, search, detail, invoke — drawn by the panel and the popover. |
|
|
92
|
+
| `client/panel.tsx` | The panel: the browser as a whole tab. |
|
|
93
|
+
| `client/popover.tsx` | The pill's popover: the browser, plus a button to the panel. |
|
|
91
94
|
| `client/pill.tsx` | The composer pill and the registration loop that owns it. |
|
|
92
95
|
| `client/skills-query.tsx` | The `skills.list` query the panel and the pill share. |
|
|
93
96
|
|
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import { type PluginHostProps, 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 { type ReactNode, useCallback, useEffect, useMemo, useRef, 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 { invocationText, sendInvocation } from "./invoke";
|
|
17
|
+
import { useSkillsQuery } from "./skills-query";
|
|
18
|
+
import { readSkill, ReportedSkillSchema, SkillEntrySchema } from "../shared/skills";
|
|
19
|
+
|
|
20
|
+
type Theme = PluginHostProps["theme"];
|
|
21
|
+
type Skill = z.infer<typeof SkillEntrySchema>;
|
|
22
|
+
type ReportedSkill = z.infer<typeof ReportedSkillSchema>;
|
|
23
|
+
|
|
24
|
+
type Selection = { kind: "discovered"; id: string } | { kind: "reported"; name: string };
|
|
25
|
+
|
|
26
|
+
// Menlo does not exist on Android, where an unknown family silently falls back
|
|
27
|
+
// to the default proportional font.
|
|
28
|
+
const MONO = Platform.select({ ios: "Menlo", default: "monospace" });
|
|
29
|
+
|
|
30
|
+
// Groups render in resolver-precedence order, not in whatever order the
|
|
31
|
+
// alphabetically-sorted skill list happens to introduce them.
|
|
32
|
+
const SOURCE_ORDER: ReadonlyArray<Skill["source"]["kind"]> = [
|
|
33
|
+
"project",
|
|
34
|
+
"repo",
|
|
35
|
+
"personal",
|
|
36
|
+
"admin",
|
|
37
|
+
"plugin",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function groupBySource(skills: Skill[]): Array<{ label: string; skills: Skill[] }> {
|
|
41
|
+
const groups = new Map<string, { kind: Skill["source"]["kind"]; skills: Skill[] }>();
|
|
42
|
+
for (const skill of skills) {
|
|
43
|
+
const existing = groups.get(skill.source.label);
|
|
44
|
+
if (existing) existing.skills.push(skill);
|
|
45
|
+
else groups.set(skill.source.label, { kind: skill.source.kind, skills: [skill] });
|
|
46
|
+
}
|
|
47
|
+
return [...groups]
|
|
48
|
+
.sort(([labelA, groupA], [labelB, groupB]) => {
|
|
49
|
+
const rank = SOURCE_ORDER.indexOf(groupA.kind) - SOURCE_ORDER.indexOf(groupB.kind);
|
|
50
|
+
return rank !== 0 ? rank : labelA.localeCompare(labelB);
|
|
51
|
+
})
|
|
52
|
+
.map(([label, group]) => ({ label, skills: group.skills }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where the browser is drawn. The panel is a whole tab, so it scrolls and
|
|
57
|
+
* paints its own background. A composer pill's popover is a host surface that
|
|
58
|
+
* already scrolls, pads and paints, so the browser draws only its content there:
|
|
59
|
+
* a second scroll view inside the host's would fight it for the gesture.
|
|
60
|
+
*/
|
|
61
|
+
export type SkillBrowserFrame = "screen" | "popover";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How many lines a detail screen's long texts get in each frame; `undefined` is
|
|
65
|
+
* all of them.
|
|
66
|
+
*
|
|
67
|
+
* The popover's pages share the host's one scroll view, and nothing in the
|
|
68
|
+
* plugin API can move its offset — so a detail opened from far down a long list
|
|
69
|
+
* would keep that offset and open with its back link, arguments and Invoke out
|
|
70
|
+
* of view. Capping the detail keeps it within the popover's height, which leaves
|
|
71
|
+
* the host no room to scroll: the offset falls back to the top, and the list
|
|
72
|
+
* returned to from there starts at the top too. The Skills tab shows it all.
|
|
73
|
+
*/
|
|
74
|
+
function detailLines(frame: SkillBrowserFrame) {
|
|
75
|
+
return frame === "popover"
|
|
76
|
+
? { description: 4, body: 6 }
|
|
77
|
+
: { description: undefined, body: undefined };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function Frame({
|
|
81
|
+
frame,
|
|
82
|
+
theme,
|
|
83
|
+
padding,
|
|
84
|
+
children,
|
|
85
|
+
}: {
|
|
86
|
+
frame: SkillBrowserFrame;
|
|
87
|
+
theme: Theme;
|
|
88
|
+
padding: number;
|
|
89
|
+
children: ReactNode;
|
|
90
|
+
}) {
|
|
91
|
+
const styles = useMemo(
|
|
92
|
+
() => ({
|
|
93
|
+
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
|
|
94
|
+
content: { padding },
|
|
95
|
+
}),
|
|
96
|
+
[theme, padding],
|
|
97
|
+
);
|
|
98
|
+
if (frame === "popover") return <View>{children}</View>;
|
|
99
|
+
return (
|
|
100
|
+
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
|
|
101
|
+
{children}
|
|
102
|
+
</ScrollView>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function detailStyles(theme: Theme, padding: number) {
|
|
107
|
+
return {
|
|
108
|
+
back: { color: theme.colors.accent, marginBottom: padding },
|
|
109
|
+
name: { color: theme.colors.foreground, fontSize: 18, marginBottom: 4 },
|
|
110
|
+
description: { color: theme.colors.foregroundMuted, marginBottom: padding },
|
|
111
|
+
path: { color: theme.colors.foregroundMuted, fontSize: 12, fontFamily: MONO },
|
|
112
|
+
copy: { color: theme.colors.accent, marginTop: 4, marginBottom: padding },
|
|
113
|
+
argsInput: {
|
|
114
|
+
color: theme.colors.foreground,
|
|
115
|
+
borderWidth: 1,
|
|
116
|
+
borderColor: theme.colors.foregroundMuted,
|
|
117
|
+
borderRadius: 8,
|
|
118
|
+
paddingHorizontal: 12,
|
|
119
|
+
paddingVertical: 8,
|
|
120
|
+
marginBottom: 8,
|
|
121
|
+
},
|
|
122
|
+
invoke: {
|
|
123
|
+
backgroundColor: theme.colors.accent,
|
|
124
|
+
borderRadius: 8,
|
|
125
|
+
paddingVertical: 10,
|
|
126
|
+
alignItems: "center" as const,
|
|
127
|
+
marginBottom: padding,
|
|
128
|
+
},
|
|
129
|
+
invokeLabel: { color: theme.colors.accentForeground, fontSize: 15 },
|
|
130
|
+
body: { color: theme.colors.foreground, fontFamily: MONO, fontSize: 12 },
|
|
131
|
+
error: { color: theme.colors.statusDanger, marginBottom: padding },
|
|
132
|
+
notInvocable: { color: theme.colors.foregroundMuted, marginBottom: padding },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type DetailStyles = ReturnType<typeof detailStyles>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Owns the arguments field and the send. The re-entrancy guard lives here rather
|
|
140
|
+
* than in each detail screen: a double tap on a slow connection would otherwise
|
|
141
|
+
* invoke the skill twice on the user's live agent.
|
|
142
|
+
*
|
|
143
|
+
* `onInvoked` is deliberately not `onBack`. Both used to be the same callback,
|
|
144
|
+
* which meant a successful invoke left the user on the Skills tab watching the
|
|
145
|
+
* list — the turn it had just started was one tab away, with nothing saying so.
|
|
146
|
+
*/
|
|
147
|
+
function useInvoke(agentId: string, onInvoked: () => void) {
|
|
148
|
+
const paseo = usePaseo();
|
|
149
|
+
const toast = useToast();
|
|
150
|
+
const [args, setArgs] = useState("");
|
|
151
|
+
const [invokeError, setInvokeError] = useState<string | null>(null);
|
|
152
|
+
const [isInvoking, setIsInvoking] = useState(false);
|
|
153
|
+
|
|
154
|
+
// Whether this detail screen is still the one on show; a send outlives it when
|
|
155
|
+
// the user goes back or the popover closes. See `sendInvocation`.
|
|
156
|
+
const showing = useRef(false);
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
showing.current = true;
|
|
159
|
+
return () => {
|
|
160
|
+
showing.current = false;
|
|
161
|
+
};
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
async function invoke(name: string) {
|
|
165
|
+
if (isInvoking) return;
|
|
166
|
+
setIsInvoking(true);
|
|
167
|
+
setInvokeError(null);
|
|
168
|
+
await sendInvocation({
|
|
169
|
+
send: (text) => paseo.agents.ref(agentId).send(text),
|
|
170
|
+
text: invocationText(name, args),
|
|
171
|
+
isShowing: () => showing.current,
|
|
172
|
+
onSent: onInvoked,
|
|
173
|
+
onFailure(message) {
|
|
174
|
+
setInvokeError(message);
|
|
175
|
+
setIsInvoking(false);
|
|
176
|
+
},
|
|
177
|
+
onUnseenFailure(message) {
|
|
178
|
+
toast.error(`Could not invoke /${name}: ${message}`);
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { args, setArgs, invoke, invokeError, isInvoking };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The arguments field, error line, and Invoke button, shared by both detail screens. */
|
|
187
|
+
function InvokeControls({
|
|
188
|
+
styles,
|
|
189
|
+
theme,
|
|
190
|
+
name,
|
|
191
|
+
controls,
|
|
192
|
+
}: {
|
|
193
|
+
styles: DetailStyles;
|
|
194
|
+
theme: Theme;
|
|
195
|
+
name: string;
|
|
196
|
+
controls: ReturnType<typeof useInvoke>;
|
|
197
|
+
}) {
|
|
198
|
+
return (
|
|
199
|
+
<>
|
|
200
|
+
<TextInput
|
|
201
|
+
style={styles.argsInput}
|
|
202
|
+
value={controls.args}
|
|
203
|
+
onChangeText={controls.setArgs}
|
|
204
|
+
placeholder="Arguments (optional)"
|
|
205
|
+
placeholderTextColor={theme.colors.foregroundMuted}
|
|
206
|
+
autoCorrect={false}
|
|
207
|
+
/>
|
|
208
|
+
{controls.invokeError ? <Text style={styles.error}>{controls.invokeError}</Text> : null}
|
|
209
|
+
<Pressable
|
|
210
|
+
style={styles.invoke}
|
|
211
|
+
disabled={controls.isInvoking}
|
|
212
|
+
onPress={() => void controls.invoke(name)}
|
|
213
|
+
>
|
|
214
|
+
<Text style={styles.invokeLabel}>{controls.isInvoking ? "Sending…" : "Invoke"}</Text>
|
|
215
|
+
</Pressable>
|
|
216
|
+
</>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function SkillDetail({
|
|
221
|
+
theme,
|
|
222
|
+
frame,
|
|
223
|
+
padding,
|
|
224
|
+
agentId,
|
|
225
|
+
skillId,
|
|
226
|
+
userInvocable,
|
|
227
|
+
onBack,
|
|
228
|
+
onInvoked,
|
|
229
|
+
}: {
|
|
230
|
+
theme: Theme;
|
|
231
|
+
frame: SkillBrowserFrame;
|
|
232
|
+
padding: number;
|
|
233
|
+
agentId: string;
|
|
234
|
+
skillId: string;
|
|
235
|
+
userInvocable: boolean;
|
|
236
|
+
onBack: () => void;
|
|
237
|
+
onInvoked: () => void;
|
|
238
|
+
}) {
|
|
239
|
+
const callReadSkill = useRpc(readSkill);
|
|
240
|
+
const toast = useToast();
|
|
241
|
+
const controls = useInvoke(agentId, onInvoked);
|
|
242
|
+
|
|
243
|
+
const query = useQuery({
|
|
244
|
+
queryKey: ["skill", agentId, skillId],
|
|
245
|
+
queryFn: () => callReadSkill({ agentId, skillId }),
|
|
246
|
+
retry: false,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const styles = useMemo(() => detailStyles(theme, padding), [theme, padding]);
|
|
250
|
+
const lines = detailLines(frame);
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<Frame frame={frame} theme={theme} padding={padding}>
|
|
254
|
+
<Pressable onPress={onBack}>
|
|
255
|
+
<Text style={styles.back}>← All skills</Text>
|
|
256
|
+
</Pressable>
|
|
257
|
+
{query.isPending ? (
|
|
258
|
+
<ActivityIndicator color={theme.colors.foregroundMuted} />
|
|
259
|
+
) : query.isError ? (
|
|
260
|
+
<Text style={styles.error}>{(query.error as Error).message}</Text>
|
|
261
|
+
) : (
|
|
262
|
+
<>
|
|
263
|
+
<Text style={styles.name}>{query.data.name}</Text>
|
|
264
|
+
<Text style={styles.description} numberOfLines={lines.description}>
|
|
265
|
+
{query.data.description}
|
|
266
|
+
</Text>
|
|
267
|
+
{/* The popover is for running a skill, not locating it; the tab keeps the path. */}
|
|
268
|
+
{frame === "screen" ? (
|
|
269
|
+
<>
|
|
270
|
+
<Text style={styles.path} selectable>
|
|
271
|
+
{query.data.path}
|
|
272
|
+
</Text>
|
|
273
|
+
<Pressable
|
|
274
|
+
onPress={() => {
|
|
275
|
+
// The path is `selectable` above, so a platform that denies
|
|
276
|
+
// programmatic copying still leaves long-press and OS Copy.
|
|
277
|
+
const path = query.data.path;
|
|
278
|
+
void copyText(path).then(
|
|
279
|
+
() => toast.show("Path copied", { variant: "success" }),
|
|
280
|
+
() => toast.error("Could not copy. Select the path and use Copy."),
|
|
281
|
+
);
|
|
282
|
+
}}
|
|
283
|
+
>
|
|
284
|
+
<Text style={styles.copy}>Copy path</Text>
|
|
285
|
+
</Pressable>
|
|
286
|
+
</>
|
|
287
|
+
) : null}
|
|
288
|
+
{userInvocable ? (
|
|
289
|
+
<InvokeControls
|
|
290
|
+
styles={styles}
|
|
291
|
+
theme={theme}
|
|
292
|
+
name={query.data.name}
|
|
293
|
+
controls={controls}
|
|
294
|
+
/>
|
|
295
|
+
) : (
|
|
296
|
+
<Text style={styles.notInvocable}>
|
|
297
|
+
This skill is model-invoked only. The agent can use it, but it cannot be run as a
|
|
298
|
+
command.
|
|
299
|
+
</Text>
|
|
300
|
+
)}
|
|
301
|
+
<Text style={styles.body} numberOfLines={lines.body}>
|
|
302
|
+
{query.data.body}
|
|
303
|
+
</Text>
|
|
304
|
+
</>
|
|
305
|
+
)}
|
|
306
|
+
</Frame>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Detail for an entry the session reported. There is no `SKILL.md` behind it, so
|
|
312
|
+
* there is no path, no body, and no `skills.read` call — but the description
|
|
313
|
+
* renders in full here rather than clipped to two lines, and the agent can still
|
|
314
|
+
* be asked to run it.
|
|
315
|
+
*/
|
|
316
|
+
function ReportedDetail({
|
|
317
|
+
theme,
|
|
318
|
+
frame,
|
|
319
|
+
padding,
|
|
320
|
+
agentId,
|
|
321
|
+
entry,
|
|
322
|
+
onBack,
|
|
323
|
+
onInvoked,
|
|
324
|
+
}: {
|
|
325
|
+
theme: Theme;
|
|
326
|
+
frame: SkillBrowserFrame;
|
|
327
|
+
padding: number;
|
|
328
|
+
agentId: string;
|
|
329
|
+
entry: ReportedSkill;
|
|
330
|
+
onBack: () => void;
|
|
331
|
+
onInvoked: () => void;
|
|
332
|
+
}) {
|
|
333
|
+
const controls = useInvoke(agentId, onInvoked);
|
|
334
|
+
const styles = useMemo(() => detailStyles(theme, padding), [theme, padding]);
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<Frame frame={frame} theme={theme} padding={padding}>
|
|
338
|
+
<Pressable onPress={onBack}>
|
|
339
|
+
<Text style={styles.back}>← All skills</Text>
|
|
340
|
+
</Pressable>
|
|
341
|
+
<Text style={styles.name}>{entry.name}</Text>
|
|
342
|
+
{entry.argumentHint ? <Text style={styles.path}>{entry.argumentHint}</Text> : null}
|
|
343
|
+
<Text
|
|
344
|
+
style={[styles.description, { marginTop: 8 }]}
|
|
345
|
+
numberOfLines={detailLines(frame).description}
|
|
346
|
+
selectable
|
|
347
|
+
>
|
|
348
|
+
{entry.description}
|
|
349
|
+
</Text>
|
|
350
|
+
<InvokeControls styles={styles} theme={theme} name={entry.name} controls={controls} />
|
|
351
|
+
<Text style={styles.notInvocable}>
|
|
352
|
+
The agent reported this itself. It has no SKILL.md on disk, so there is nothing further to
|
|
353
|
+
show.
|
|
354
|
+
</Text>
|
|
355
|
+
</Frame>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function listStyles(theme: Theme, padding: number) {
|
|
360
|
+
return {
|
|
361
|
+
search: {
|
|
362
|
+
color: theme.colors.foreground,
|
|
363
|
+
backgroundColor: theme.colors.surface0,
|
|
364
|
+
borderWidth: 1,
|
|
365
|
+
borderColor: theme.colors.foregroundMuted,
|
|
366
|
+
borderRadius: 8,
|
|
367
|
+
paddingHorizontal: 12,
|
|
368
|
+
paddingVertical: 8,
|
|
369
|
+
marginBottom: padding,
|
|
370
|
+
},
|
|
371
|
+
groupLabel: {
|
|
372
|
+
color: theme.colors.foregroundMuted,
|
|
373
|
+
fontSize: 12,
|
|
374
|
+
textTransform: "uppercase" as const,
|
|
375
|
+
marginTop: padding,
|
|
376
|
+
marginBottom: 6,
|
|
377
|
+
},
|
|
378
|
+
row: { paddingVertical: 10 },
|
|
379
|
+
name: { color: theme.colors.foreground, fontSize: 15 },
|
|
380
|
+
hint: { color: theme.colors.foregroundMuted, fontSize: 13, fontFamily: MONO },
|
|
381
|
+
description: { color: theme.colors.foregroundMuted, fontSize: 13, marginTop: 2 },
|
|
382
|
+
groupNote: { color: theme.colors.foregroundMuted, fontSize: 12, marginBottom: 6 },
|
|
383
|
+
message: { color: theme.colors.foregroundMuted },
|
|
384
|
+
error: { color: theme.colors.statusDanger },
|
|
385
|
+
groupError: { color: theme.colors.statusDanger, fontSize: 13, marginBottom: 6 },
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
type ListStyles = ReturnType<typeof listStyles>;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The list, the search, both detail screens and the invoke — everything the
|
|
393
|
+
* Skills tab shows, drawn by the tab and by the composer pill's popover alike,
|
|
394
|
+
* so a skill run from either is sent the same way.
|
|
395
|
+
*
|
|
396
|
+
* `onInvoked` is what happens after a successful send, and it is the one thing
|
|
397
|
+
* the two callers do differently: the tab moves to the agent's own tab, where
|
|
398
|
+
* the turn it started is, while the popover closes over a composer that is
|
|
399
|
+
* already that agent's.
|
|
400
|
+
*/
|
|
401
|
+
export function SkillBrowser({
|
|
402
|
+
theme,
|
|
403
|
+
frame,
|
|
404
|
+
padding,
|
|
405
|
+
agentId,
|
|
406
|
+
onInvoked,
|
|
407
|
+
}: {
|
|
408
|
+
theme: Theme;
|
|
409
|
+
frame: SkillBrowserFrame;
|
|
410
|
+
padding: number;
|
|
411
|
+
agentId: string;
|
|
412
|
+
onInvoked: () => void;
|
|
413
|
+
}) {
|
|
414
|
+
// Only a liveness check — the browser reads everything else off the query,
|
|
415
|
+
// whose own hook is what tracks `cwd`.
|
|
416
|
+
const agentCwd = useAgent(agentId, (snapshot) => snapshot.cwd);
|
|
417
|
+
const [search, setSearch] = useState("");
|
|
418
|
+
// Discovered entries are addressed by their stable id; reported ones have no id
|
|
419
|
+
// and are addressed by name, which the server guarantees is unique across both
|
|
420
|
+
// reported buckets.
|
|
421
|
+
const [selected, setSelected] = useState<Selection | null>(null);
|
|
422
|
+
|
|
423
|
+
const query = useSkillsQuery(agentId);
|
|
424
|
+
|
|
425
|
+
// The selection is cleared first, so the browser is back on its list when the
|
|
426
|
+
// user returns to it rather than on the detail of a skill already run.
|
|
427
|
+
const handleInvoked = useCallback(
|
|
428
|
+
function handleInvoked() {
|
|
429
|
+
setSelected(null);
|
|
430
|
+
onInvoked();
|
|
431
|
+
},
|
|
432
|
+
[onInvoked],
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const styles = useMemo(() => listStyles(theme, padding), [theme, padding]);
|
|
436
|
+
|
|
437
|
+
const term = search.trim().toLowerCase();
|
|
438
|
+
|
|
439
|
+
const matches = (skill: { name: string; description: string }) =>
|
|
440
|
+
term.length === 0 ||
|
|
441
|
+
skill.name.toLowerCase().includes(term) ||
|
|
442
|
+
skill.description.toLowerCase().includes(term);
|
|
443
|
+
|
|
444
|
+
const filtered = useMemo(
|
|
445
|
+
() => (query.data?.skills ?? []).filter(matches),
|
|
446
|
+
[query.data, term],
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const filteredReportedSkills = useMemo(
|
|
450
|
+
() => (query.data?.reported.skills ?? []).filter(matches),
|
|
451
|
+
[query.data, term],
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
const filteredReportedCommands = useMemo(
|
|
455
|
+
() => (query.data?.reported.commands ?? []).filter(matches),
|
|
456
|
+
[query.data, term],
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
if (selected?.kind === "discovered") {
|
|
460
|
+
const entry = (query.data?.skills ?? []).find((skill) => skill.id === selected.id);
|
|
461
|
+
return (
|
|
462
|
+
<SkillDetail
|
|
463
|
+
theme={theme}
|
|
464
|
+
frame={frame}
|
|
465
|
+
padding={padding}
|
|
466
|
+
agentId={agentId}
|
|
467
|
+
skillId={selected.id}
|
|
468
|
+
userInvocable={entry?.userInvocable ?? true}
|
|
469
|
+
onBack={() => setSelected(null)}
|
|
470
|
+
onInvoked={handleInvoked}
|
|
471
|
+
/>
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (selected?.kind === "reported") {
|
|
476
|
+
const reportedEntry = [
|
|
477
|
+
...(query.data?.reported.skills ?? []),
|
|
478
|
+
...(query.data?.reported.commands ?? []),
|
|
479
|
+
].find((entry) => entry.name === selected.name);
|
|
480
|
+
// A refetch can drop an entry the session no longer reports. Fall back to the
|
|
481
|
+
// list rather than rendering a detail screen for something that is gone.
|
|
482
|
+
if (!reportedEntry) {
|
|
483
|
+
setSelected(null);
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
return (
|
|
487
|
+
<ReportedDetail
|
|
488
|
+
theme={theme}
|
|
489
|
+
frame={frame}
|
|
490
|
+
padding={padding}
|
|
491
|
+
agentId={agentId}
|
|
492
|
+
entry={reportedEntry}
|
|
493
|
+
onBack={() => setSelected(null)}
|
|
494
|
+
onInvoked={handleInvoked}
|
|
495
|
+
/>
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (agentCwd == null) {
|
|
500
|
+
return (
|
|
501
|
+
<Frame frame={frame} theme={theme} padding={padding}>
|
|
502
|
+
<Text style={styles.message}>This agent is no longer available.</Text>
|
|
503
|
+
</Frame>
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (query.isPending) {
|
|
508
|
+
return (
|
|
509
|
+
<Frame frame={frame} theme={theme} padding={padding}>
|
|
510
|
+
<ActivityIndicator color={theme.colors.foregroundMuted} />
|
|
511
|
+
</Frame>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (query.isError) {
|
|
516
|
+
return (
|
|
517
|
+
<Frame frame={frame} theme={theme} padding={padding}>
|
|
518
|
+
<Text style={styles.error}>{(query.error as Error).message}</Text>
|
|
519
|
+
</Frame>
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const reported = query.data?.reported;
|
|
524
|
+
// Every provider reaches this screen. A provider whose skill directories
|
|
525
|
+
// nobody has documented still reports what its session loaded, so the browser
|
|
526
|
+
// renders that and says where it came from rather than refusing outright.
|
|
527
|
+
const scanned = query.data?.scanned ?? false;
|
|
528
|
+
|
|
529
|
+
return (
|
|
530
|
+
<Frame frame={frame} theme={theme} padding={padding}>
|
|
531
|
+
<TextInput
|
|
532
|
+
style={styles.search}
|
|
533
|
+
value={search}
|
|
534
|
+
onChangeText={setSearch}
|
|
535
|
+
placeholder="Search skills"
|
|
536
|
+
placeholderTextColor={theme.colors.foregroundMuted}
|
|
537
|
+
autoCorrect={false}
|
|
538
|
+
autoCapitalize="none"
|
|
539
|
+
/>
|
|
540
|
+
{scanned ? null : (
|
|
541
|
+
<Text style={styles.groupNote}>
|
|
542
|
+
Skill files are only scanned for Claude and Codex. Everything below is what the{" "}
|
|
543
|
+
{query.data?.provider ?? "provider"} session reported.
|
|
544
|
+
</Text>
|
|
545
|
+
)}
|
|
546
|
+
{filtered.length === 0 &&
|
|
547
|
+
filteredReportedSkills.length === 0 &&
|
|
548
|
+
filteredReportedCommands.length === 0 &&
|
|
549
|
+
reported?.error == null ? (
|
|
550
|
+
<Text style={styles.message}>
|
|
551
|
+
{term.length > 0 ? "No skills match that search." : "No skills found."}
|
|
552
|
+
</Text>
|
|
553
|
+
) : null}
|
|
554
|
+
{groupBySource(filtered).map((group) => (
|
|
555
|
+
<View key={group.label}>
|
|
556
|
+
<Text style={styles.groupLabel}>{group.label}</Text>
|
|
557
|
+
{group.skills.map((skill) => (
|
|
558
|
+
<Pressable
|
|
559
|
+
key={skill.id}
|
|
560
|
+
style={styles.row}
|
|
561
|
+
onPress={() => setSelected({ kind: "discovered", id: skill.id })}
|
|
562
|
+
>
|
|
563
|
+
<Text style={styles.name}>{skill.name}</Text>
|
|
564
|
+
<Text style={styles.description} numberOfLines={2}>
|
|
565
|
+
{skill.description}
|
|
566
|
+
</Text>
|
|
567
|
+
</Pressable>
|
|
568
|
+
))}
|
|
569
|
+
</View>
|
|
570
|
+
))}
|
|
571
|
+
{reported?.error ? (
|
|
572
|
+
<View>
|
|
573
|
+
<Text style={styles.groupLabel}>Reported by the agent</Text>
|
|
574
|
+
<Text style={styles.groupError}>{reported.error}</Text>
|
|
575
|
+
</View>
|
|
576
|
+
) : null}
|
|
577
|
+
{filteredReportedSkills.length > 0 ? (
|
|
578
|
+
<ReportedGroup
|
|
579
|
+
styles={styles}
|
|
580
|
+
label="Built-in skills"
|
|
581
|
+
note="Loaded by the running session. These have no SKILL.md to read."
|
|
582
|
+
entries={filteredReportedSkills}
|
|
583
|
+
onSelect={(name) => setSelected({ kind: "reported", name })}
|
|
584
|
+
/>
|
|
585
|
+
) : null}
|
|
586
|
+
{filteredReportedCommands.length > 0 ? (
|
|
587
|
+
<ReportedGroup
|
|
588
|
+
styles={styles}
|
|
589
|
+
label="Built-in commands"
|
|
590
|
+
note="Session controls the agent reported, not skills."
|
|
591
|
+
entries={filteredReportedCommands}
|
|
592
|
+
onSelect={(name) => setSelected({ kind: "reported", name })}
|
|
593
|
+
/>
|
|
594
|
+
) : null}
|
|
595
|
+
</Frame>
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Rows open a reduced detail screen: no path and no body, but the full
|
|
601
|
+
* description and an Invoke button.
|
|
602
|
+
*/
|
|
603
|
+
function ReportedGroup({
|
|
604
|
+
styles,
|
|
605
|
+
label,
|
|
606
|
+
note,
|
|
607
|
+
entries,
|
|
608
|
+
onSelect,
|
|
609
|
+
}: {
|
|
610
|
+
styles: ListStyles;
|
|
611
|
+
label: string;
|
|
612
|
+
note: string;
|
|
613
|
+
entries: ReportedSkill[];
|
|
614
|
+
onSelect: (name: string) => void;
|
|
615
|
+
}) {
|
|
616
|
+
return (
|
|
617
|
+
<View>
|
|
618
|
+
<Text style={styles.groupLabel}>{label}</Text>
|
|
619
|
+
<Text style={styles.groupNote}>{note}</Text>
|
|
620
|
+
{entries.map((entry) => (
|
|
621
|
+
<Pressable key={entry.name} style={styles.row} onPress={() => onSelect(entry.name)}>
|
|
622
|
+
<Text style={styles.name}>
|
|
623
|
+
{entry.name}
|
|
624
|
+
{entry.argumentHint ? <Text style={styles.hint}> {entry.argumentHint}</Text> : null}
|
|
625
|
+
</Text>
|
|
626
|
+
<Text style={styles.description} numberOfLines={2}>
|
|
627
|
+
{entry.description}
|
|
628
|
+
</Text>
|
|
629
|
+
</Pressable>
|
|
630
|
+
))}
|
|
631
|
+
</View>
|
|
632
|
+
);
|
|
633
|
+
}
|