@fungi.computer/parakeet 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.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +186 -2
  3. package/dist/account-commands.d.ts +102 -0
  4. package/dist/account-commands.js +239 -0
  5. package/dist/agent-head.d.ts +4 -0
  6. package/dist/agent-head.js +2 -0
  7. package/dist/client-contributions.d.ts +20 -0
  8. package/dist/client-contributions.js +41 -0
  9. package/dist/commands/advertised.d.ts +23 -0
  10. package/dist/commands/advertised.js +72 -0
  11. package/dist/commands/builtins.d.ts +14 -0
  12. package/dist/commands/builtins.js +91 -0
  13. package/dist/commands/hints.d.ts +36 -0
  14. package/dist/commands/hints.js +64 -0
  15. package/dist/commands/interactions.d.ts +55 -0
  16. package/dist/commands/interactions.js +101 -0
  17. package/dist/commands/run.d.ts +11 -0
  18. package/dist/commands/run.js +61 -0
  19. package/dist/commands/slash.d.ts +124 -0
  20. package/dist/commands/slash.js +182 -0
  21. package/dist/commands/use-slash-commands.d.ts +57 -0
  22. package/dist/commands/use-slash-commands.js +207 -0
  23. package/dist/components/AgentHead.d.ts +27 -0
  24. package/dist/components/AgentHead.js +53 -0
  25. package/dist/components/ChatComposer.d.ts +43 -0
  26. package/dist/components/ChatComposer.js +209 -0
  27. package/dist/components/ChatMessage.d.ts +8 -0
  28. package/dist/components/ChatMessage.js +16 -0
  29. package/dist/components/ClientRows.d.ts +11 -0
  30. package/dist/components/ClientRows.js +46 -0
  31. package/dist/components/CodeBlock.d.ts +11 -0
  32. package/dist/components/CodeBlock.js +38 -0
  33. package/dist/components/InteractionPanel.d.ts +15 -0
  34. package/dist/components/InteractionPanel.js +42 -0
  35. package/dist/components/MarkdownRenderer.d.ts +6 -0
  36. package/dist/components/MarkdownRenderer.js +48 -0
  37. package/dist/components/RunFailureNotice.d.ts +7 -0
  38. package/dist/components/RunFailureNotice.js +13 -0
  39. package/dist/components/ShiitakeAvatar.d.ts +19 -0
  40. package/dist/components/ShiitakeAvatar.js +20 -0
  41. package/dist/components/SlashMenu.d.ts +8 -0
  42. package/dist/components/SlashMenu.js +50 -0
  43. package/dist/components/ThinkingBlock.d.ts +3 -0
  44. package/dist/components/ThinkingBlock.js +18 -0
  45. package/dist/components/TimelineItem.d.ts +15 -0
  46. package/dist/components/TimelineItem.js +35 -0
  47. package/dist/components/ToolGroup.d.ts +12 -0
  48. package/dist/components/ToolGroup.js +90 -0
  49. package/dist/components/WaitingNotice.d.ts +7 -0
  50. package/dist/components/WaitingNotice.js +8 -0
  51. package/dist/components/avatar-presentation.d.ts +23 -0
  52. package/dist/components/avatar-presentation.js +16 -0
  53. package/dist/components/sprite-service.d.ts +2 -0
  54. package/dist/components/sprite-service.js +9 -0
  55. package/dist/components/still-frame-cache.d.ts +16 -0
  56. package/dist/components/still-frame-cache.js +63 -0
  57. package/dist/conversation.d.ts +164 -0
  58. package/dist/conversation.js +760 -0
  59. package/dist/core/assistant-visibility.d.ts +3 -0
  60. package/dist/core/assistant-visibility.js +4 -0
  61. package/dist/core/author-labels.d.ts +16 -0
  62. package/dist/core/author-labels.js +16 -0
  63. package/dist/core/build-timeline.d.ts +36 -0
  64. package/dist/core/build-timeline.js +328 -0
  65. package/dist/core/client-actions.d.ts +35 -0
  66. package/dist/core/client-actions.js +128 -0
  67. package/dist/core/client-contributions.d.ts +24 -0
  68. package/dist/core/client-contributions.js +115 -0
  69. package/dist/core/client-timeline.d.ts +33 -0
  70. package/dist/core/client-timeline.js +72 -0
  71. package/dist/core/format-context.d.ts +11 -0
  72. package/dist/core/format-context.js +20 -0
  73. package/dist/core/format-tokens.d.ts +2 -0
  74. package/dist/core/format-tokens.js +6 -0
  75. package/dist/core/model-identity.d.ts +3 -0
  76. package/dist/core/model-identity.js +7 -0
  77. package/dist/core/session-projection.d.ts +11 -0
  78. package/dist/core/session-projection.js +191 -0
  79. package/dist/core/session-query.d.ts +118 -0
  80. package/dist/core/session-query.js +187 -0
  81. package/dist/core/session-view-state.d.ts +66 -0
  82. package/dist/core/session-view-state.js +114 -0
  83. package/dist/core/tool-contract.d.ts +15 -0
  84. package/dist/core/tool-contract.js +3 -0
  85. package/dist/core/tool-groups.d.ts +36 -0
  86. package/dist/core/tool-groups.js +151 -0
  87. package/dist/core/tool-view.d.ts +182 -0
  88. package/dist/core/tool-view.js +618 -0
  89. package/dist/headless.d.ts +28 -0
  90. package/dist/headless.js +24 -0
  91. package/dist/index.d.ts +9 -0
  92. package/dist/index.js +4 -0
  93. package/dist/lib/failure.d.ts +2 -0
  94. package/dist/lib/failure.js +9 -0
  95. package/dist/markdown.d.ts +3 -0
  96. package/dist/markdown.js +6 -0
  97. package/dist/model-commands.d.ts +44 -0
  98. package/dist/model-commands.js +232 -0
  99. package/dist/parakeet.d.ts +77 -0
  100. package/dist/parakeet.js +276 -0
  101. package/dist/provider-choices.d.ts +8 -0
  102. package/dist/provider-choices.js +43 -0
  103. package/dist/reload-command.d.ts +43 -0
  104. package/dist/reload-command.js +72 -0
  105. package/dist/session-commands.d.ts +53 -0
  106. package/dist/session-commands.js +259 -0
  107. package/dist/session.d.ts +24 -0
  108. package/dist/session.js +1 -0
  109. package/dist/store/types.d.ts +163 -0
  110. package/dist/store/types.js +1 -0
  111. package/dist/styles.css +19156 -0
  112. package/docs/api/README.md +15 -0
  113. package/docs/api/commands/slash.md +321 -0
  114. package/docs/api/headless.md +2510 -0
  115. package/docs/api/index.md +1253 -0
  116. package/package.json +79 -3
  117. package/src/styles.css +268 -0
