@neosh/palette 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 +378 -0
  2. package/package.json +21 -0
  3. package/plugin.toml +4 -0
package/main.ts ADDED
@@ -0,0 +1,378 @@
1
+ /**
2
+ * The command palette.
3
+ *
4
+ * One key that reaches everything, which is the point of the pattern: a workspace accumulates more
5
+ * actions than a keyboard has comfortable chords, and the alternative to a palette is a menu bar
6
+ * nobody reads or a cheat sheet nobody opens.
7
+ *
8
+ * It searches what the *registry* knows rather than a hand-written list, so a command registered by
9
+ * a plugin loaded five minutes ago is findable without this file changing. Conversations are in the
10
+ * same list because "go to the thing I was doing" and "run the thing" are the same intent arriving
11
+ * through the same key.
12
+ */
13
+
14
+ import type { KeymapEntry, Neosh, PluginContext } from "@neosh/api";
15
+ import { byteLength } from "@neosh/api";
16
+ import { picker } from "@neosh/api/ui";
17
+
18
+ type Entry =
19
+ | { kind: "command"; name: string }
20
+ | { kind: "session"; id: string };
21
+
22
+ export async function activate({ neosh, subscriptions }: PluginContext) {
23
+ subscriptions.push(
24
+ await neosh.cmd.register("commandPalette.toggle", () => open(neosh), {
25
+ desc: "Search commands and conversations",
26
+ }),
27
+ );
28
+ subscriptions.push(
29
+ await neosh.cmd.register("help.keys", () => showKeys(neosh), {
30
+ desc: "Show every key binding",
31
+ }),
32
+ );
33
+ // Registered once rather than per overlay: a command name is global, and re-registering it on
34
+ // every `<C-z>` would leave the previous window's handler shadowed and its float on screen.
35
+ subscriptions.push(
36
+ await neosh.cmd.register("help.keys.key", () => void dismissKeys?.(), {
37
+ desc: "Dismiss the key list",
38
+ }),
39
+ );
40
+
41
+ // `:checkhealth`: every plugin, what became of it, and what each one put in the registries.
42
+ // No default key — `^K` runs it by name.
43
+ subscriptions.push(
44
+ await neosh.cmd.register("plugins.list", () => showPlugins(neosh), {
45
+ desc: "Every plugin: loaded, held or failed, and what each registered",
46
+ }),
47
+ );
48
+
49
+ await neosh.keymap.set("chat", "<C-k>", "commandPalette.toggle", { desc: "Command palette" });
50
+ // `^Z` rather than `<F1>`. A function key is not a key everybody has: Apple's top row is
51
+ // brightness and volume until somebody goes into the settings, so the key that lists every key
52
+ // was the one key a new Mac could not press — and a keyboard whose F-row lives on a layer is in
53
+ // the same position. `^Z` is the one chord chat mode had left, every terminal delivers it
54
+ // identically, and raw mode means it cannot suspend anything. Rebind it like any other default.
55
+ await neosh.keymap.set("chat", "<C-z>", "help.keys", { desc: "Key bindings" });
56
+
57
+ // Last on the row, and the way out of it: whatever else got dropped for width is in here.
58
+ await neosh.hint.set("commands", { keys: "^K", label: "commands", priority: 30 });
59
+ await neosh.hint.set("keys", { keys: "^Z", label: "keys", priority: 31 });
60
+ }
61
+
62
+ async function open(neosh: Neosh): Promise<void> {
63
+ const [commands, keymaps, sessions, current] = await Promise.all([
64
+ neosh.cmd.list(),
65
+ neosh.keymap.list("chat"),
66
+ neosh.session.list().catch(() => []),
67
+ neosh.session.current().catch(() => null),
68
+ ]);
69
+
70
+ // A command's binding is what makes the palette teach rather than merely dispatch: you look
71
+ // something up twice and the third time you use the key.
72
+ const keyFor = new Map<string, string>();
73
+ for (const k of keymaps) {
74
+ if (!keyFor.has(k.command)) keyFor.set(k.command, k.lhs);
75
+ }
76
+
77
+ const items = [
78
+ // Conversations first: switching is the most frequent thing anyone does here, and the current
79
+ // one is excluded because "switch to where I already am" is not an action.
80
+ ...sessions
81
+ .filter((s) => s.id !== current?.id)
82
+ .map((s) => ({
83
+ label: s.label,
84
+ detail: "conversation",
85
+ keywords: s.cwd,
86
+ value: { kind: "session", id: s.id } as Entry,
87
+ })),
88
+ ...commands
89
+ // The palette's own entry would be a loop, and the key handlers registered by widgets are
90
+ // internal plumbing rather than things to invoke.
91
+ .filter((c) => c.name !== "commandPalette.toggle" && !c.name.includes(".key"))
92
+ .map((c) => ({
93
+ label: c.name,
94
+ detail: [keyFor.get(c.name), c.desc].filter(Boolean).join(" "),
95
+ keywords: `${c.desc ?? ""} ${c.plugin}`,
96
+ value: { kind: "command", name: c.name } as Entry,
97
+ })),
98
+ ];
99
+
100
+ const chosen = await picker(neosh, items, {
101
+ title: "Go to",
102
+ placeholder: "nothing matches",
103
+ width: 78,
104
+ height: 14,
105
+ });
106
+ if (!chosen) return;
107
+
108
+ try {
109
+ if (chosen.kind === "session") await neosh.session.switch(chosen.id);
110
+ else await neosh.cmd.exec(chosen.name);
111
+ } catch (e) {
112
+ neosh.notify(String(e), "warn");
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Every binding, grouped by mode.
118
+ *
119
+ * Read from the registry rather than written down, so it cannot drift — and so a plugin's keys
120
+ * appear here without that plugin knowing this exists. It closes on any key, because a help window
121
+ * you have to work out how to dismiss is a help window that taught you the wrong thing first.
122
+ */
123
+ async function showKeys(neosh: Neosh): Promise<void> {
124
+ const modes = ["chat", "normal", "insert", "visual"] as const;
125
+ const commands = await neosh.cmd.list();
126
+ const describe = new Map(commands.map((c) => [c.name, c.desc ?? ""]));
127
+
128
+ // Which panel you asked from, if you asked from one.
129
+ //
130
+ // `?` in the sidebar means "what can I do *here*", and a list that answers with thirty global
131
+ // bindings and puts the panel's own keys below the fold has answered a different question. The
132
+ // window is found by kind rather than by anything the panel told us, so this works for a panel
133
+ // this plugin has never heard of.
134
+ const here = (await neosh.win.list().catch(() => []))
135
+ .find((w) => w.focused)?.kind ?? null;
136
+
137
+ interface Row {
138
+ text: string;
139
+ /** Byte range of the key itself, so it can be highlighted apart from its description. */
140
+ key?: [number, number];
141
+ heading?: boolean;
142
+ }
143
+ const rows: Row[] = [];
144
+
145
+ /** One group of bindings under a heading, keys padded to a common column. */
146
+ const emit = (title: string, maps: KeymapEntry[]) => {
147
+ if (maps.length === 0) return;
148
+ if (rows.length > 0) rows.push({ text: "" });
149
+ rows.push({ text: title, heading: true });
150
+ const width = Math.max(...maps.map((m) => m.lhs.length));
151
+ for (const m of [...maps].sort((a, b) => a.lhs.localeCompare(b.lhs))) {
152
+ const note = m.desc ?? describe.get(m.command) ?? m.command;
153
+ rows.push({ text: ` ${m.lhs.padEnd(width)} ${note}`, key: [2, 2 + byteLength(m.lhs)] });
154
+ }
155
+ };
156
+
157
+ for (const mode of modes) {
158
+ const maps = await neosh.keymap.list(mode);
159
+ if (maps.length === 0) continue;
160
+ // Bindings scoped to a buffer kind get a section of their own, named after the kind.
161
+ //
162
+ // Not cosmetic: a panel's keys are ordinary bindings now, so `^N` is legitimately two things —
163
+ // "new conversation" everywhere and "next row" in the sidebar — and one flat list showing the
164
+ // same key twice with no way to tell which is which is worse than not listing them. The heading
165
+ // is where the answer goes, and it is read from the binding rather than written down, so a
166
+ // panel somebody else wrote gets a section here without knowing this exists.
167
+ const byKind = new Map<string, KeymapEntry[]>();
168
+ const plain: KeymapEntry[] = [];
169
+ for (const m of maps) {
170
+ if (m.scope.kind === "buf_kind") {
171
+ const list = byKind.get(m.scope.name) ?? [];
172
+ list.push(m);
173
+ byKind.set(m.scope.name, list);
174
+ } else {
175
+ plain.push(m);
176
+ }
177
+ }
178
+ // The panel you are standing in goes first; everything else keeps its alphabetical place.
179
+ const ordered = [...byKind].sort((a, b) =>
180
+ a[0] === here ? -1 : b[0] === here ? 1 : a[0].localeCompare(b[0])
181
+ );
182
+ const first = ordered.filter(([kind]) => kind === here);
183
+ for (const [kind, list] of first) emit(`${mode.toUpperCase()} · ${kind}`, list);
184
+ emit(mode.toUpperCase(), plain);
185
+ for (const [kind, list] of ordered.filter(([kind]) => kind !== here)) {
186
+ emit(`${mode.toUpperCase()} · ${kind}`, list);
187
+ }
188
+ }
189
+
190
+ // What is left: keys that are not bindings at all, but what the *host* does with a key nothing
191
+ // claimed. The registry cannot know these, so this is the one place they are written down.
192
+ //
193
+ // The project panel used to be on this list and no longer is — its keys became ordinary bindings,
194
+ // so they come out of the registry above, complete with whatever a third party has added to them.
195
+ // That is the difference this section is now measuring: everything above is discovered,
196
+ // everything below had to be remembered.
197
+ for (const [title, keys] of [["COMPOSER", COMPOSER_KEYS], [
198
+ "READING THE TRANSCRIPT",
199
+ READING_KEYS,
200
+ ]] as const) {
201
+ rows.push({ text: "" });
202
+ rows.push({ text: title, heading: true });
203
+ for (const [lhs, note] of keys) {
204
+ rows.push({ text: ` ${lhs.padEnd(9)} ${note}`, key: [2, 2 + byteLength(lhs)] });
205
+ }
206
+ }
207
+
208
+ if (rows.length === 0) rows.push({ text: "no bindings" });
209
+ rows.push({ text: "" });
210
+ rows.push({ text: " any key closes this", heading: false });
211
+
212
+ // A second `<C-z>` replaces the first window rather than stacking one behind it.
213
+ await dismissKeys?.();
214
+
215
+ const buf = await neosh.buf.create({ name: "[keys]", scratch: true });
216
+ const ns = await neosh.ns.create("neosh.help");
217
+ await neosh.buf.setLines(buf, 0, -1, rows.map((r) => r.text));
218
+ for (let i = 0; i < rows.length; i++) {
219
+ const r = rows[i]!;
220
+ if (r.heading) {
221
+ await neosh.ns.mark(ns, buf, i, 0, { hlGroup: "Title", endCol: byteLength(r.text) });
222
+ } else if (r.key) {
223
+ await neosh.ns.mark(ns, buf, i, r.key[0], { hlGroup: "Key", endCol: r.key[1] });
224
+ }
225
+ }
226
+
227
+ const win = await neosh.float.open(buf, {
228
+ anchor: { kind: "screen" },
229
+ width: { kind: "max", n: 64 },
230
+ height: { kind: "max", n: 24 },
231
+ border: "rounded",
232
+ title: " keys ",
233
+ closeOnBlur: true,
234
+ focusable: true,
235
+ // Above the panels, because this is asked *from* one. `?` in a panel is the key that answers
236
+ // "what do the keys here do", and every panel worth asking it in is a float of its own — so at
237
+ // the default depth the answer opened underneath the question and read as nothing happening.
238
+ z: 300,
239
+ });
240
+ await neosh.focus.push(win);
241
+
242
+ const capture = await neosh.keymap.capture(win, "help.keys.key").catch(() => null);
243
+ dismissKeys = async () => {
244
+ dismissKeys = null;
245
+ capture?.dispose();
246
+ await neosh.focus.pop().catch(() => {});
247
+ await neosh.win.close(win).catch(() => {});
248
+ };
249
+ }
250
+
251
+ /** Closes whichever key list is open, if any. Set while one is on screen and cleared as it goes. */
252
+ let dismissKeys: (() => Promise<void>) | null = null;
253
+
254
+ /**
255
+ * What the project panel's captured keys do.
256
+ *
257
+ * A capture is not a keymap, so `keymap.list` cannot see these — and a help screen that omits the
258
+ * keys for the thing on the left of the screen is worse than no help screen.
259
+ */
260
+ /**
261
+ * The composer's own keys.
262
+ *
263
+ * Also not keymaps: they are what the host does with a key nothing claimed, which is the only way
264
+ * a text field can have a hundred behaviours without taking a hundred names out of the keymap
265
+ * namespace. Written down here because a text field whose keys are undiscoverable is a text box.
266
+ */
267
+ const COMPOSER_KEYS: [string, string][] = [
268
+ ["← →", "by character"],
269
+ ["^← ^→", "by word"],
270
+ ["↑ ↓", "between lines, or scroll the transcript"],
271
+ ["Home End", "start and end of the line"],
272
+ ["^Home ^End", "start and end of the draft"],
273
+ ["Shift+…", "any motion, extending a selection"],
274
+ ["S-CR", "new line instead of sending"],
275
+ ["^W", "delete the word behind the cursor"],
276
+ ["^U", "delete back to the start of the line"],
277
+ ["^A", "select everything"],
278
+ ["^C", "copy the selection — or clear the draft when there is none"],
279
+ ["^X", "cut the selection"],
280
+ ];
281
+
282
+ /** What the keys mean once you are in the transcript. */
283
+ const READING_KEYS: [string, string][] = [
284
+ ["hjkl", "move, and so do the arrows"],
285
+ ["w b", "by word"],
286
+ ["0 $", "start and end of the line"],
287
+ ["g G", "top and bottom"],
288
+ ["v", "start selecting; motions extend it"],
289
+ ["a", "select the whole transcript"],
290
+ ["y", "copy and leave"],
291
+ ["Esc q", "leave"],
292
+ ];
293
+
294
+
295
+
296
+ /**
297
+ * The plugins panel: one row per plugin, and on `↵` what that plugin put on the surface.
298
+ *
299
+ * Drawn from the registries — `cmd.list`, `keymap.list`, `hl.list`, `ext.points` — rather than
300
+ * from anything a plugin says about itself, so a plugin that registered a command it does not
301
+ * mention in its manifest is listed with it anyway. The manifest's `provides` is shown beside,
302
+ * which is how a point nobody reads and a reader nobody declared both become visible.
303
+ */
304
+ async function showPlugins(neosh: Neosh): Promise<void> {
305
+ const plugins = await neosh.ext.plugins();
306
+ const mark = (state: string) =>
307
+ state === "loaded" ? { icon: "●", hl: "Diagnostic.Ok" }
308
+ : state === "held" ? { icon: "○", hl: "Sidebar.Dim" }
309
+ : { icon: "✗", hl: "Diagnostic.Error" };
310
+ const chosen = await picker<string>(
311
+ neosh,
312
+ plugins.map((p) => ({
313
+ label: p.name,
314
+ detail: `${p.state}${p.bundled ? " · bundled" : ""}${p.manifest.description ? ` · ${p.manifest.description}` : ""}`,
315
+ keywords: p.state,
316
+ ...mark(p.state),
317
+ value: p.name,
318
+ })),
319
+ { title: "Plugins", width: 80 },
320
+ );
321
+ if (chosen === null) return;
322
+ const info = plugins.find((p) => p.name === chosen);
323
+ if (!info) return;
324
+
325
+ const [commands, keymaps, groups, points] = await Promise.all([
326
+ neosh.cmd.list(),
327
+ neosh.keymap.list(),
328
+ neosh.hl.list(),
329
+ neosh.ext.points(),
330
+ ]);
331
+ const mine = commands.filter((c) => c.plugin === chosen).map((c) => c.name);
332
+ const keys = keymaps.filter((k) => mine.includes(k.command));
333
+ const colours = groups.filter((g) => g.owner === chosen).map((g) => g.name);
334
+ const reads = points.filter((p) => p.readers.includes(chosen)).map((p) => p.point);
335
+ const writes = points.filter((p) => p.contributors.includes(chosen)).map((p) => p.point);
336
+
337
+ const rows: Array<{ label: string; detail?: string; hl?: string; icon?: string }> = [];
338
+ const section = (title: string, items: string[], detail?: (s: string) => string | undefined) => {
339
+ if (items.length === 0) return;
340
+ rows.push({ label: title, hl: "Sidebar.Heading", icon: " " });
341
+ for (const it of items) rows.push({ label: ` ${it}`, detail: detail?.(it) });
342
+ };
343
+ if (info.error) rows.push({ label: info.error, hl: "Diagnostic.Error", icon: "✗" });
344
+ // Every list on a manifest is optional on the wire — absent when empty.
345
+ const m = info.manifest;
346
+ const list = (v: string[] | null | undefined) => v ?? [];
347
+ const act = m.activation ?? {};
348
+ const provides = m.provides ?? {};
349
+ section("manifest", [
350
+ `version ${m.version}`,
351
+ ...(list(m.requires).length ? [`requires ${list(m.requires).join(", ")}`] : []),
352
+ ...(list(m.after).length ? [`after ${list(m.after).join(", ")}`] : []),
353
+ ...(list(m.permissions).length ? [`permissions ${list(m.permissions).join(", ")}`] : []),
354
+ ...(list(act.on_command).length ? [`loads on command ${list(act.on_command).join(", ")}`] : []),
355
+ ...(list(act.on_event).length ? [`loads on event ${list(act.on_event).join(", ")}`] : []),
356
+ ...(list(act.on_kind).length ? [`loads on kind ${list(act.on_kind).join(", ")}`] : []),
357
+ ]);
358
+ section("kinds", list(provides.kinds));
359
+ section("points read", reads);
360
+ section("points written", writes, (p) => {
361
+ const r = points.find((q) => q.point === p);
362
+ return r && r.readers.length === 0 ? "nothing reads this" : undefined;
363
+ });
364
+ section("vars", list(provides.vars));
365
+ section("highlights", colours);
366
+ // Last, because it is the longest: a panel has forty verbs and one manifest.
367
+ section("commands", mine, (name) => {
368
+ const bound = keys.filter((k) => k.command === name).map((k) => k.lhs);
369
+ return bound.length ? bound.join(" ") : undefined;
370
+ });
371
+ if (rows.length === 0) rows.push({ label: "registered nothing", hl: "Sidebar.Dim" });
372
+
373
+ await picker<number>(
374
+ neosh,
375
+ rows.map((r, i) => ({ label: r.label, detail: r.detail, hl: r.hl, icon: r.icon, value: i })),
376
+ { title: chosen, width: 80, height: 24, filter: false, hints: "" },
377
+ );
378
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@neosh/palette",
3
+ "version": "0.1.0",
4
+ "description": "One key to everything: commands, conversations, and what they are bound to.",
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/palette"
15
+ },
16
+ "files": [
17
+ "*.ts",
18
+ "plugin.toml",
19
+ "!._*"
20
+ ]
21
+ }
package/plugin.toml ADDED
@@ -0,0 +1,4 @@
1
+ name = "palette"
2
+ version = "0.1.0"
3
+ entry = "main.ts"
4
+ description = "One key to everything: commands, conversations, and what they are bound to."