@fungi.computer/whistle 0.0.0-stage → 0.0.1
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 +373 -2
- package/dist/catalog.d.ts +459 -0
- package/dist/catalog.js +147 -0
- package/dist/html.d.ts +14 -0
- package/dist/html.js +11 -0
- package/dist/index.d.ts +253 -0
- package/dist/index.js +694 -0
- package/dist/interaction.d.ts +59 -0
- package/dist/interaction.js +131 -0
- package/dist/keymap.d.ts +34 -0
- package/dist/keymap.js +36 -0
- package/dist/wire.d.ts +380 -0
- package/dist/wire.js +299 -0
- package/docs/api/README.md +17 -0
- package/docs/api/catalog.md +498 -0
- package/docs/api/html.md +45 -0
- package/docs/api/index.md +675 -0
- package/docs/api/interaction.md +124 -0
- package/docs/api/wire.md +1033 -0
- package/package.json +87 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jamstack Consulting LLC
|
|
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
CHANGED
|
@@ -1,3 +1,374 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Whistle (`@fungi.computer/whistle`)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Whistle is a typed command graph for apps that expose the same actions in many
|
|
4
|
+
places. You write one command definition. Whistle derives six immutable views
|
|
5
|
+
from it: key bindings, command palette, slash commands, help, menu, and an agent
|
|
6
|
+
view for AI tools. Every caller, from a key press to an agent, runs the command
|
|
7
|
+
through one typed `execute` door and gets one typed outcome back.
|
|
8
|
+
|
|
9
|
+
Use it when a keyboard shortcut, a palette entry, a `/slash` command and an
|
|
10
|
+
agent tool should all mean the same action, show the same availability, and fail
|
|
11
|
+
the same way. Whistle owns the command graph. Your host keeps key matching,
|
|
12
|
+
rendering, transport, authorization, and the real work.
|
|
13
|
+
|
|
14
|
+
## Install or set up
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install @fungi.computer/whistle
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Whistle is an ESM package with Zod as its only runtime dependency. It runs in
|
|
21
|
+
Node, browsers, and Workers. It has five entrypoints:
|
|
22
|
+
|
|
23
|
+
- `@fungi.computer/whistle`: the runtime, `scopeSnapshot`, the keymap adapter,
|
|
24
|
+
and the command types.
|
|
25
|
+
- `@fungi.computer/whistle/wire`: Zod schemas and parsers for command
|
|
26
|
+
definitions, prompts, notices, and prompt answers.
|
|
27
|
+
- `@fungi.computer/whistle/interaction`: a renderer-neutral interaction store.
|
|
28
|
+
- `@fungi.computer/whistle/catalog`: parses a backend's `whistle.catalog/v1`
|
|
29
|
+
command catalog and turns it into commands.
|
|
30
|
+
|
|
31
|
+
- `@fungi.computer/whistle/html`: binds command projections to a DOM keymap.
|
|
32
|
+
HTML users install the optional peer `@opentui/keymap@0.5.10`; core users do
|
|
33
|
+
not need it.
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
This creates a runtime, contributes one command to the palette and slash
|
|
38
|
+
surfaces, reads the snapshot, and runs the command:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createWhistle } from "@fungi.computer/whistle";
|
|
42
|
+
|
|
43
|
+
const whistle = createWhistle();
|
|
44
|
+
|
|
45
|
+
const lease = whistle.contribute({
|
|
46
|
+
sourceId: "editor",
|
|
47
|
+
namespace: "editor",
|
|
48
|
+
commands: [
|
|
49
|
+
{
|
|
50
|
+
id: "save",
|
|
51
|
+
title: "Save file",
|
|
52
|
+
category: "File",
|
|
53
|
+
projections: {
|
|
54
|
+
palette: {},
|
|
55
|
+
slash: { aliases: ["save"] },
|
|
56
|
+
bindings: { values: ["mod+s"] },
|
|
57
|
+
},
|
|
58
|
+
handler: () => ({ saved: true }),
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const snapshot = whistle.snapshot();
|
|
64
|
+
console.log(snapshot.palette.map((row) => row.commandId)); // ["editor:save"]
|
|
65
|
+
console.log(snapshot.slash.map((row) => row.alias)); // ["save"]
|
|
66
|
+
|
|
67
|
+
const outcome = await whistle.execute("editor:save", { origin: "palette" });
|
|
68
|
+
if (outcome.status === "handled") console.log(outcome.result); // { saved: true }
|
|
69
|
+
|
|
70
|
+
lease.dispose();
|
|
71
|
+
whistle.close();
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A command's canonical ID is `<namespace>:<id>`. Every projection row carries
|
|
75
|
+
that ID, so a palette click, a key press and a slash command all call the same
|
|
76
|
+
`execute`.
|
|
77
|
+
|
|
78
|
+
### Bind a browser keyboard
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
npm install @opentui/keymap@0.5.10
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { createWhistle } from "@fungi.computer/whistle";
|
|
86
|
+
import { bindHtmlKeymap } from "@fungi.computer/whistle/html";
|
|
87
|
+
|
|
88
|
+
const whistle = createWhistle();
|
|
89
|
+
whistle.contribute({
|
|
90
|
+
sourceId: "browser",
|
|
91
|
+
namespace: "browser",
|
|
92
|
+
commands: [
|
|
93
|
+
{
|
|
94
|
+
id: "save",
|
|
95
|
+
title: "Save",
|
|
96
|
+
category: "File",
|
|
97
|
+
projections: { bindings: { values: ["mod+s"] } },
|
|
98
|
+
handler: () => ({ saved: true }),
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
const keyboard = bindHtmlKeymap(document.body, whistle, "keyboard");
|
|
103
|
+
// Contributions and availability updates refresh the projected key layer.
|
|
104
|
+
keyboard.dispose();
|
|
105
|
+
whistle.close();
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`dispose` removes the Whistle layer and subscription, and can be called twice.
|
|
109
|
+
The underlying HTML keymap host follows the document's lifetime; this helper
|
|
110
|
+
does not remove its DOM listeners. Commands still execute through Whistle.
|
|
111
|
+
|
|
112
|
+
## Core concepts
|
|
113
|
+
|
|
114
|
+
### Commands, contributions and leases
|
|
115
|
+
|
|
116
|
+
A command has an `id`, a `title`, a `category`, an optional `description`, a
|
|
117
|
+
`handler`, and optional `projections`, `action` and `availability`. A command
|
|
118
|
+
shows up only on the surfaces its `projections` name.
|
|
119
|
+
|
|
120
|
+
`contribute` admits a batch of commands from one `sourceId` into one
|
|
121
|
+
`namespace`, optionally inside a focus `scope` such as one open window.
|
|
122
|
+
Admission is atomic: the whole contribution is validated before any of it goes
|
|
123
|
+
live. It throws when a namespace already belongs to another source, when a
|
|
124
|
+
canonical ID or a slash alias in the same scope repeats, or when a field is
|
|
125
|
+
blank.
|
|
126
|
+
|
|
127
|
+
Each contribution returns a lease for that exact generation. `lease.update`
|
|
128
|
+
swaps its commands atomically. `lease.dispose` removes it. Disposing a stale
|
|
129
|
+
lease never removes a replacement. Both calls are idempotent.
|
|
130
|
+
|
|
131
|
+
### Snapshots and projections
|
|
132
|
+
|
|
133
|
+
`snapshot()` returns six frozen arrays: `bindings`, `palette`, `slash`, `help`,
|
|
134
|
+
`menu` and `agent`. Rows are immutable copies. Each surface sorts by its
|
|
135
|
+
explicit `order`, then by command ID in UTF-8 byte order, so the order never
|
|
136
|
+
depends on insertion. A snapshot keeps its identity until the graph or some
|
|
137
|
+
availability changes. `subscribe(listener)` tells you when the graph changes.
|
|
138
|
+
|
|
139
|
+
The agent view drops `presentation` metadata and keeps the semantic
|
|
140
|
+
`inputSchema` and `outputSchema`, so an agent gets a tool description and never
|
|
141
|
+
a UI layout. `scopeSnapshot(snapshot, scope)` gives the human and agent view for
|
|
142
|
+
one focused scope: global rows plus that scope's rows, with a focused slash
|
|
143
|
+
alias or binding shadowing the same global one. Agent rows carry their scope and
|
|
144
|
+
follow the same filter. The complete runtime snapshot still lists all scopes.
|
|
145
|
+
|
|
146
|
+
### Execution
|
|
147
|
+
|
|
148
|
+
`execute(commandId, input)` is the only way a handler runs. `input.origin` is
|
|
149
|
+
one of eight closed origins: `agent`, `browser`, `keyboard`, `menu`, `palette`,
|
|
150
|
+
`programmatic`, `slash` and `terminal`. The outcome is one of:
|
|
151
|
+
|
|
152
|
+
- `handled`: the handler finished. Its return value is in `result`. This does
|
|
153
|
+
not mean physical work or a durable workflow completed.
|
|
154
|
+
- `unavailable`: the command's `availability()` said no. The reason comes back.
|
|
155
|
+
- `failed`: the handler threw. `error` is JSON: `{ code, message, details? }`.
|
|
156
|
+
- `missing`: no live command has that ID.
|
|
157
|
+
- `invalid`: the command ID is blank.
|
|
158
|
+
- `closed`: the runtime was closed first.
|
|
159
|
+
|
|
160
|
+
### Availability
|
|
161
|
+
|
|
162
|
+
A command can supply a synchronous `availability()` reader over current host
|
|
163
|
+
facts. `snapshot()` keeps unavailable commands visible with their reason, and
|
|
164
|
+
`execute()` reads availability again right before calling the handler.
|
|
165
|
+
Availability is a convenience check. It is not authorization, so the handler
|
|
166
|
+
still admits the real request. The host updates its own facts and publishes
|
|
167
|
+
snapshots over its own connection. Whistle does no polling or broadcast.
|
|
168
|
+
|
|
169
|
+
### Failures
|
|
170
|
+
|
|
171
|
+
Throw `WhistleActionError(code, message, details?)` to choose the failure code
|
|
172
|
+
and attach JSON details. Any other `Error` becomes `command_failed`. Stacks and
|
|
173
|
+
non-Error thrown values never cross the execution result.
|
|
174
|
+
|
|
175
|
+
### Interactions
|
|
176
|
+
|
|
177
|
+
A running command can talk to its human caller through `input.interaction`.
|
|
178
|
+
`prompt` asks one `choice`, `text` (optionally `secret`) or `confirmation`
|
|
179
|
+
question. `notify` shows `info`, `progress`, `handoff` (open a URL, optionally
|
|
180
|
+
enter a code) or `success`. The vocabulary names the kind of answer, never a
|
|
181
|
+
widget.
|
|
182
|
+
|
|
183
|
+
A caller can pass its own interaction. Otherwise `present(presenter)` registers
|
|
184
|
+
the mounted client that opens one for each human-origin execution. Agent and
|
|
185
|
+
programmatic executions get none. Whistle settles the session with `handled`,
|
|
186
|
+
`cancelled` or `failed` when the handler ends, and the presenter can `retry` the
|
|
187
|
+
same execution. The newest presenter wins until its disposer runs.
|
|
188
|
+
|
|
189
|
+
`createWhistleInteractionHost` from `./interaction` is a renderer-neutral store
|
|
190
|
+
for one interaction. It parses every prompt, notice and answer with the `./wire`
|
|
191
|
+
schemas, and a terminal or browser renderer draws its snapshot.
|
|
192
|
+
|
|
193
|
+
### Slash arguments
|
|
194
|
+
|
|
195
|
+
A slash projection can carry `arguments`, a data-only grammar for the text after
|
|
196
|
+
the alias. Fixed `words` map to complete argument objects. `text` puts the rest
|
|
197
|
+
of the line into one field of a fixed argument object, optionally with a request
|
|
198
|
+
ID field for idempotent retries. Clients parse, complete and hint from this
|
|
199
|
+
data, so no client names a command in code.
|
|
200
|
+
|
|
201
|
+
### Catalogs
|
|
202
|
+
|
|
203
|
+
A backend advertises its commands as a `whistle.catalog/v1` catalog of the same
|
|
204
|
+
data-only definitions. `parseCatalog` validates it at the client boundary.
|
|
205
|
+
`catalogCommands(catalog, invoke)` turns it into commands whose handlers call
|
|
206
|
+
your `invoke` function. Each reply is a step: `done` (optional notice and
|
|
207
|
+
result) or `prompt` (one question plus the complete arguments of the next call,
|
|
208
|
+
with the answer going into a named field). Multi-step flows stay stateless on
|
|
209
|
+
the wire and run through the caller's interaction.
|
|
210
|
+
|
|
211
|
+
### Closing
|
|
212
|
+
|
|
213
|
+
`close()` is synchronous and idempotent and returns `undefined`. The first call
|
|
214
|
+
releases every live contribution and leaves the shared empty snapshot. Leases
|
|
215
|
+
issued earlier become harmless. An `execute` Promise returned before `close`
|
|
216
|
+
still settles, and may settle `handled`. A later `execute` returns `closed`, and
|
|
217
|
+
a later valid `contribute` throws `WhistleClosedError`.
|
|
218
|
+
|
|
219
|
+
### Typing a reusable handler
|
|
220
|
+
|
|
221
|
+
Use indexed access on `WhistleCommand` to declare a handler apart from a command
|
|
222
|
+
literal:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
import type { WhistleCommand } from "@fungi.computer/whistle";
|
|
226
|
+
|
|
227
|
+
type SaveArguments = Readonly<{ path: string }>;
|
|
228
|
+
type SaveResult = Readonly<{ saved: string }>;
|
|
229
|
+
|
|
230
|
+
const save: WhistleCommand<SaveArguments, SaveResult>["handler"] = ({
|
|
231
|
+
arguments: input,
|
|
232
|
+
}) => (input === undefined ? undefined : { saved: input.path });
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The handler receives `WhistleExecutionInput<SaveArguments>`: the origin,
|
|
236
|
+
optional arguments, a cancellation signal and an optional interaction. It
|
|
237
|
+
returns `SaveResult`, `undefined`, or a Promise of either.
|
|
238
|
+
`WhistleCommandHandler` names this callback type. Commands with different
|
|
239
|
+
argument and result types can share one `readonly WhistleCommand<never>[]`
|
|
240
|
+
contribution. Contributions erase those generics at runtime, so the handler
|
|
241
|
+
still validates the arguments it actually gets.
|
|
242
|
+
|
|
243
|
+
### Presentation hints
|
|
244
|
+
|
|
245
|
+
`action.inputSchema` and `action.outputSchema` describe structured data and keep
|
|
246
|
+
opaque JSON annotations. `action.presentation` is an optional local hint:
|
|
247
|
+
`action`, `choice`, `form`, `confirmation`, or `custom` with opaque JSON for an
|
|
248
|
+
owning client's own field or gesture bindings. A server capability must not
|
|
249
|
+
prescribe its form, menu or other rendering. Human projections keep the hint.
|
|
250
|
+
The agent projection drops it.
|
|
251
|
+
|
|
252
|
+
## API reference
|
|
253
|
+
|
|
254
|
+
See the generated [API reference](docs/api/README.md). It covers the root
|
|
255
|
+
entrypoint and the `wire`, `interaction` and `catalog` subpaths.
|
|
256
|
+
|
|
257
|
+
## Examples
|
|
258
|
+
|
|
259
|
+
### Show a command as unavailable
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import { createWhistle } from "@fungi.computer/whistle";
|
|
263
|
+
|
|
264
|
+
let signedIn = false;
|
|
265
|
+
const whistle = createWhistle();
|
|
266
|
+
whistle.contribute({
|
|
267
|
+
sourceId: "account",
|
|
268
|
+
namespace: "account",
|
|
269
|
+
commands: [
|
|
270
|
+
{
|
|
271
|
+
id: "sync",
|
|
272
|
+
title: "Sync now",
|
|
273
|
+
category: "Account",
|
|
274
|
+
projections: { palette: {}, agent: {} },
|
|
275
|
+
availability: () =>
|
|
276
|
+
signedIn
|
|
277
|
+
? { status: "available" }
|
|
278
|
+
: { status: "unavailable", reason: "Sign in first." },
|
|
279
|
+
handler: () => undefined,
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
console.log(whistle.snapshot().palette[0]?.availability);
|
|
285
|
+
// { status: "unavailable", reason: "Sign in first." }
|
|
286
|
+
console.log(await whistle.execute("account:sync", { origin: "agent" }));
|
|
287
|
+
// { status: "unavailable", reason: "Sign in first." }
|
|
288
|
+
signedIn = true;
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Fail with a code
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
import { WhistleActionError, createWhistle } from "@fungi.computer/whistle";
|
|
295
|
+
|
|
296
|
+
const whistle = createWhistle();
|
|
297
|
+
whistle.contribute({
|
|
298
|
+
sourceId: "files",
|
|
299
|
+
namespace: "files",
|
|
300
|
+
commands: [
|
|
301
|
+
{
|
|
302
|
+
id: "open",
|
|
303
|
+
title: "Open file",
|
|
304
|
+
category: "File",
|
|
305
|
+
handler: () => {
|
|
306
|
+
throw new WhistleActionError("not_found", "No such file", {
|
|
307
|
+
path: "a.txt",
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const outcome = await whistle.execute("files:open", { origin: "terminal" });
|
|
315
|
+
// { status: "failed", error: { code: "not_found", message: "No such file", details: { path: "a.txt" } } }
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### Load a backend catalog
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
import { createWhistle } from "@fungi.computer/whistle";
|
|
322
|
+
import { catalogCommands, parseCatalog } from "@fungi.computer/whistle/catalog";
|
|
323
|
+
|
|
324
|
+
const catalog = parseCatalog(await (await fetch("/commands")).json());
|
|
325
|
+
const whistle = createWhistle();
|
|
326
|
+
whistle.contribute({
|
|
327
|
+
sourceId: "backend",
|
|
328
|
+
namespace: "backend",
|
|
329
|
+
commands: catalogCommands(catalog, async (commandId, args, signal) => {
|
|
330
|
+
const response = await fetch(`/commands/${commandId}`, {
|
|
331
|
+
method: "POST",
|
|
332
|
+
body: JSON.stringify(args),
|
|
333
|
+
signal,
|
|
334
|
+
});
|
|
335
|
+
return response.json();
|
|
336
|
+
}),
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Checks
|
|
341
|
+
|
|
342
|
+
From a checkout, the unit tests cover the runtime, wire schemas, interaction
|
|
343
|
+
host and catalog:
|
|
344
|
+
|
|
345
|
+
```sh
|
|
346
|
+
pnpm --filter @fungi.computer/whistle test
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
## Limits and errors
|
|
350
|
+
|
|
351
|
+
- A runtime holds at most 64 live contributions and 256 live commands.
|
|
352
|
+
- A command has at most 16 binding values and 16 slash aliases. A slash grammar
|
|
353
|
+
has at most 32 words.
|
|
354
|
+
- A catalog has at most 128 commands, and a catalog flow stops after 16 steps
|
|
355
|
+
with `flow_too_long`. A catalog command that needs a prompt fails with
|
|
356
|
+
`interaction_required` when there is no interaction, which is always the case
|
|
357
|
+
for agent and programmatic origins.
|
|
358
|
+
- Prompt and notice text is capped at 8192 characters. A choice prompt has 1 to
|
|
359
|
+
256 options. Notice URLs must be `http` or `https`.
|
|
360
|
+
- Namespaces must not contain a colon, and command IDs, titles, categories and
|
|
361
|
+
labels must not be blank. Violations throw a plain `Error` from `contribute`
|
|
362
|
+
or `lease.update`.
|
|
363
|
+
- These bounds are defenses inside the implementation. They are not stable API
|
|
364
|
+
promises.
|
|
365
|
+
|
|
366
|
+
Whistle does not parse key presses or slash input from raw text, render UI, run
|
|
367
|
+
transport, or keep durable delivery state. It owns no browser, terminal, DOM,
|
|
368
|
+
React, product or authorization state, and has no timers or background work. Use
|
|
369
|
+
the `./wire` parsers on untrusted input before it becomes a command, prompt or
|
|
370
|
+
notice.
|
|
371
|
+
|
|
372
|
+
## Contribute and license
|
|
373
|
+
|
|
374
|
+
MIT. See [LICENSE](./LICENSE).
|