@fungi.computer/whistle

中文文档
0.3.0发布于 2026/10/28MITTypeScriptES Module近 30 天下载 1.8万

Semantic command graph, projections and wire parsing

原始使用文档
文档目录23 节
  1. Install or set up
  2. Quick start
  3. Bind a browser keyboard
  4. Core concepts
  5. Commands, contributions and leases
  6. Snapshots and projections
  7. Execution
  8. Availability
  9. Failures
  10. Interactions
  11. Slash arguments
  12. Catalogs
  13. Closing
  14. Typing a reusable handler
  15. Presentation hints
  16. API reference
  17. Examples
  18. Show a command as unavailable
  19. Fail with a code
  20. Load a backend catalog
  21. Checks
  22. Limits and errors
  23. Contribute and license

Whistle (@fungi.computer/whistle)

Whistle is a typed command graph for apps that expose the same actions in many places. You write one command definition. Whistle derives six immutable views from it: key bindings, command palette, slash commands, help, menu, and an agent view for AI tools. Every caller, from a key press to an agent, runs the command through one typed execute door and gets one typed outcome back.

Use it when a keyboard shortcut, a palette entry, a /slash command and an agent tool should all mean the same action, show the same availability, and fail the same way. Whistle owns the command graph. Your host keeps key matching, rendering, transport, authorization, and the real work.

Install or set up

npm install @fungi.computer/whistle

Whistle is an ESM package with Zod as its only runtime dependency. It runs in Node, browsers, and Workers. It has five entrypoints:

  • @fungi.computer/whistle: the runtime, scopeSnapshot, the keymap adapter, and the command types.

  • @fungi.computer/whistle/wire: Zod schemas and parsers for command definitions, prompts, notices, and prompt answers.

  • @fungi.computer/whistle/interaction: a renderer-neutral interaction store.

  • @fungi.computer/whistle/catalog: parses a backend's whistle.catalog/v1 command catalog and turns it into commands.

  • @fungi.computer/whistle/html: binds command projections to a DOM keymap. HTML users install the optional peer @opentui/[email protected]; core users do not need it.

Quick start

This creates a runtime, contributes one command to the palette and slash surfaces, reads the snapshot, and runs the command:

import { createWhistle } from "@fungi.computer/whistle";

const whistle = createWhistle();

const lease = whistle.contribute({
  sourceId: "editor",
  namespace: "editor",
  commands: [
    {
      id: "save",
      title: "Save file",
      category: "File",
      projections: {
        palette: {},
        slash: { aliases: ["save"] },
        bindings: { values: ["mod+s"] },
      },
      handler: () => ({ saved: true }),
    },
  ],
});

const snapshot = whistle.snapshot();
console.log(snapshot.palette.map((row) => row.command)); // [{ namespace: "editor", id: "save" }]
console.log(snapshot.slash.map((row) => row.alias)); // ["save"]

const outcome = await whistle.execute(
  { namespace: "editor", id: "save" },
  { origin: "palette" },
);
if (outcome.status === "handled") console.log(outcome.result); // { saved: true }

lease.dispose();
whistle.close();

A command is named by its typed key, { namespace, id }. Every projection row carries that key as command, so a palette click, a key press and a slash command all call the same execute with it. A row's commandId is an opaque render key Whistle mints: use it for React keys or dedupe, never build or parse it. Namespaces may contain colons (an App surface such as installation:inkcap); command ids may not.

Bind a browser keyboard

npm install @opentui/[email protected]
import { createWhistle } from "@fungi.computer/whistle";
import { bindHtmlKeymap } from "@fungi.computer/whistle/html";

const whistle = createWhistle();
whistle.contribute({
  sourceId: "browser",
  namespace: "browser",
  commands: [
    {
      id: "save",
      title: "Save",
      category: "File",
      projections: { bindings: { values: ["mod+s"] } },
      handler: () => ({ saved: true }),
    },
  ],
});
const keyboard = bindHtmlKeymap(document.body, whistle, "keyboard");
// Contributions and availability updates refresh the projected key layer.
keyboard.dispose();
whistle.close();

dispose removes the Whistle layer and subscription, and can be called twice. The underlying HTML keymap host follows the document's lifetime; this helper does not remove its DOM listeners. Commands still execute through Whistle.

Core concepts

Commands, contributions and leases

A command has an id, a title, a category, an optional description, a handler, and optional projections, action and availability. A command shows up only on the surfaces its projections name.

contribute admits a batch of commands from one sourceId into one namespace, optionally inside a focus scope such as one open window. Admission is atomic: the whole contribution is validated before any of it goes live. It throws when a namespace already belongs to another source, when a canonical ID or a slash alias in the same scope repeats, or when a field is blank.

Each contribution returns a lease for that exact generation. lease.update swaps its commands atomically. lease.dispose removes it. Disposing a stale lease never removes a replacement. Both calls are idempotent.

Snapshots and projections

