@neosh/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/main.ts +190 -0
- package/package.json +21 -0
- package/plugin.toml +4 -0
package/main.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slash menu.
|
|
3
|
+
*
|
|
4
|
+
* Two vocabularies meet in one composer and nobody should have to know which is which. `/compact`
|
|
5
|
+
* belongs to the driver — `claude` implements it, neosh has never heard of it — and `/model`
|
|
6
|
+
* belongs to neosh. They are typed the same way because from where you are sitting they are the
|
|
7
|
+
* same thing: a short name for something you want to happen.
|
|
8
|
+
*
|
|
9
|
+
* So this asks both. neosh's commands come from the registry, which is why one registered by a
|
|
10
|
+
* plugin loaded five minutes ago is in the list. The driver's come from the driver, at its
|
|
11
|
+
* handshake, which is the only place they could come from — which commands a `claude` install
|
|
12
|
+
* accepts depends on its project directory, its plugins and its MCP servers, and a list written
|
|
13
|
+
* down here would be wrong on the first machine that had one of its own.
|
|
14
|
+
*
|
|
15
|
+
* # It is a completion, not a dialogue
|
|
16
|
+
*
|
|
17
|
+
* The first version of this was an ordinary picker: a modal list in the middle of the screen, its
|
|
18
|
+
* own filter box, fuzzy-matched over every command's *description*. Three things were wrong with
|
|
19
|
+
* that, and they compounded.
|
|
20
|
+
*
|
|
21
|
+
* The composer kept the `/` that opened it and nothing else, so what you typed went somewhere you
|
|
22
|
+
* could not see. The filter matched descriptions, so `compact` — six letters that occur in that
|
|
23
|
+
* order in a great many English sentences — matched a dozen unrelated commands and put one of them
|
|
24
|
+
* under the cursor. And `↵` *ran* whatever was under the cursor. Typing `/compact` and pressing
|
|
25
|
+
* enter therefore ran something else entirely, silently, while `/compact` itself was not in the
|
|
26
|
+
* list at all: a driver only reports its commands after the conversation has run a turn, and a
|
|
27
|
+
* fresh conversation has not.
|
|
28
|
+
*
|
|
29
|
+
* What replaces it holds to one rule: **the composer is the field, and this is a suggestion about
|
|
30
|
+
* what is in it.** What you type goes into the composer and the menu follows. Matching is on the
|
|
31
|
+
* *name*, anchored at the start, never on descriptions. Backspacing past the `/` dismisses it. And
|
|
32
|
+
* when nothing matches, `↵` sends what you typed, verbatim — because a name this list has never
|
|
33
|
+
* heard of is not a mistake, it is a command the driver knows about and neosh has not been told
|
|
34
|
+
* about yet. A conversation that has not run a turn knows *none* of the driver's commands, so on a
|
|
35
|
+
* fresh conversation that is the only way `/compact` can work at all.
|
|
36
|
+
*
|
|
37
|
+
* What happens on accept differs by which vocabulary the row came from, and that is the whole of
|
|
38
|
+
* the difference:
|
|
39
|
+
*
|
|
40
|
+
* * a neosh command runs, and the draft is cleared, because it was never a message;
|
|
41
|
+
* * a driver command with no argument is *sent*, because it is a message and it is finished;
|
|
42
|
+
* * one that takes an argument is written into the composer, because it is not.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type { Neosh, PluginContext } from "@neosh/api";
|
|
46
|
+
import { picker } from "@neosh/api/ui";
|
|
47
|
+
|
|
48
|
+
type Entry =
|
|
49
|
+
| { kind: "command"; name: string }
|
|
50
|
+
| { kind: "driver"; name: string; takesArgument: boolean }
|
|
51
|
+
| { kind: "verbatim"; text: string };
|
|
52
|
+
|
|
53
|
+
/** Commands that are plumbing rather than something to invoke. */
|
|
54
|
+
const HIDDEN = /\.key$|^slash\./;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What the composer has to look like for the menu to be about it: a slash, then a command name,
|
|
58
|
+
* and nothing else. A space ends it — by then you are typing an argument, and the menu has no
|
|
59
|
+
* opinion about arguments — and so does a second line.
|
|
60
|
+
*/
|
|
61
|
+
const TOKEN = /^\/([\w.:-]*)$/;
|
|
62
|
+
|
|
63
|
+
export async function activate({ neosh, subscriptions }: PluginContext) {
|
|
64
|
+
let draft = "";
|
|
65
|
+
let open = false;
|
|
66
|
+
|
|
67
|
+
// Opened deliberately — from `^K`, from a binding, from another plugin — starting from whatever
|
|
68
|
+
// command name is already half-typed. Marked open for the same reason the draft path is: the menu
|
|
69
|
+
// writes the composer back, and a second one opening on its own echo would take the keyboard from
|
|
70
|
+
// the first.
|
|
71
|
+
// The query is asked for rather than passed: this takes several round-trips to open, and
|
|
72
|
+
// everything typed in that window lands in the composer. Read at the start, the menu comes up
|
|
73
|
+
// unfiltered with a row nobody aimed at under `↵` — which is how typing `/compact` at speed ran
|
|
74
|
+
// the first command in the list instead. Read at the end, what you typed is the filter.
|
|
75
|
+
const start = async () => {
|
|
76
|
+
if (open) return;
|
|
77
|
+
open = true;
|
|
78
|
+
await show(neosh, () => TOKEN.exec(draft)?.[1] ?? "", () => (open = false));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
subscriptions.push(
|
|
82
|
+
await neosh.cmd.register("slash.open", () => start(), {
|
|
83
|
+
desc: "Run a command by name, neosh's or the agent's",
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Opened by the draft rather than by a key. `/` has to stay a character you can type — a binding
|
|
88
|
+
// on it would mean no message could ever begin with one — so the menu follows what is in the
|
|
89
|
+
// composer.
|
|
90
|
+
//
|
|
91
|
+
// Only on the *bare* slash, and not again while it is up. Once it is open it is the menu that
|
|
92
|
+
// owns the keyboard and the menu that writes the composer back, so every keystroke from then on
|
|
93
|
+
// arrives here as an echo of something this plugin just did. Re-opening on those is a loop.
|
|
94
|
+
subscriptions.push(
|
|
95
|
+
neosh.agent.onComposerChange(({ text }) => {
|
|
96
|
+
draft = text;
|
|
97
|
+
if (text === "/") void start();
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function show(neosh: Neosh, query: () => string, done: () => void): Promise<void> {
|
|
103
|
+
const [commands, keymaps, driver] = await Promise.all([
|
|
104
|
+
neosh.cmd.list(),
|
|
105
|
+
neosh.keymap.list("chat"),
|
|
106
|
+
neosh.agent.driverCommands().catch(() => []),
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const keyFor = new Map<string, string>();
|
|
110
|
+
for (const k of keymaps) {
|
|
111
|
+
if (!keyFor.has(k.command)) keyFor.set(k.command, k.lhs);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const items = [
|
|
115
|
+
// The agent's first. They are the ones you cannot find any other way — every neosh command is
|
|
116
|
+
// also in `^K`, and none of the agent's is.
|
|
117
|
+
...driver.map((d) => ({
|
|
118
|
+
label: d.name,
|
|
119
|
+
detail: [d.description, d.argument_hint].filter(Boolean).join(" ") || "the agent's",
|
|
120
|
+
value: {
|
|
121
|
+
kind: "driver",
|
|
122
|
+
name: d.name,
|
|
123
|
+
takesArgument: Boolean(d.argument_hint),
|
|
124
|
+
} as Entry,
|
|
125
|
+
})),
|
|
126
|
+
...commands
|
|
127
|
+
.filter((c) => !HIDDEN.test(c.name))
|
|
128
|
+
.map((c) => ({
|
|
129
|
+
label: c.name,
|
|
130
|
+
detail: [keyFor.get(c.name), c.desc].filter(Boolean).join(" "),
|
|
131
|
+
value: { kind: "command", name: c.name } as Entry,
|
|
132
|
+
})),
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const chosen = await picker(neosh, items, {
|
|
136
|
+
title: "Run",
|
|
137
|
+
// Anchored to the field it is completing and lifted clear of it, the way every completion
|
|
138
|
+
// menu in every editor is placed. In the middle of the screen it covered the answer you were
|
|
139
|
+
// reading and pointed at nothing.
|
|
140
|
+
anchor: { kind: "dock", dock: "bottom" },
|
|
141
|
+
match: "name",
|
|
142
|
+
query,
|
|
143
|
+
// The composer is the field. Everything typed here goes back into it, so the message says what
|
|
144
|
+
// you typed and escaping the menu leaves it exactly there — mid-word, ready to keep going.
|
|
145
|
+
onQuery: (q) => void neosh.agent.setDraft(`/${q}`),
|
|
146
|
+
// What `↵` does when nothing is highlighted, which here means nothing matched. Without it the
|
|
147
|
+
// accept key would do nothing at all on exactly the input that most needs it to work.
|
|
148
|
+
freeform: (q) => (q ? ({ kind: "verbatim", text: `/${q}` } as Entry) : null),
|
|
149
|
+
placeholder: "not one of ours \u2014 \u21b5 sends it to the agent as typed",
|
|
150
|
+
width: 78,
|
|
151
|
+
// Short. It is a hint about a word you are half-way through typing, not a catalogue — `^K` is
|
|
152
|
+
// the catalogue — and a menu that eats a third of the transcript to say so is one you close
|
|
153
|
+
// before reading.
|
|
154
|
+
height: 8,
|
|
155
|
+
hints: "↵ run ^P/^N move type to filter esc keep typing",
|
|
156
|
+
});
|
|
157
|
+
done();
|
|
158
|
+
|
|
159
|
+
// Escaped, or filtered down to nothing. Either way what was typed is in the composer, where it
|
|
160
|
+
// was being typed, and it stays there: it may have been the first word of an ordinary sentence.
|
|
161
|
+
if (!chosen) return;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
switch (chosen.kind) {
|
|
165
|
+
case "command":
|
|
166
|
+
await neosh.agent.setDraft("");
|
|
167
|
+
await neosh.cmd.exec(chosen.name);
|
|
168
|
+
return;
|
|
169
|
+
// Finished being typed, so it goes. Leaving a complete command sitting in the composer for a
|
|
170
|
+
// second confirmation is a keystroke that asks nothing: you chose it from a list. One that
|
|
171
|
+
// takes an argument is *not* finished, and stays where you can add it.
|
|
172
|
+
case "driver":
|
|
173
|
+
if (chosen.takesArgument) {
|
|
174
|
+
await neosh.agent.setDraft(`/${chosen.name} `);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
await neosh.agent.setDraft("");
|
|
178
|
+
await neosh.agent.send(`/${chosen.name}`);
|
|
179
|
+
return;
|
|
180
|
+
// Not in the list, which is not the same as wrong: the driver is the one that gets to say
|
|
181
|
+
// whether it knows this name, and it will say so in its own words.
|
|
182
|
+
case "verbatim":
|
|
183
|
+
await neosh.agent.setDraft("");
|
|
184
|
+
await neosh.agent.send(chosen.text);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
neosh.notify(String(e), "warn");
|
|
189
|
+
}
|
|
190
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neosh/slash",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Type / to reach a command \u2014 neosh's own, and whatever the driver says it accepts.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"neosh",
|
|
9
|
+
"neosh-plugin"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/neoswarm/neosh.git",
|
|
14
|
+
"directory": "plugins/builtin/slash"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"*.ts",
|
|
18
|
+
"plugin.toml",
|
|
19
|
+
"!._*"
|
|
20
|
+
]
|
|
21
|
+
}
|
package/plugin.toml
ADDED