@xpufx/paseo-slash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/client/commands.ts +122 -0
  4. package/client/console.tsx +484 -0
  5. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  6. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  7. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  8. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  9. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  10. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  11. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  12. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  13. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  14. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  15. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  16. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  17. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  18. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  19. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  20. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  21. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  22. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  23. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  24. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  25. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  26. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  27. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  28. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  29. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  30. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  31. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  32. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  33. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  34. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  35. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  36. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  37. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  38. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  39. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  40. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  41. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  42. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  43. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  44. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  45. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  46. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  47. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  48. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  49. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  50. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  51. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  52. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  53. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  54. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  55. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  56. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  57. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  58. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  59. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  60. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  61. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  62. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  63. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  64. package/docs/screenshots/slash-autocomplete.jpg +0 -0
  65. package/docs/screenshots/slash-console.jpg +0 -0
  66. package/package.json +27 -0
  67. package/paseo-plugin.json +4 -0
  68. package/server/orchestrate.ts +81 -0
  69. package/server/resources.ts +132 -0
  70. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  71. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  72. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  73. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  74. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  75. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  76. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  77. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  78. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  79. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  80. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  81. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  82. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  83. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  84. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  85. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  86. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  87. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  88. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  89. package/shared/resources.ts +289 -0
  90. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  91. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  92. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  93. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  94. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  95. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  96. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  97. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  98. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  99. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  100. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  101. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  102. package/shared/version.ts +2 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xpufx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # s/ash