snapshot() returns six frozen arrays: bindings, palette, slash, help, menu and agent. Rows are immutable copies. Each surface sorts by its explicit order, then by command ID in UTF-8 byte order, so the order never depends on insertion. A snapshot keeps its identity until the graph or some availability changes. subscribe(listener) tells you when the graph changes.

The agent view drops presentation metadata and keeps the semantic inputSchema and outputSchema, so an agent gets a tool description and never a UI layout. scopeSnapshot(snapshot, scope) gives the human and agent view for one focused scope: global rows plus that scope's rows, with a focused slash alias or binding shadowing the same global one. Agent rows carry their scope and follow the same filter. The complete runtime snapshot still lists all scopes.

Execution

execute(command, input) is the only way a handler runs. input.origin is one of eight closed origins: agent, browser, keyboard, menu, palette, programmatic, slash and terminal. The outcome is one of:

  • handled: the handler finished. Its return value is in result. This does not mean physical work or a durable workflow completed.
  • unavailable: the command's availability() said no. The reason comes back.
  • failed: the handler threw. error is JSON: { code, message, details? }.
  • missing: no live command has that ID.
  • invalid: the command ID is blank.
  • closed: the runtime was closed first.

Availability

A command can supply a synchronous availability() reader over current host facts. snapshot() keeps unavailable commands visible with their reason, and execute() reads availability again right before calling the handler. Availability is a convenience check. It is not authorization, so the handler still admits the real request. The host updates its own facts and publishes snapshots over its own connection. Whistle does no polling or broadcast.

Failures

Throw WhistleActionError(code, message, details?) to choose the failure code and attach JSON details. Any other Error becomes command_failed. Stacks and non-Error thrown values never cross the execution result.

Interactions

A running command can talk to its human caller through input.interaction. prompt asks one choice, text (optionally secret) or confirmation question. notify shows info, progress, handoff (open a URL, optionally enter a code) or success. The vocabulary names the kind of answer, never a widget.

A caller can pass its own interaction. Otherwise present(presenter) registers the mounted client that opens one for each human-origin execution. Agent and programmatic executions get none. Whistle settles the session with handled, cancelled or failed when the handler ends. The newest presenter wins until its disposer runs.

createWhistleInteractionHost from ./interaction is a renderer-neutral store for one interaction. It parses every prompt, notice and answer with the ./wire schemas, and a terminal or browser renderer draws its snapshot.

After an answer or Try again, the host reuses the current progress message, or the command title when the producer supplies none, in the existing running row. This does not open a conversation for an inline operation that never asked a question or showed progress/a handoff.

interaction.run(operation) marks one serial operation as retryable. Once the conversation has asked a question or shown progress/a handoff, a failure is shown and waits for host.retry() or dismissal. Before that, it propagates normally: a failed inline action does not suddenly open a conversation. Retry repeats only that operation with its captured inputs; the original handler stays suspended, so successful earlier steps and questions are not replayed. Dismissal aborts that wait. Failures outside run end the handler normally and do not advertise a whole-command replay fallback. Notification-only interactions execute run once and propagate failure, without an interactive retry wait.

Slash arguments

A slash projection can carry arguments, a data-only grammar for the text after the alias. Fixed words map to complete argument objects. text puts the rest of the line into one field of a fixed argument object, optionally with a request ID field for idempotent retries. Clients parse, complete and hint from this data, so no client names a command in code.

Catalogs

A backend advertises its commands as a whistle.catalog/v1 catalog of the same data-only definitions. parseCatalog validates it at the client boundary. catalogCommands(catalog, invoke) turns it into commands whose handlers call your invoke function. Each reply is a step: done (optional notice and result) or prompt (one question plus the complete arguments of the next call, with the answer going into a named field). Multi-step flows stay stateless on the wire and run through the caller's interaction. Each catalog invocation is one retryable operation. Non-secret answers and request identity are retained; secret answers are not. The continuation keeps the secret questions instead, asks them inside each attempt, and removes their values from the invocation payload as soon as it settles, whether it succeeds or fails. Retry re-asks only those secret questions, not earlier non-secret choices. If a later backend step still requires a secret, it must be entered again for that invocation too.

Closing

close() is synchronous and idempotent and returns undefined. The first call releases every live contribution and leaves the shared empty snapshot. Leases issued earlier become harmless. An execute Promise returned before close still settles, and may settle handled. A later execute returns closed, and a later valid contribute throws WhistleClosedError.

Typing a reusable handler

Use indexed access on WhistleCommand to declare a handler apart from a command literal:

import type { WhistleCommand } from "@fungi.computer/whistle";

type SaveArguments = Readonly<{ path: string }>;
type SaveResult = Readonly<{ saved: string }>;

const save: WhistleCommand<SaveArguments, SaveResult>["handler"] = ({
  arguments: input,
}) => (input === undefined ? undefined : { saved: input.path });

The handler receives WhistleExecutionInput<SaveArguments>: the origin, optional arguments, a cancellation signal and an optional interaction. It returns SaveResult, undefined, or a Promise of either. WhistleCommandHandler names this callback type. Commands with different argument and result types can share one readonly WhistleCommand<never>[] contribution. Contributions erase those generics at runtime, so the handler still validates the arguments it actually gets.

