@gpambrozio/paseo-skills 0.2.0 → 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.
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ /** What invoking a skill sends: `/name`, followed by the arguments when there are any. */
2
+ export function invocationText(name: string, args: string): string {
3
+ const trimmed = args.trim();
4
+ return trimmed ? `/${name} ${trimmed}` : `/${name}`;
5
+ }
6
+
7
+ /**
8
+ * Sends one invocation and reports back only to the screen that started it, and
9
+ * only while that screen is still showing.
10
+ *
11
+ * A send can outlast its screen: the user goes back to the list and picks
12
+ * another skill, or dismisses the popover and opens it again, before the send
13
+ * answers. Its success must not then clear the newer selection or close the
14
+ * newer popover — Paseo's `close()` is not scoped to one opening — so `onSent`
15
+ * runs only while `isShowing()` still holds. The send itself is never
16
+ * abandoned, and a failure nobody is looking at goes to `onUnseenFailure`
17
+ * rather than nowhere.
18
+ */
19
+ export async function sendInvocation(options: {
20
+ send: (text: string) => Promise<unknown>;
21
+ text: string;
22
+ isShowing: () => boolean;
23
+ onSent: () => void;
24
+ onFailure: (message: string) => void;
25
+ onUnseenFailure: (message: string) => void;
26
+ }): Promise<void> {
27
+ try {
28
+ await options.send(options.text);
29
+ } catch (error) {
30
+ const message = error instanceof Error ? error.message : String(error);
31
+ if (options.isShowing()) options.onFailure(message);
32
+ else options.onUnseenFailure(message);
33
+ return;
34
+ }
35
+ if (options.isShowing()) options.onSent();
36
+ }