@@ -0,0 +1,72 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { catalogCommands, parseCatalog } from "@fungi.computer/whistle/catalog";
3
+ const cancelled = Schema.is(Schema.Struct({ cancelled: Schema.Literal(true) }));
4
+ /** A bare invocation views; one carrying action arguments (`/plan clear`) acts. */
5
+ const bare = Schema.is(Schema.UndefinedOr(Schema.Record(Schema.String, Schema.Never)));
6
+ /**
7
+ * Turn each running extension's advertised `whistle.catalog/v1` catalog into
8
+ * Whistle commands that call that extension's own door. The client names no
9
+ * command: a new extension's catalog appears as new commands.
10
+ */
11
+ export function advertisedCommands(runtime, session, onInspect) {
12
+ const live = new Map();
13
+ const withdraw = (id) => {
14
+ live.get(id)?.lease.dispose();
15
+ live.delete(id);
16
+ };
17
+ return {
18
+ update: (slots) => {
19
+ const errors = [];
20
+ for (const id of live.keys()) {
21
+ const slot = Object.hasOwn(slots, id) ? slots[id] : undefined;
22
+ if (slot?.status !== "running" || slot.catalog === undefined)
23
+ withdraw(id);
24
+ }
25
+ for (const [id, slot] of Object.entries(slots)) {
26
+ if (slot.status !== "running" || slot.catalog === undefined)
27
+ continue;
28
+ const encoded = JSON.stringify(slot.catalog);
29
+ if (live.get(id)?.catalog === encoded)
30
+ continue;
31
+ withdraw(id);
32
+ try {
33
+ const address = session.extension(id);
34
+ const commands = catalogCommands(parseCatalog(slot.catalog), (command, args) => address.call(command, args));
35
+ live.set(id, {
36
+ catalog: encoded,
37
+ lease: runtime.contribute({
38
+ sourceId: `extension:${id}`,
39
+ namespace: id,
40
+ commands: commands.map((command) => ({
41
+ ...command,
42
+ handler: async (input) => {
43
+ const result = await command.handler(input);
44
+ if (input.origin !== "agent" &&
45
+ bare(input.arguments) &&
46
+ !cancelled(result) &&
47
+ !input.signal?.aborted &&
48
+ live.get(id)?.catalog === encoded)
49
+ onInspect?.(id, command.id);
50
+ return result;
51
+ },
52
+ })),
53
+ }),
54
+ });
55
+ }
56
+ catch (cause) {
57
+ errors.push({
58
+ extensionId: id,
59
+ message: cause instanceof Error
60
+ ? cause.message
61
+ : "The advertised catalog is invalid",
62
+ });
63
+ }
64
+ }
65
+ return errors;
66
+ },
67
+ dispose: () => {
68
+ for (const id of live.keys())
69
+ withdraw(id);
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,14 @@
1
+ import type { WhistleContribution, WhistleRuntime } from "@fungi.computer/whistle";
2
+ /** The few commands every Parakeet client has, whatever the backend offers. */
3
+ export type ParakeetBuiltinOptions = Readonly<{
4
+ /** Clear the composer draft and any notice. */
5
+ clear: () => void;
6
+ /** Open the renderer-owned all-command palette, when one exists. */
7
+ showCommands?: (() => void | Promise<void>) | undefined;
8
+ }>;
9
+ /**
10
+ * `/help` lists the live commands (built-in and advertised) and runs the one
11
+ * chosen; `/keys` does the same for key bindings; `/clear` empties the
12
+ * composer.
13
+ */
14
+ export declare function parakeetBuiltinCommands(runtime: Pick<WhistleRuntime, "snapshot" | "execute">, options: ParakeetBuiltinOptions): WhistleContribution["commands"];
@@ -0,0 +1,91 @@
1
+ import { keyBindings, slashRegistry } from "./slash.js";
2
+ /** Run a chosen command after the choosing command has settled. */
3
+ function runAfter(runtime, commandId, origin) {
4
+ setTimeout(() => {
5
+ void runtime.execute(commandId, { origin });
6
+ }, 0);
7
+ }
8
+ /**
9
+ * `/help` lists the live commands (built-in and advertised) and runs the one
10
+ * chosen; `/keys` does the same for key bindings; `/clear` empties the
11
+ * composer.
12
+ */
13
+ export function parakeetBuiltinCommands(runtime, options) {
14
+ return [
15
+ {
16
+ id: "help",
17
+ title: "Show commands",
18
+ category: "Advanced",
19
+ projections: {
20
+ help: { order: -3 },
21
+ slash: { aliases: ["help"], order: -3 },
22
+ },
23
+ handler: async (input) => {
24
+ if (options.showCommands !== undefined) {
25
+ await options.showCommands();
26
+ return undefined;
27
+ }
28
+ const commands = slashRegistry(runtime.snapshot()).filter((command) => command.alias !== "help");
29
+ if (input.interaction === undefined)
30
+ return commands.map((command) => `/${command.alias}`);
31
+ const chosen = await input.interaction.prompt({
32
+ type: "choice",
33
+ message: "Commands",
34
+ options: commands.map((command) => ({
35
+ value: command.commandId,
36
+ label: `/${command.alias}`,
37
+ description: command.keys.length === 0
38
+ ? command.title
39
+ : `${command.title} · ${command.keys.join(", ")}`,
40
+ })),
41
+ });
42
+ runAfter(runtime, chosen, input.origin);
43
+ return undefined;
44
+ },
45
+ },
46
+ {
47
+ id: "keys",
48
+ title: "Show key bindings",
49
+ category: "Advanced",
50
+ projections: {
51
+ help: { order: -2 },
52
+ slash: { aliases: ["keys"], order: -2 },
53
+ },
54
+ handler: async (input) => {
55
+ const keys = keyBindings(runtime.snapshot());
56
+ if (input.interaction === undefined)
57
+ return keys.map((row) => `${row.keys} ${row.title}`);
58
+ if (keys.length === 0) {
59
+ input.interaction.notify({
60
+ type: "info",
61
+ message: "No key bindings are active.",
62
+ });
63
+ return undefined;
64
+ }
65
+ const chosen = await input.interaction.prompt({
66
+ type: "choice",
67
+ message: "Key bindings",
68
+ options: keys.map((row) => ({
69
+ value: row.commandId,
70
+ label: row.title,
71
+ description: row.keys,
72
+ })),
73
+ });
74
+ runAfter(runtime, chosen, input.origin);
75
+ return undefined;
76
+ },
77
+ },
78
+ {
79
+ id: "clear",
80
+ title: "Clear the composer",
81
+ category: "Advanced",
82
+ projections: {
83
+ help: { order: -1 },
84
+ slash: { aliases: ["clear"], order: -1 },
85
+ },
86
+ handler: () => {
87
+ options.clear();
88
+ },
89
+ },
90
+ ];
91
+ }
@@ -0,0 +1,36 @@
1
+ import type { KeyBindingRow, SlashCompletion } from "./slash.js";
2
+ /** One key hint: the keys, what they do, and how much it matters (0 first). */
3
+ export type KeyHint = Readonly<{
4
+ /** Key label, e.g. `Enter`. */
5
+ keys: string;
6
+ /** What the key does. */
7
+ label: string;
8
+ /** Lower numbers survive narrow widths. */
9
+ priority: number;
10
+ }>;
11
+ /** The composer's physical keys, which differ slightly between renderers. */
12
+ export type ComposerKeys = Readonly<{
13
+ /** Key that inserts a newline, e.g. `Shift+Enter`. */
14
+ newline?: string | undefined;
15
+ /** Optional transcript scroll keys, e.g. `PgUp/PgDn`. */
16
+ scroll?: string | undefined;
17
+ /** Optional key that queues a follow-up during a run, e.g. `Tab`. */
18
+ queue?: string | undefined;
19
+ }>;
20
+ /** What the composer is doing right now. */
21
+ export type ComposerHintContext = Readonly<{
22
+ /** The current slash completion. */
23
+ completion: SlashCompletion;
24
+ /** Whether a run is active (Tab queues, Esc cancels). */
25
+ busy: boolean;
26
+ /** Live Whistle key bindings. */
27
+ bindings: readonly KeyBindingRow[];
28
+ /** The renderer's composer keys. */
29
+ keys: ComposerKeys;
30
+ }>;
31
+ /** Hints for the current composer state, in display order. */
32
+ export declare function composerHints(context: ComposerHintContext): readonly KeyHint[];
33
+ /** One line of hints. */
34
+ export declare function formatHints(hints: readonly KeyHint[]): string;
35
+ /** Keep the most important hints that fit `width` characters, in order. */
36
+ export declare function fitHints(hints: readonly KeyHint[], width: number): readonly KeyHint[];
@@ -0,0 +1,64 @@
1
+ const cancelBindings = new Set(["Esc", "Ctrl+C"]);
2
+ /** Hints for the current composer state, in display order. */
3
+ export function composerHints(context) {
4
+ const { completion } = context;
5
+ if (completion.kind === "commands" && completion.items.length > 0)
6
+ return [
7
+ { keys: "↑↓", label: "choose", priority: 1 },
8
+ { keys: "Tab", label: "complete", priority: 0 },
9
+ { keys: "Enter", label: "run", priority: 0 },
10
+ { keys: "Esc", label: "dismiss", priority: 2 },
11
+ ];
12
+ if (completion.kind === "arguments")
13
+ return completion.items.length > 0
14
+ ? [
15
+ { keys: "↑↓", label: "choose", priority: 1 },
16
+ { keys: "Tab", label: "complete", priority: 0 },
17
+ { keys: "Enter", label: "run", priority: 0 },
18
+ { keys: "Esc", label: "dismiss", priority: 2 },
19
+ ]
20
+ : [
21
+ { keys: "Enter", label: "run", priority: 0 },
22
+ { keys: "Esc", label: "dismiss", priority: 2 },
23
+ ];
24
+ const hints = [{ keys: "Enter", label: "send", priority: 0 }];
25
+ if (context.busy && context.keys.queue !== undefined)
26
+ hints.push({ keys: context.keys.queue, label: "queue", priority: 0 });
27
+ if (context.keys.newline !== undefined)
28
+ hints.push({ keys: context.keys.newline, label: "newline", priority: 1 });
29
+ if (context.keys.scroll !== undefined)
30
+ hints.push({ keys: context.keys.scroll, label: "scroll", priority: 2 });
31
+ for (const binding of context.bindings) {
32
+ const cancel = binding.keys
33
+ .split(", ")
34
+ .some((key) => cancelBindings.has(key));
35
+ if (cancel && !context.busy)
36
+ continue;
37
+ hints.push({
38
+ keys: binding.keys,
39
+ label: binding.title.toLocaleLowerCase(),
40
+ priority: cancel ? 0 : 3,
41
+ });
42
+ }
43
+ hints.push({ keys: "/", label: "commands", priority: 4 });
44
+ return hints;
45
+ }
46
+ const separator = " · ";
47
+ /** One line of hints. */
48
+ export function formatHints(hints) {
49
+ return hints.map((hint) => `${hint.keys} ${hint.label}`).join(separator);
50
+ }
51
+ /** Keep the most important hints that fit `width` characters, in order. */
52
+ export function fitHints(hints, width) {
53
+ const kept = new Set();
54
+ let used = 0;
55
+ const byPriority = [...hints].sort((left, right) => left.priority - right.priority);
56
+ for (const hint of byPriority) {
57
+ const length = hint.keys.length + 1 + hint.label.length + (kept.size === 0 ? 0 : 3);
58
+ if (used + length > width)
59
+ continue;
60
+ kept.add(hint);
61
+ used += length;
62
+ }
63
+ return hints.filter((hint) => kept.has(hint));
64
+ }
@@ -0,0 +1,55 @@
1
+ import type { WhistleInteractionEnd, WhistleRuntime } from "@fungi.computer/whistle";
2
+ import { type WhistleInteractionHost } from "@fungi.computer/whistle/interaction";
3
+ /** One interaction a renderer should draw as a dialog or panel. */
4
+ export type ParakeetOpenInteraction = Readonly<{
5
+ /** Changes whenever a different interaction opens. */
6
+ key: number;
7
+ /** The renderer-neutral interaction store to draw. */
8
+ host: WhistleInteractionHost;
9
+ /** Run the same command again with the same input. */
10
+ retry: () => void;
11
+ }>;
12
+ /** A one-line result shown beside the composer instead of a dialog. */
13
+ export type ParakeetNotice = Readonly<{
14
+ /** How to color it. */
15
+ tone: "info" | "success" | "error";
16
+ /** What happened. */
17
+ message: string;
18
+ }>;
19
+ /** Everything a renderer draws for command conversations. */
20
+ export type ParakeetInteractionState = Readonly<{
21
+ /** The interaction that needs the human, when one does. */
22
+ open?: ParakeetOpenInteraction | undefined;
23
+ /** A manually opened, live panel row from an admitted client entry. */
24
+ inspection?: Readonly<{
25
+ /** Composition-root identity of the admitted client entry. */
26
+ entryId: string;
27
+ /** Stable identity of the panel row being inspected. */
28
+ rowId: string;
29
+ }> | undefined;
30
+ /** The latest one-line result. */
31
+ notice?: ParakeetNotice | undefined;
32
+ }>;
33
+ /** Parakeet's command conversation store, shared by every renderer. */
34
+ export type ParakeetInteractions = Readonly<{
35
+ /** Current state. */
36
+ snapshot: () => ParakeetInteractionState;
37
+ /** Observe changes. */
38
+ subscribe: (listener: () => void) => () => void;
39
+ /** Dismiss the open interaction, cancelling it if it is still running. */
40
+ close: () => void;
41
+ /** Open or dismiss an admitted panel inspection without starting a command. */
42
+ inspect: (inspection: ParakeetInteractionState["inspection"]) => void;
43
+ /** Replace or clear the one-line notice, starting a new command turn. */
44
+ report: (notice: ParakeetNotice | undefined) => void;
45
+ /** Become the runtime's presenter until the returned disposer runs. */
46
+ attach: (present: WhistleRuntime["present"]) => () => void;
47
+ /** How the most recent interaction ended; a dismissal is not a failure to report. */
48
+ lastEnd: () => WhistleInteractionEnd["status"] | undefined;
49
+ }>;
50
+ /**
51
+ * Own how commands talk to the human. An interaction opens only when it asks
52
+ * a question, hands off to a URL or shows progress; a command that just
53
+ * reports a result leaves a one-line notice instead of a dialog.
54
+ */
55
+ export declare function createParakeetInteractions(): ParakeetInteractions;
@@ -0,0 +1,101 @@
1
+ import { createWhistleInteractionHost, } from "@fungi.computer/whistle/interaction";
2
+ /**
3
+ * Own how commands talk to the human. An interaction opens only when it asks
4
+ * a question, hands off to a URL or shows progress; a command that just
5
+ * reports a result leaves a one-line notice instead of a dialog.
6
+ */
7
+ export function createParakeetInteractions() {
8
+ let state = {};
9
+ let sequence = 0;
10
+ let lastEnd;
11
+ const listeners = new Set();
12
+ const publish = (next) => {
13
+ state = next;
14
+ for (const listener of listeners)
15
+ listener();
16
+ };
17
+ const closeKey = (key) => {
18
+ if (state.open?.key === key)
19
+ publish({ ...state, open: undefined });
20
+ };
21
+ const presenter = ({ title, retry }) => {
22
+ const key = ++sequence;
23
+ let shown = false;
24
+ const host = createWhistleInteractionHost({
25
+ title,
26
+ onEnd: (end) => {
27
+ lastEnd = end.status;
28
+ const message = host.snapshot().message;
29
+ if (!shown) {
30
+ if (end.status === "failed")
31
+ publish({
32
+ ...state,
33
+ notice: { tone: "error", message: end.message },
34
+ });
35
+ else if (message !== undefined && message.type !== "progress")
36
+ publish({
37
+ ...state,
38
+ notice: {
39
+ tone: message.type === "success" ? "success" : "info",
40
+ message: message.message,
41
+ },
42
+ });
43
+ return;
44
+ }
45
+ const lingers = end.status === "failed" ||
46
+ (end.status === "handled" &&
47
+ message !== undefined &&
48
+ message.type !== "progress");
49
+ if (!lingers)
50
+ closeKey(key);
51
+ },
52
+ });
53
+ const unsubscribe = host.subscribe(() => {
54
+ if (shown)
55
+ return;
56
+ const current = host.snapshot();
57
+ if (current.prompt === undefined &&
58
+ current.handoff === undefined &&
59
+ current.message?.type !== "progress")
60
+ return;
61
+ shown = true;
62
+ const previous = state.open;
63
+ publish({ ...state, open: { key, host, retry }, notice: undefined });
64
+ previous?.host.cancel();
65
+ });
66
+ const session = host.begin();
67
+ return {
68
+ interaction: session.interaction,
69
+ settle: (end) => {
70
+ unsubscribe();
71
+ session.settle(end);
72
+ },
73
+ };
74
+ };
75
+ return {
76
+ snapshot: () => state,
77
+ subscribe: (listener) => {
78
+ listeners.add(listener);
79
+ return () => {
80
+ listeners.delete(listener);
81
+ };
82
+ },
83
+ close: () => {
84
+ const open = state.open;
85
+ if (open === undefined)
86
+ return;
87
+ publish({ ...state, open: undefined });
88
+ open.host.cancel();
89
+ },
90
+ inspect: (inspection) => {
91
+ publish({ ...state, inspection });
92
+ },
93
+ report: (notice) => {
94
+ // A new report starts a new turn: forget how the last interaction ended.
95
+ lastEnd = undefined;
96
+ publish({ ...state, notice });
97
+ },
98
+ attach: (present) => present(presenter),
99
+ lastEnd: () => lastEnd,
100
+ };
101
+ }
@@ -0,0 +1,11 @@
1
+ import type { WhistleExecutionOrigin, WhistleExecutionOutcome, WhistleRuntime } from "@fungi.computer/whistle";
2
+ /** The Whistle surface a composer needs to run drafts. */
3
+ export type SlashRuntime = Pick<WhistleRuntime, "snapshot" | "execute">;
4
+ /** Whether a handled command declined its input (for example a refused replacement). */
5
+ export declare function isCancelledOutcome(outcome: WhistleExecutionOutcome): boolean;
6
+ /**
7
+ * Run one composer draft: a prompt goes to `send`, a `/command` runs through
8
+ * Whistle. A grammar that asks for a request id keeps the same id across
9
+ * retries of the same draft until the command is handled.
10
+ */
11
+ export declare function runSlashDraft(draft: string, commands: SlashRuntime, send: () => void, origin: WhistleExecutionOrigin): Promise<WhistleExecutionOutcome | undefined>;
@@ -0,0 +1,61 @@
1
+ import { nanoid } from "@fungi.computer/cairn";
2
+ import { z } from "zod";
3
+ import { parseSlashDraft, slashRegistry } from "./slash.js";
4
+ const pendingByRuntime = new WeakMap();
5
+ /** Whether a handled command declined its input (for example a refused replacement). */
6
+ export function isCancelledOutcome(outcome) {
7
+ if (outcome.status !== "handled")
8
+ return false;
9
+ return z.looseObject({ cancelled: z.literal(true) }).safeParse(outcome.result)
10
+ .success;
11
+ }
12
+ /**
13
+ * Run one composer draft: a prompt goes to `send`, a `/command` runs through
14
+ * Whistle. A grammar that asks for a request id keeps the same id across
15
+ * retries of the same draft until the command is handled.
16
+ */
17
+ export async function runSlashDraft(draft, commands, send, origin) {
18
+ const parsed = parseSlashDraft(slashRegistry(commands.snapshot()), draft);
19
+ if (parsed.kind === "prompt") {
20
+ send();
21
+ return undefined;
22
+ }
23
+ if (parsed.kind === "invalid")
24
+ return {
25
+ status: "failed",
26
+ error: { code: "invalid_arguments", message: parsed.message },
27
+ };
28
+ const { command, requestIdField } = parsed;
29
+ if (parsed.arguments === undefined)
30
+ return commands.execute(command.commandId, { origin });
31
+ if (requestIdField === undefined)
32
+ return commands.execute(command.commandId, {
33
+ origin,
34
+ arguments: parsed.arguments,
35
+ });
36
+ const states = pendingByRuntime.get(commands) ?? new Map();
37
+ pendingByRuntime.set(commands, states);
38
+ const previous = states.get(command.commandId);
39
+ if (previous?.draft === draft && previous.running !== undefined)
40
+ return previous.running;
41
+ const state = {
42
+ draft,
43
+ requestId: previous?.draft === draft ? previous.requestId : nanoid(),
44
+ };
45
+ states.set(command.commandId, state);
46
+ const running = commands
47
+ .execute(command.commandId, {
48
+ origin,
49
+ arguments: { ...parsed.arguments, [requestIdField]: state.requestId },
50
+ })
51
+ .then((outcome) => {
52
+ if (states.get(command.commandId) !== state)
53
+ return outcome;
54
+ state.running = undefined;
55
+ if (outcome.status === "handled" && !isCancelledOutcome(outcome))
56
+ states.delete(command.commandId);
57
+ return outcome;
58
+ });
59
+ state.running = running;
60
+ return running;
61
+ }
@@ -0,0 +1,124 @@
1
+ import type { WhistleAvailability, WhistleJsonObject, WhistleSlashArguments, WhistleSlashWord, WhistleSnapshot } from "@fungi.computer/whistle";
2
+ /** One slash command as every Parakeet renderer lists it. */
3
+ export type SlashCommand = Readonly<{
4
+ /** The alias typed after `/`. */
5
+ alias: string;
6
+ /** Canonical Whistle command identity. */
7
+ commandId: string;
8
+ /** Human-facing title. */
9
+ title: string;
10
+ /** Optional longer explanation. */
11
+ description?: string | undefined;
12
+ /** Human-facing category. */
13
+ category: string;
14
+ /** Surface order supplied by the command owner. */
15
+ order: number;
16
+ /** The argument grammar, when the command takes text after its alias. */
17
+ grammar?: WhistleSlashArguments | undefined;
18
+ /** Key labels bound to the same command, e.g. `Ctrl+O`. */
19
+ keys: readonly string[];
20
+ /** Unavailable commands stay listed with their reason. */
21
+ availability: WhistleAvailability;
22
+ }>;
23
+ /** One key binding as help and hint surfaces list it. */
24
+ export type KeyBindingRow = Readonly<{
25
+ /** Canonical Whistle command identity. */
26
+ commandId: string;
27
+ /** Human key label, e.g. `Ctrl+O`. */
28
+ keys: string;
29
+ /** What the key does. */
30
+ title: string;
31
+ /** Surface order supplied by the command owner. */
32
+ order: number;
33
+ }>;
34
+ /** Human key labels from Whistle bindings, with the platform's modifier name. */
35
+ export declare function keyLabel(binding: string, mac?: boolean): string;
36
+ /** Every slash alias in the live graph, with the keys bound to its command. */
37
+ export declare function slashRegistry(snapshot: WhistleSnapshot): readonly SlashCommand[];
38
+ /** Every bound key in the live graph, grouped by command. */
39
+ export declare function keyBindings(snapshot: WhistleSnapshot): readonly KeyBindingRow[];
40
+ /** How one composer draft maps to a command. */
41
+ export type SlashParse = Readonly<{
42
+ /** Not a command: send it as a prompt. */
43
+ kind: "prompt";
44
+ }> | Readonly<{
45
+ /** Run this command. */
46
+ kind: "command";
47
+ /** The command. */
48
+ command: SlashCommand;
49
+ /** Arguments decoded by the command's grammar; absent for a bare alias. */
50
+ arguments?: WhistleJsonObject | undefined;
51
+ /** Field that must receive a stable per-draft request id. */
52
+ requestIdField?: string | undefined;
53
+ }> | Readonly<{
54
+ /** Refuse the draft without running anything. */
55
+ kind: "invalid";
56
+ /** Why, for the human. */
57
+ message: string;
58
+ }>;
59
+ /** Parse one draft against the registry; only a leading `/` names a command. */
60
+ export declare function parseSlashDraft(registry: readonly SlashCommand[], draft: string): SlashParse;
61
+ /** One command offered while the alias is being typed. */
62
+ export type SlashCommandItem = Readonly<{
63
+ /** The command. */
64
+ command: SlashCommand;
65
+ /** `/alias`. */
66
+ label: string;
67
+ /** Indices of `label` characters that matched the query, for emphasis. */
68
+ matches: readonly number[];
69
+ /** Draft text after accepting this item. */
70
+ insert: string;
71
+ }>;
72
+ /** One argument word offered after the alias. */
73
+ export type SlashWordItem = Readonly<{
74
+ /** The word. */
75
+ word: WhistleSlashWord;
76
+ /** The word as displayed. */
77
+ label: string;
78
+ /** Indices of `label` characters that matched the query. */
79
+ matches: readonly number[];
80
+ /** Draft text after accepting this item. */
81
+ insert: string;
82
+ }>;
83
+ /** What a composer shows for the current draft. */
84
+ export type SlashCompletion = Readonly<{
85
+ /** Nothing to complete. */
86
+ kind: "none";
87
+ }> | Readonly<{
88
+ /** Choosing a command. */
89
+ kind: "commands";
90
+ /** Text typed after `/`. */
91
+ query: string;
92
+ /** Ranked matches, best first. */
93
+ items: readonly SlashCommandItem[];
94
+ }> | Readonly<{
95
+ /** Typing a command's arguments. */
96
+ kind: "arguments";
97
+ /** The command whose arguments are being typed. */
98
+ command: SlashCommand;
99
+ /** Text typed after the alias. */
100
+ query: string;
101
+ /** Usage hint, e.g. `/goal <objective>`. */
102
+ hint: string;
103
+ /** Ranked matching words, best first. */
104
+ items: readonly SlashWordItem[];
105
+ }>;
106
+ /** How well one candidate matched a query; lower tiers and spreads rank first. */
107
+ export type FuzzyMatch = Readonly<{
108
+ /** 0 prefix, 1 word start, 2 substring, 3 in-order subsequence. */
109
+ tier: number;
110
+ /** Characters skipped within the tier (distance or gaps). */
111
+ spread: number;
112
+ /** Candidate indices that matched the query, for emphasis. */
113
+ indices: number[];
114
+ }>;
115
+ /**
116
+ * Fuzzy match: a prefix beats a word start beats a substring beats an
117
+ * in-order subsequence; tighter subsequences rank first.
118
+ */
119
+ export declare function fuzzyMatch(candidate: string, query: string): FuzzyMatch | undefined;
120
+ /** Rank completions for one draft; renderers draw exactly these rows. */
121
+ export declare function completeSlash(registry: readonly SlashCommand[], draft: string, options?: Readonly<{
122
+ /** Category offered for a bare `/`; conversations use `Conversation`. */
123
+ idleCategory?: string;
124
+ }>): SlashCompletion;