Presentation hints

action.inputSchema and action.outputSchema describe structured data and keep opaque JSON annotations. action.presentation is an optional local hint: action, choice, form, confirmation, or custom with opaque JSON for an owning client's own field or gesture bindings. A server capability must not prescribe its form, menu or other rendering. Human projections keep the hint. The agent projection drops it.

API reference

See the generated API reference. It covers the root entrypoint and the wire, interaction and catalog subpaths.

Examples

Show a command as unavailable

import { createWhistle } from "@fungi.computer/whistle";

let signedIn = false;
const whistle = createWhistle();
whistle.contribute({
  sourceId: "account",
  namespace: "account",
  commands: [
    {
      id: "sync",
      title: "Sync now",
      category: "Account",
      projections: { palette: {}, agent: {} },
      availability: () =>
        signedIn
          ? { status: "available" }
          : { status: "unavailable", reason: "Sign in first." },
      handler: () => undefined,
    },
  ],
});

console.log(whistle.snapshot().palette[0]?.availability);
// { status: "unavailable", reason: "Sign in first." }
console.log(
  await whistle.execute(
    { namespace: "account", id: "sync" },
    { origin: "agent" },
  ),
);
// { status: "unavailable", reason: "Sign in first." }
signedIn = true;

Fail with a code

import { WhistleActionError, createWhistle } from "@fungi.computer/whistle";

const whistle = createWhistle();
whistle.contribute({
  sourceId: "files",
  namespace: "files",
  commands: [
    {
      id: "open",
      title: "Open file",
      category: "File",
      handler: () => {
        throw new WhistleActionError("not_found", "No such file", {
          path: "a.txt",
        });
      },
    },
  ],
});

const outcome = await whistle.execute(
  { namespace: "files", id: "open" },
  { origin: "terminal" },
);
// { status: "failed", error: { code: "not_found", message: "No such file", details: { path: "a.txt" } } }

Load a backend catalog

import { createWhistle } from "@fungi.computer/whistle";
import { catalogCommands, parseCatalog } from "@fungi.computer/whistle/catalog";

const catalog = parseCatalog(await (await fetch("/commands")).json());
const whistle = createWhistle();
whistle.contribute({
  sourceId: "backend",
  namespace: "backend",
  commands: catalogCommands(catalog, async (commandId, args, signal) => {
    const response = await fetch(`/commands/${commandId}`, {
      method: "POST",
      body: JSON.stringify(args),
      signal,
    });
    return response.json();
  }),
});

Checks

From a checkout, the unit tests cover the runtime, wire schemas, interaction host and catalog:

pnpm --filter @fungi.computer/whistle test

Limits and errors

  • A runtime holds at most 64 live contributions and 256 live commands.
  • A command has at most 16 binding values and 16 slash aliases. A slash grammar has at most 32 words.
  • A catalog has at most 128 commands, and a catalog flow stops after 16 steps with flow_too_long. A catalog command that needs a prompt fails with interaction_required when there is no interaction, which is always the case for agent and programmatic origins.
  • Prompt and notice text is capped at 8192 characters. A choice prompt has 1 to 256 options. Notice URLs must be http or https.
  • Namespaces must not contain a colon, and command IDs, titles, categories and labels must not be blank. Violations throw a plain Error from contribute or lease.update.
  • These bounds are defenses inside the implementation. They are not stable API promises.

Whistle does not parse key presses or slash input from raw text, render UI, run transport, or keep durable delivery state. It owns no browser, terminal, DOM, React, product or authorization state, and has no timers or background work. Use the ./wire parsers on untrusted input before it becomes a command, prompt or notice.

Contribute and license

MIT. See LICENSE.

版本与发布记录

@fungi.computer/whistle 共发布 6 个版本,首次发布于 2026/10/15;过去 12 个月发布了 6 个版本。

版本发布日期距今
0.3.0latest2026/10/2818 天前
0.2.02026/10/2719 天前
0.1.02026/10/2323 天前
0.0.22026/10/1729 天前
0.0.12026/10/151 个月前
0.0.0-stage2026/10/151 个月前

关于 @fungi.computer/whistle 的常见问题

@fungi.computer/whistle 是什么?

Semantic command graph, projections and wire parsing

如何安装 @fungi.computer/whistle?

在项目目录运行 npm install @fungi.computer/whistle,或使用 pnpm add / yarn add / bun add。

@fungi.computer/whistle 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@fungi.computer/whistle 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@fungi.computer/whistle 可以商用吗?

@fungi.computer/whistle 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@fungi.computer/whistle 的下载量有多少?

根据 npm 官方下载统计,@fungi.computer/whistle 在 2026/10/16 – 2026/11/14 这 30 天内被下载 17,576 次,日均约 586 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@fungi.computer/whistle 还在维护吗?

最新版本 0.3.0 发布于 2026/10/28(18 天前);至今共发布 6 个版本,其中过去 12 个月发布了 6 个;首次发布于 2026/10/15。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。