@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.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/client/commands.ts +122 -0
- package/client/console.tsx +484 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/docs/screenshots/slash-autocomplete.jpg +0 -0
- package/docs/screenshots/slash-console.jpg +0 -0
- package/package.json +27 -0
- package/paseo-plugin.json +4 -0
- package/server/orchestrate.ts +81 -0
- package/server/resources.ts +132 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/resources.ts +289 -0
- package/shared/vendor/paseo-plugin-helper/README.md +11 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
- 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
|
+
}
|