2
+
3
+ Slash-command console for [Paseo](https://github.com/getpaseo/paseo) (v0.8+).
4
+
5
+ <p align="center">
6
+ <img src="docs/screenshots/slash-console.jpg" alt="S/ash console — command repository, prefix, and shipped catalog" width="380">
7
+ </p>
8
+
9
+ <p align="center">
10
+ <img src="docs/screenshots/slash-autocomplete.jpg" alt="S/ash command autocomplete in the composer" width="480">
11
+ </p>
12
+
13
+ Adds a **S/ash console** sidebar surface for managing the slash commands offered
14
+ in the composer, and keeps those commands registered as the repository changes.
15
+ A command carries one of three actions:
16
+
17
+ - **send** — interpolate `{args}` into a prompt template and send it to the agent.
18
+ - **open** — open a named plugin surface.
19
+ - **rpc** — run an allowlisted daemon operation (shipped: `slash.ping`, `slash.echo`).
20
+
21
+ Built on [paseo-plugin-helper](https://github.com/xpufx/paseo/tree/main/packages/paseo-plugin-helper), the shared Paseo plugin runtime.
22
+
23
+ > [!NOTE]
24
+ > **Prerequisites & Platform Support**:
25
+ > - Developed and tested primarily on **Linux**.
26
+
27
+ ## What it does
28
+
29
+ - **Command repository.** The console lists enabled commands with their action
30
+ summary and supports add / edit / remove through helper form primitives.
31
+ - **Shipped catalog.** The seed commands (`review`, `console`, `ping`) are
32
+ listed separately so a missing one can be added back with one tap.
33
+ - **Prefix.** An optional shared prefix (default `slash-`, clearable to render
34
+ bare command names) is applied to every command name.
35
+ - **Bundle import/export.** Enabled commands round-trip through a versioned
36
+ `slash-commands` document, so a command set can be shared between machines.
37
+
38
+ ## RPC contracts (`shared/resources.ts`)
39
+
40
+ - `slash.settings`: persisted `prefix` and command repository.
41
+ - `slash.commands.list`: enabled commands with the prefix applied.
42
+ - `slash.catalog.list`: the shipped seed catalog, without touching settings.
43
+ - `slash.commands.run`: run one command by name; `send`/`open` resolve
44
+ client-side, `rpc` executes daemon-side.
45
+ - `slash.bundle.export` / `slash.bundle.import`: share command sets as a
46
+ versioned bundle.
47
+
48
+ ## Install
49
+
50
+ ```sh
51
+ paseo plugin add xpufx/paseo --path plugins/slash
52
+ ```
53
+
54
+ ## Development
55
+
56
+ ```sh
57
+ npm run typecheck --workspace=plugins/slash
58
+ npm test --workspace=plugins/slash
59
+ ```
@@ -0,0 +1,122 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import {
3
+ interpolateTemplate,
4
+ listCommandsRpc,
5
+ runCommandRpc,
6
+ slashSettingsContract,
7
+ withPrefix,
8
+ type SlashCommand,
9
+ } from "../shared/resources";
10
+
11
+ async function fetchCommands(rpc: PluginClientContext["rpc"]): Promise<SlashCommand[]> {
12
+ const res = await (rpc as unknown as (contract: unknown, input: unknown) => Promise<{ commands: SlashCommand[] }>)(
13
+ listCommandsRpc as unknown,
14
+ {},
15
+ );
16
+ return res.commands;
17
+ }
18
+
19
+ function report(where: string, err: unknown): void {
20
+ try {
21
+ // Visible in Paseo GUI logs; never throws back into registration.
22
+ console.error(`[slash.commands] ${where}: ${err instanceof Error ? err.message : String(err)}`);
23
+ } catch {}
24
+ }
25
+
26
+ export function registerSlashCommands(client: PluginClientContext): () => void {
27
+ const removers: Array<() => void> = [];
28
+ let disposed = false;
29
+
30
+ async function sync() {
31
+ if (disposed) return;
32
+ if (typeof client.rpc !== "function") {
33
+ report("sync", `client.rpc unavailable (${typeof client.rpc}); retrying on interval`);
34
+ return;
35
+ }
36
+ let commands: SlashCommand[] = [];
37
+ try {
38
+ commands = await fetchCommands(client.rpc);
39
+ } catch (err) {
40
+ report("list-commands", err);
41
+ return;
42
+ }
43
+ for (const remove of removers.splice(0)) {
44
+ try {
45
+ remove();
46
+ } catch {}
47
+ }
48
+ if (disposed) return;
49
+ let prefix = "";
50
+ try {
51
+ const settings = await (client.rpc as unknown as (c: unknown, i: unknown) => Promise<{ prefix?: string }>)(
52
+ slashSettingsContract.get as unknown,
53
+ {},
54
+ );
55
+ prefix = settings.prefix ?? "";
56
+ } catch (err) {
57
+ report("get-prefix", err);
58
+ }
59
+ for (const command of commands) {
60
+ const name = withPrefix(prefix, command.name);
61
+ if (command.action.verb === "send") {
62
+ removers.push(
63
+ client.addSlashCommand({
64
+ name,
65
+ description: command.description || command.title,
66
+ argumentHint: "{args}",
67
+ context: "agent",
68
+ async onSubmit(ctx) {
69
+ const prompt = interpolateTemplate(command.action.verb === "send" ? command.action.template : "", ctx.args);
70
+ await ctx.paseo.agents.ref(ctx.agent.id).send(prompt);
71
+ },
72
+ }),
73
+ );
74
+ } else if (command.action.verb === "open") {
75
+ const target = command.action.verb === "open" ? command.action.target : "";
76
+ removers.push(
77
+ client.addSlashCommand({
78
+ name,
79
+ description: command.description || command.title,
80
+ argumentHint: "",
81
+ context: "agent",
82
+ async onSubmit(ctx) {
83
+ ctx.openSurface(target);
84
+ },
85
+ }),
86
+ );
87
+ } else {
88
+ removers.push(
89
+ client.addSlashCommand({
90
+ name,
91
+ description: command.description || command.title,
92
+ argumentHint: "[args]",
93
+ context: "agent",
94
+ async onSubmit(ctx) {
95
+ try {
96
+ const out = await (client.rpc as unknown as (c: unknown, i: unknown) => Promise<{ result?: unknown }>)(
97
+ runCommandRpc as unknown,
98
+ { name: command.name, args: ctx.args, agentId: ctx.agent.id },
99
+ );
100
+ await ctx.paseo.agents.ref(ctx.agent.id).send(`/${command.name}: ${JSON.stringify(out.result ?? null)}`);
101
+ } catch (e) {
102
+ await ctx.paseo.agents.ref(ctx.agent.id).send(`/${command.name} failed: ${e instanceof Error ? e.message : String(e)}`);
103
+ }
104
+ },
105
+ }),
106
+ );
107
+ }
108
+ }
109
+ }
110
+
111
+ void sync();
112
+ const interval = setInterval(sync, 30_000);
113
+ return () => {
114
+ disposed = true;
115
+ clearInterval(interval);
116
+ for (const remove of removers.splice(0)) {
117
+ try {
118
+ remove();
119
+ } catch {}
120
+ }
121
+ };
122
+ }
@@ -0,0 +1,484 @@
1
+ import { useState } from "react";
2
+ import { View } from "react-native";
3
+ import {
4
+ ActionBar,
5
+ Badge,
6
+ Button,
7
+ Card,
8
+ Collapsible,
9
+ EmptyState,
10
+ FormRow,
11
+ ModalBody,
12
+ Row,
13
+ SectionHeader,
14
+ Select,
15
+ Stack,
16
+ Tabs,
17
+ TextInput,
18
+ Toggle,
19
+ spacing,
20
+ usePluginSettings,
21
+ useRpcMutation,
22
+ useRpcQuery,
23
+ type TabItem,
24
+ } from "paseo-plugin-helper/client";
25
+ import {
26
+ COMMAND_NAME_HINT,
27
+ SUGGESTED_PREFIX,
28
+ actionSummary,
29
+ catalogRpc,
30
+ draftFromCommand,
31
+ emptyCommandDraft,
32
+ exportBundleRpc,
33
+ importBundleRpc,
34
+ missingCatalogCommands,
35
+ operationsListRpc,
36
+ removeCommandByName,
37
+ slashSettingsContract,
38
+ upsertCommand,
39
+ validateCommandDraft,
40
+ type CommandDraftErrors,
41
+ type CommandDraftWarnings,
42
+ type OperationCatalog,
43
+ type SlashCommand,
44
+ type SlashCommandDraft,
45
+ type SlashVerb,
46
+ } from "../shared/resources";
47
+
48
+ const ACTION_TABS: TabItem[] = [
49
+ { id: "send", label: "Send", icon: "Send" },
50
+ { id: "open", label: "Open", icon: "ExternalLink" },
51
+ { id: "rpc", label: "RPC", icon: "Zap" },
52
+ ];
53
+
54
+ const VERB_ICON: Record<SlashVerb, string> = {
55
+ send: "Send",
56
+ open: "ExternalLink",
57
+ rpc: "Zap",
58
+ };
59
+
60
+ const VERB_VARIANT = {
61
+ send: "accent",
62
+ open: "info",
63
+ rpc: "warning",
64
+ } as const;
65
+
66
+ // Keep the console a readable centered column instead of stretching edge-to-edge
67
+ // on large viewports. The cap lives in the helper (`ModalBody maxContentWidth`);
68
+ // this is the only place the console picks the value.
69
+ const CONSOLE_CONTENT_MAX_WIDTH = 600;
70
+
71
+ // Header-only rows: the helper reserves an 8px bottom margin for content that
72
+ // follows. Command rows have none, so reclaim it and use the token scale for
73
+ // the row's own inset instead of Card's full surface padding.
74
+ const ROW_HEADER_STYLE = {
75
+ marginBottom: 0,
76
+ paddingHorizontal: spacing.md,
77
+ paddingVertical: spacing.sm,
78
+ };
79
+
80
+ interface CommandRowProps {
81
+ prefix: string;
82
+ command: SlashCommand;
83
+ /** True when the command is one of the plugin's bundled (shipped) seeds. */
84
+ shipped?: boolean;
85
+ onToggle: (enabled: boolean) => void;
86
+ onEdit: () => void;
87
+ onRemove: () => void;
88
+ }
89
+
90
+ function CommandRow({ prefix, command, shipped, onToggle, onEdit, onRemove }: CommandRowProps) {
91
+ const label = `/${prefix}${command.name}`;
92
+ return (
93
+ <Card variant="elevated" noPadding>
94
+ <Card.Header
95
+ icon={VERB_ICON[command.action.verb]}
96
+ title={label}
97
+ subtitle={command.description || command.title}
98
+ badge={
99
+ <Row gap="xs" align="center">
100
+ <Badge
101
+ size="sm"
102
+ label={actionSummary(command.action)}
103
+ variant={VERB_VARIANT[command.action.verb]}
104
+ />
105
+ {shipped ? <Badge size="sm" label="Shipped" variant="neutral" /> : null}
106
+ </Row>
107
+ }
108
+ action={
109
+ <Row gap="xs" align="center">
110
+ <Toggle value={command.enabled} onValueChange={onToggle} />
111
+ <Button
112
+ size="sm"
113
+ variant="ghost"
114
+ icon="Pencil"
115
+ accessibilityLabel={`Edit ${label}`}
116
+ onPress={onEdit}
117
+ />
118
+ <Button
119
+ size="sm"
120
+ variant="danger"
121
+ icon="Trash2"
122
+ accessibilityLabel={`Remove ${label}`}
123
+ onPress={onRemove}
124
+ />
125
+ </Row>
126
+ }
127
+ style={ROW_HEADER_STYLE}
128
+ />
129
+ </Card>
130
+ );
131
+ }
132
+
133
+ interface CommandFormProps {
134
+ draft: SlashCommandDraft;
135
+ errors: CommandDraftErrors;
136
+ warnings: CommandDraftWarnings;
137
+ catalog: OperationCatalog;
138
+ onChange: (patch: Partial<SlashCommandDraft>) => void;
139
+ }
140
+
141
+ function CommandForm({ draft, errors, warnings, catalog, onChange }: CommandFormProps) {
142
+ const rpcOptions = catalog.rpc ?? [];
143
+ const openOptions = catalog.open ?? [];
144
+ const rpcChoices = rpcOptions.map((operation) => ({ label: operation, value: operation }));
145
+ const openChoices = openOptions.map((target) => ({ label: target, value: target }));
146
+
147
+ return (
148
+ <Stack gap="sm">
149
+ <FormRow label="Name" description={COMMAND_NAME_HINT}>
150
+ <TextInput
151
+ value={draft.name}
152
+ errorText={errors.name}
153
+ placeholder="my-command"
154
+ onChangeText={(name) => onChange({ name })}
155
+ />
156
+ </FormRow>
157
+ <FormRow label="Title">
158
+ <TextInput
159
+ value={draft.title}
160
+ errorText={errors.title}
161
+ placeholder="My command"
162
+ onChangeText={(title) => onChange({ title })}
163
+ />
164
+ </FormRow>
165
+ <FormRow label="Description">
166
+ <TextInput
167
+ value={draft.description}
168
+ errorText={errors.description}
169
+ placeholder="Optional one-line summary"
170
+ multiline
171
+ numberOfLines={2}
172
+ onChangeText={(description) => onChange({ description })}
173
+ />
174
+ </FormRow>
175
+ <FormRow label="Action">
176
+ <Tabs
177
+ tabs={ACTION_TABS}
178
+ activeTab={draft.verb}
179
+ onTabChange={(verb) => onChange({ verb: verb as SlashVerb })}
180
+ />
181
+ </FormRow>
182
+ {draft.verb === "send" ? (
183
+ <FormRow label="Prompt template">
184
+ <TextInput
185
+ value={draft.template}
186
+ errorText={errors.action}
187
+ placeholder="Review the current changes: {args}"
188
+ multiline
189
+ numberOfLines={3}
190
+ onChangeText={(template) => onChange({ template })}
191
+ />
192
+ </FormRow>
193
+ ) : draft.verb === "open" ? (
194
+ <FormRow label="Target surface">
195
+ <Stack gap="xs">
196
+ {openOptions.length > 0 ? (
197
+ <Select
198
+ value={draft.target}
199
+ options={openChoices}
200
+ placeholder="Choose a shipped surface…"
201
+ onValueChange={(target) => onChange({ target })}
202
+ />
203
+ ) : null}
204
+ <TextInput
205
+ value={draft.target}
206
+ errorText={errors.action}
207
+ helperText={warnings.action}
208
+ placeholder="slash-console"
209
+ onChangeText={(target) => onChange({ target })}
210
+ />
211
+ </Stack>
212
+ </FormRow>
213
+ ) : (
214
+ <FormRow label="RPC operation">
215
+ <Stack gap="xs">
216
+ {rpcOptions.length > 0 ? (
217
+ <Select
218
+ value={draft.operation}
219
+ options={rpcChoices}
220
+ placeholder="Choose a catalog operation…"
221
+ onValueChange={(operation) => onChange({ operation })}
222
+ />
223
+ ) : null}
224
+ <TextInput
225
+ value={draft.operation}
226
+ errorText={errors.action}
227
+ helperText={warnings.action}
228
+ placeholder="slash.ping"
229
+ mono
230
+ onChangeText={(operation) => onChange({ operation })}
231
+ />
232
+ </Stack>
233
+ </FormRow>
234
+ )}
235
+ <FormRow label="Enabled">
236
+ <Toggle value={draft.enabled} onValueChange={(enabled) => onChange({ enabled })} />
237
+ </FormRow>
238
+ </Stack>
239
+ );
240
+ }
241
+
242
+ export function SlashConsole() {
243
+ const { settings, updateSettings, resetSettings } = usePluginSettings(slashSettingsContract);
244
+ const catalog = useRpcQuery(catalogRpc, {});
245
+ const operations = useRpcQuery(operationsListRpc, {});
246
+ const exportBundle = useRpcMutation(exportBundleRpc);
247
+ const importBundle = useRpcMutation(importBundleRpc);
248
+ const [form, setForm] = useState<{ originalName: string | null; draft: SlashCommandDraft } | null>(null);
249
+ const [bundleJson, setBundleJson] = useState("");
250
+ const [bundleError, setBundleError] = useState("");
251
+ const prefix = settings.prefix ?? "";
252
+ const commands = settings.commands ?? [];
253
+ const operationCatalog: OperationCatalog = operations.data ?? {};
254
+
255
+ const takenNames = commands
256
+ .filter((command) => command.name !== form?.originalName)
257
+ .map((command) => command.name);
258
+ const validation = form
259
+ ? validateCommandDraft(form.draft, takenNames, operationCatalog)
260
+ : { errors: {} as CommandDraftErrors, warnings: {} as CommandDraftWarnings };
261
+ const catalogCommands = catalog.data?.commands ?? [];
262
+ const catalogNames = new Set(catalogCommands.map((command) => command.name));
263
+ const presentNames = new Set(commands.map((command) => command.name));
264
+ const missingFromCatalog = missingCatalogCommands(commands, catalogCommands);
265
+
266
+ function updateDraft(patch: Partial<SlashCommandDraft>) {
267
+ setForm((prev) => (prev ? { ...prev, draft: { ...prev.draft, ...patch } } : prev));
268
+ }
269
+
270
+ function saveForm() {
271
+ if (!form || !validation.command) return;
272
+ updateSettings({ commands: upsertCommand(commands, form.originalName, validation.command) });
273
+ setForm(null);
274
+ }
275
+
276
+ async function handleExport() {
277
+ setBundleError("");
278
+ try {
279
+ const out = await exportBundle.mutateAsync({});
280
+ setBundleJson(JSON.stringify(out.bundle));
281
+ } catch (e) {
282
+ setBundleError(e instanceof Error ? e.message : String(e));
283
+ }
284
+ }
285
+
286
+ async function handleImport() {
287
+ setBundleError("");
288
+ try {
289
+ const parsed = JSON.parse(bundleJson) as unknown;
290
+ const bundle = (parsed as { bundle?: unknown }).bundle ?? parsed;
291
+ await importBundle.mutateAsync({ bundle: bundle as never, overwrite: false });
292
+ } catch (e) {
293
+ setBundleError(e instanceof Error ? e.message : String(e));
294
+ }
295
+ }
296
+
297
+ return (
298
+ <View style={{ flex: 1, minHeight: 0, width: "100%" }}>
299
+ <ModalBody scrollMode="always" maxContentWidth={CONSOLE_CONTENT_MAX_WIDTH}>
300
+ <Card variant="tinted">
301
+ <FormRow label="Command prefix">
302
+ <TextInput
303
+ value={prefix}
304
+ placeholder={`${SUGGESTED_PREFIX} (suggestion — leave empty for none)`}
305
+ onChangeText={(val) => updateSettings({ prefix: val })}
306
+ />
307
+ </FormRow>
308
+ {form ? null : (
309
+ <ActionBar align="flex-start" direction="row" style={{ marginTop: spacing.sm }}>
310
+ <Button
311
+ label="Add command"
312
+ size="sm"
313
+ variant="primary"
314
+ icon="Plus"
315
+ onPress={() => setForm({ originalName: null, draft: emptyCommandDraft() })}
316
+ />
317
+ </ActionBar>
318
+ )}
319
+ </Card>
320
+
321
+ {form ? (
322
+ <Card variant="elevated">
323
+ <Card.Header
324
+ title={form.originalName ? `Edit /${prefix}${form.originalName}` : "New command"}
325
+ badge={
326
+ <Badge size="sm" label={form.draft.verb} variant={VERB_VARIANT[form.draft.verb]} />
327
+ }
328
+ />
329
+ <CommandForm
330
+ draft={form.draft}
331
+ errors={validation.errors}
332
+ warnings={validation.warnings}
333
+ catalog={operationCatalog}
334
+ onChange={updateDraft}
335
+ />
336
+ <ActionBar align="flex-end" style={{ marginTop: spacing.sm }}>
337
+ <Button label="Cancel" size="sm" variant="ghost" onPress={() => setForm(null)} />
338
+ <Button
339
+ label={form.originalName ? "Save changes" : "Add command"}
340
+ size="sm"
341
+ variant="primary"
342
+ icon="Check"
343
+ disabled={!validation.command}
344
+ onPress={saveForm}
345
+ />
346
+ </ActionBar>
347
+ </Card>
348
+ ) : null}
349
+
350
+ <SectionHeader title="Commands" count={commands.length} />
351
+ {commands.length === 0 ? (
352
+ <EmptyState
353
+ icon="Terminal"
354
+ title="No slash commands"
355
+ description="Add one above or pick a shipped command from the catalog."
356
+ />
357
+ ) : (
358
+ <Stack gap="xs">
359
+ {commands.map((command) => (
360
+ <CommandRow
361
+ key={command.name}
362
+ prefix={prefix}
363
+ command={command}
364
+ shipped={catalogNames.has(command.name)}
365
+ onToggle={(enabled) =>
366
+ updateSettings({
367
+ commands: commands.map((c) => (c.name === command.name ? { ...c, enabled } : c)),
368
+ })
369
+ }
370
+ onEdit={() => setForm({ originalName: command.name, draft: draftFromCommand(command) })}
371
+ onRemove={() => updateSettings({ commands: removeCommandByName(commands, command.name) })}
372
+ />
373
+ ))}
374
+ </Stack>
375
+ )}
376
+
377
+ <Collapsible
378
+ title="Shipped catalog"
379
+ subtitle={`${catalogCommands.length} commands bundled with the plugin`}
380
+ icon="Package"
381
+ badge={
382
+ <Badge
383
+ size="sm"
384
+ label={
385
+ missingFromCatalog.length > 0 ? `${missingFromCatalog.length} to add` : "All added"
386
+ }
387
+ variant={missingFromCatalog.length > 0 ? "warning" : "neutral"}
388
+ />
389
+ }
390
+ >
391
+ {catalog.isLoading ? (
392
+ <EmptyState icon="Package" title="Loading the shipped catalog…" />
393
+ ) : catalogCommands.length === 0 ? (
394
+ <EmptyState
395
+ icon="Package"
396
+ title="Shipped catalog unavailable"
397
+ description="The bundled command list could not be loaded."
398
+ />
399
+ ) : (
400
+ <Stack gap="xs">
401
+ {catalogCommands.map((command) => {
402
+ const added = presentNames.has(command.name);
403
+ return (
404
+ <Card key={command.name} variant="tinted" noPadding>
405
+ <Card.Header
406
+ title={`/${command.name}`}
407
+ subtitle={command.description || command.title}
408
+ badge={
409
+ <Badge
410
+ size="sm"
411
+ label={actionSummary(command.action)}
412
+ variant={VERB_VARIANT[command.action.verb]}
413
+ />
414
+ }
415
+ action={
416
+ added ? (
417
+ <Badge size="sm" label="Added" variant="neutral" />
418
+ ) : (
419
+ <Button
420
+ label="Add"
421
+ size="sm"
422
+ variant="secondary"
423
+ icon="Plus"
424
+ onPress={() => updateSettings({ commands: [...commands, command] })}
425
+ />
426
+ )
427
+ }
428
+ style={ROW_HEADER_STYLE}
429
+ />
430
+ </Card>
431
+ );
432
+ })}
433
+ </Stack>
434
+ )}
435
+ </Collapsible>
436
+
437
+ <Collapsible
438
+ title="Advanced"
439
+ subtitle="Bundle import/export and reset"
440
+ icon="Wrench"
441
+ >
442
+ <FormRow label="Bundle JSON" description="Paste an exported bundle to import (validated server-side)">
443
+ <TextInput
444
+ value={bundleJson}
445
+ placeholder='{"bundle":"slash-commands","version":1,"commands":[…]}'
446
+ multiline
447
+ numberOfLines={3}
448
+ errorText={bundleError || undefined}
449
+ onChangeText={(val) => {
450
+ setBundleJson(val);
451
+ setBundleError("");
452
+ }}
453
+ />
454
+ </FormRow>
455
+ <ActionBar align="flex-end" style={{ marginTop: spacing.sm }}>
456
+ <Button
457
+ label="Export"
458
+ size="sm"
459
+ variant="secondary"
460
+ icon="Download"
461
+ loading={exportBundle.isPending}
462
+ onPress={handleExport}
463
+ />
464
+ <Button
465
+ label="Import"
466
+ size="sm"
467
+ variant="secondary"
468
+ icon="Upload"
469
+ loading={importBundle.isPending}
470
+ onPress={handleImport}
471
+ />
472
+ <Button
473
+ label="Reset to seeds"
474
+ size="sm"
475
+ variant="danger"
476
+ icon="RotateCcw"
477
+ onPress={() => void resetSettings()}
478
+ />
479
+ </ActionBar>
480
+ </Collapsible>
481
+ </ModalBody>
482
+ </View>
483
+ );
484
+ }