@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.
Files changed (3) hide show
  1. package/main.ts +190 -0
  2. package/package.json +21 -0
  3. 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
@@ -0,0 +1,4 @@
1
+ name = "slash"
2
+ version = "0.1.0"
3
+ entry = "main.ts"
4
+ description = "Type / to reach a command — neosh's own, and whatever the driver says it accepts."