@fungi.computer/parakeet 0.0.1 → 0.2.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 (71) hide show
  1. package/README.md +45 -6
  2. package/dist/account-commands.js +3 -11
  3. package/dist/commands/advertised.d.ts +3 -1
  4. package/dist/commands/advertised.js +89 -4
  5. package/dist/commands/builtins.js +1 -1
  6. package/dist/commands/interactions.d.ts +22 -7
  7. package/dist/commands/interactions.js +41 -17
  8. package/dist/commands/run.d.ts +3 -2
  9. package/dist/commands/run.js +5 -2
  10. package/dist/commands/slash.d.ts +2 -0
  11. package/dist/commands/slash.js +13 -7
  12. package/dist/commands/use-slash-commands.js +4 -4
  13. package/dist/components/AgentHead.d.ts +9 -12
  14. package/dist/components/AgentHead.js +12 -44
  15. package/dist/components/ChatComposer.d.ts +3 -1
  16. package/dist/components/ChatComposer.js +79 -15
  17. package/dist/components/ChatMessage.d.ts +4 -0
  18. package/dist/components/ChatMessage.js +6 -2
  19. package/dist/components/ClientRows.js +2 -2
  20. package/dist/components/InteractionPanel.d.ts +2 -4
  21. package/dist/components/InteractionPanel.js +2 -2
  22. package/dist/components/ShiitakeAvatar.d.ts +7 -5
  23. package/dist/components/ShiitakeAvatar.js +6 -4
  24. package/dist/components/TimelineItem.d.ts +2 -0
  25. package/dist/components/TimelineItem.js +31 -7
  26. package/dist/components/avatar-presentation.d.ts +7 -14
  27. package/dist/components/avatar-presentation.js +6 -13
  28. package/dist/conversation-opening.d.ts +47 -0
  29. package/dist/conversation-opening.js +173 -0
  30. package/dist/conversation.d.ts +50 -5
  31. package/dist/conversation.js +587 -87
  32. package/dist/core/author-labels.d.ts +2 -0
  33. package/dist/core/build-timeline.d.ts +4 -2
  34. package/dist/core/build-timeline.js +46 -7
  35. package/dist/core/client-actions.js +6 -3
  36. package/dist/core/client-timeline.d.ts +9 -2
  37. package/dist/core/client-timeline.js +15 -4
  38. package/dist/core/draft-storage.d.ts +63 -0
  39. package/dist/core/draft-storage.js +50 -0
  40. package/dist/core/model-identity.d.ts +2 -0
  41. package/dist/core/model-identity.js +6 -0
  42. package/dist/core/queued-recall.d.ts +35 -0
  43. package/dist/core/queued-recall.js +138 -0
  44. package/dist/core/session-projection.js +17 -0
  45. package/dist/core/session-query.d.ts +54 -11
  46. package/dist/core/session-query.js +38 -12
  47. package/dist/core/tool-groups.d.ts +1 -1
  48. package/dist/core/tool-groups.js +3 -1
  49. package/dist/core/tool-view.d.ts +1 -1
  50. package/dist/core/tool-view.js +3 -2
  51. package/dist/core/use-timeline-item-key.d.ts +8 -0
  52. package/dist/core/use-timeline-item-key.js +29 -0
  53. package/dist/headless.d.ts +6 -4
  54. package/dist/headless.js +1 -1
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.js +1 -1
  57. package/dist/model-commands.js +65 -40
  58. package/dist/parakeet.js +17 -18
  59. package/dist/provider-choices.js +10 -7
  60. package/dist/session-commands.d.ts +0 -9
  61. package/dist/session-commands.js +39 -68
  62. package/dist/session.d.ts +1 -1
  63. package/dist/store/types.d.ts +10 -4
  64. package/dist/styles.css +105 -117
  65. package/docs/api/commands/slash.md +24 -21
  66. package/docs/api/headless.md +375 -232
  67. package/docs/api/index.md +168 -60
  68. package/package.json +16 -10
  69. package/src/styles.css +4 -9
  70. package/dist/components/still-frame-cache.d.ts +0 -16
  71. package/dist/components/still-frame-cache.js +0 -63
package/README.md CHANGED
@@ -126,17 +126,56 @@ It owns parsing, completion, stable request identities and interaction state.
126
126
  The browser and terminal render the same questions, choices and notices.
127
127
  `/model` and `/thinking` use that interaction path.
128
128
 
129
+ `ParakeetProvider` accepts `onToast` for transient command information. The host
130
+ renders it through its existing toast owner: the terminal's shared toast
131
+ viewport or Grove's `ui.showToast`. `ParakeetInteractions.toast` and
132
+ `subscribeToasts` expose the same renderer-neutral seam. Ordinary first-success
133
+ echoes are silent, including after a dialog completes. Progress, questions and
134
+ handoffs remain in their interaction while active; failures retain their retry
135
+ or attention UI rather than expiring as information.
136
+
137
+ After Try again, a final information or success notice stays in that
138
+ interaction's existing result row until Done/close, without an extra toast. A
139
+ producer that supplies no completion notice does not gain a fabricated result.
140
+
129
141
  Account commands are provider-neutral. The host's account owner and policy
130
142
  decide which login methods are available. The presentation layer does not gain a
131
143
  credential store or make an advertised command authorized.
132
144
 
145
+ Queued timeline rows offer Cancel through
146
+ `useTimeline().cancelQueued(commandId)`. The row disappears immediately while
147
+ the existing Session owner decides the pending-only cancellation. This never
148
+ stops the running turn or restores text to the composer. TooLate or an
149
+ unconfirmed response produces a warning and restores any row still reported as
150
+ queued; an accepted response stays hidden through stale queued snapshots until
151
+ the owner projection catches up. Only one queued gesture waits for its owner at
152
+ a time, and switching conversations discards its presentation state. Recall
153
+ remains a separate restore-to-composer gesture.
154
+
133
155
  The composer reconciles optimistic prompts with owner facts by request identity.
134
- Retrying a failed admission preserves its request identity. Retrying failed or
135
- interrupted durable work submits a new request identity. Failed observation
136
- shows connection failure; reconnecting does not prove pending work failed or
137
- resubmit it automatically. Typed command errors remain visible and retryable.
138
- The host remains responsible for the Session transport, current permissions and
139
- operator diagnostics.
156
+ Submitting clears the draft immediately and adds a faded pending message.
157
+ Acceptance restores normal emphasis; owner-queued messages remain faded. If
158
+ admission fails, the exact submitted text returns to the composer and the host
159
+ receives an error toast. Any newer draft stays first, followed by a blank line
160
+ and the restored text; acceptance never clears newer typing. An uncertain
161
+ admission retains its request identity while the owner resolves it. An accepted
162
+ resolution confirms the admitted command or recorded entry; only confirmed
163
+ withdrawal restores the draft and permits retry with a new request identity,
164
+ because the old identity is durably fenced. Retrying failed or interrupted
165
+ durable work also submits a new request identity, preserving the failed prompt's
166
+ text and image cargo, including image-only prompts after live-page eviction. It
167
+ does not consume a newer unsent draft. Failed observation shows connection
168
+ failure; reconnecting does not prove pending work failed or resubmit it
169
+ automatically. Typed command errors remain visible and retryable. The host
170
+ remains responsible for the Session transport, current permissions and operator
171
+ diagnostics.
172
+
173
+ An owner's accepted withdrawal receipt can name a run-free recorded entry:
174
+ `receipt.commandId === message.id` even though `message.commandId === null`.
175
+ That receipt confirms durable recording, not cancellation or model admission. A
176
+ translating host retains the original composer identity in its durable metadata
177
+ and projects it as `messages[].requestId` or `pending.prompts[].requestId`; its
178
+ internal admission key is not that identity.
140
179
 
141
180
  ## API and examples
142
181
 
@@ -91,17 +91,13 @@ export async function signInProvider(owner, provider, interaction, request = {})
91
91
  type: "progress",
92
92
  message: `Signing in to ${provider.name}…`,
93
93
  });
94
- await owner.login(request.option === undefined
94
+ await interaction.run(() => owner.login(request.option === undefined
95
95
  ? { providerId: provider.id, method: method.kind }
96
96
  : {
97
97
  providerId: provider.id,
98
98
  method: method.kind,
99
99
  option: request.option,
100
- }, interaction);
101
- interaction.notify({
102
- type: "success",
103
- message: `Signed in to ${provider.name}.`,
104
- });
100
+ }, interaction));
105
101
  }
106
102
  async function signOutProvider(owner, provider, interaction) {
107
103
  const confirmed = await interaction.prompt({
@@ -112,11 +108,7 @@ async function signOutProvider(owner, provider, interaction) {
112
108
  });
113
109
  if (!confirmed)
114
110
  return;
115
- await owner.logout(provider.id, interaction);
116
- interaction.notify({
117
- type: "success",
118
- message: `Signed out of ${provider.name}.`,
119
- });
111
+ await interaction.run(() => owner.logout(provider.id, interaction));
120
112
  }
121
113
  /** Cancelling a sign-in is an ordinary ending, not a failure. */
122
114
  async function unlessCancelled(interaction, work) {
@@ -1,6 +1,7 @@
1
1
  import type { SessionExtensionSlots } from "@fungi.computer/shiitake/client";
2
2
  import type { WhistleRuntime } from "@fungi.computer/whistle";
3
3
  import type { ParakeetSession } from "../session.js";
4
+ import type { ClientContribution } from "../core/client-contributions.js";
4
5
  /** Why one advertised catalog could not become commands. */
5
6
  export type AdvertisedCatalogError = Readonly<{
6
7
  /** The extension that advertised it. */
@@ -19,5 +20,6 @@ export type AdvertisedCommands = Readonly<{
19
20
  * Turn each running extension's advertised `whistle.catalog/v1` catalog into
20
21
  * Whistle commands that call that extension's own door. The client names no
21
22
  * command: a new extension's catalog appears as new commands.
23
+ * @param contributions - Current admitted panels that own unanswered prompts.
22
24
  */
23
- export declare function advertisedCommands(runtime: Pick<WhistleRuntime, "contribute">, session: Pick<ParakeetSession, "extension">, onInspect?: (entryId: string, commandId: string) => void): AdvertisedCommands;
25
+ export declare function advertisedCommands(runtime: Pick<WhistleRuntime, "contribute">, session: Pick<ParakeetSession, "extension">, contributions: () => readonly ClientContribution[], onInspect?: (entryId: string, commandId: string) => void): AdvertisedCommands;
@@ -1,5 +1,5 @@
1
1
  import * as Schema from "effect/Schema";
2
- import { catalogCommands, parseCatalog } from "@fungi.computer/whistle/catalog";
2
+ import { catalogCommands, parseCatalog, parseCatalogStep, } from "@fungi.computer/whistle/catalog";
3
3
  const cancelled = Schema.is(Schema.Struct({ cancelled: Schema.Literal(true) }));
4
4
  /** A bare invocation views; one carrying action arguments (`/plan clear`) acts. */
5
5
  const bare = Schema.is(Schema.UndefinedOr(Schema.Record(Schema.String, Schema.Never)));
@@ -7,9 +7,28 @@ const bare = Schema.is(Schema.UndefinedOr(Schema.Record(Schema.String, Schema.Ne
7
7
  * Turn each running extension's advertised `whistle.catalog/v1` catalog into
8
8
  * Whistle commands that call that extension's own door. The client names no
9
9
  * command: a new extension's catalog appears as new commands.
10
+ * @param contributions - Current admitted panels that own unanswered prompts.
10
11
  */
11
- export function advertisedCommands(runtime, session, onInspect) {
12
+ export function advertisedCommands(runtime, session, contributions, onInspect) {
12
13
  const live = new Map();
14
+ const panelActions = (id, command) => contributions()
15
+ .find((entry) => entry.entryId === id)
16
+ ?.value.panel.flatMap((row) => row.actions ?? [])
17
+ .filter((action) => action.id === command) ?? [];
18
+ const intent = (action) => JSON.stringify([action.id, action.input ?? {}]);
19
+ // A prompt borrows its panel intent, not the entire backend operation.
20
+ // Once answered, its pending response must retain its ordinary lifetime.
21
+ const prompts = new Set();
22
+ const refreshPrompts = () => {
23
+ for (const prompt of prompts) {
24
+ const visible = new Set(panelActions(prompt.entryId, prompt.commandId).map(intent));
25
+ if (live.has(prompt.entryId) &&
26
+ prompt.intents.every((id) => visible.has(id)))
27
+ continue;
28
+ prompt.execution.withdrawn = true;
29
+ prompt.controller.abort();
30
+ }
31
+ };
13
32
  const withdraw = (id) => {
14
33
  live.get(id)?.lease.dispose();
15
34
  live.delete(id);
@@ -31,7 +50,8 @@ export function advertisedCommands(runtime, session, onInspect) {
31
50
  withdraw(id);
32
51
  try {
33
52
  const address = session.extension(id);
34
- const commands = catalogCommands(parseCatalog(slot.catalog), (command, args) => address.call(command, args));
53
+ const catalog = parseCatalog(slot.catalog);
54
+ const commands = catalogCommands(catalog, (command, args) => address.call(command, args));
35
55
  live.set(id, {
36
56
  catalog: encoded,
37
57
  lease: runtime.contribute({
@@ -40,7 +60,70 @@ export function advertisedCommands(runtime, session, onInspect) {
40
60
  commands: commands.map((command) => ({
41
61
  ...command,
42
62
  handler: async (input) => {
43
- const result = await command.handler(input);
63
+ const actions = panelActions(id, command.id);
64
+ const execution = {
65
+ intents: actions.map(intent),
66
+ withdrawn: false,
67
+ };
68
+ // Invocation-local closures retain admitted identities only.
69
+ // A caller may share its signal and pass secret arguments.
70
+ const scopedCommand = catalogCommands(catalog, async (name, args) => {
71
+ const reply = await address.call(name, args);
72
+ const step = parseCatalogStep(reply);
73
+ if (step.type === "prompt") {
74
+ const selected = actions.filter((action) => Object.entries(action.input ?? {}).every(([key, value]) => JSON.stringify(Object.hasOwn(step.next.arguments, key)
75
+ ? step.next.arguments[key]
76
+ : args[key]) === JSON.stringify(value)));
77
+ // Before selection, a chooser borrows all its candidates.
78
+ execution.intents = (selected.length > 0 ? selected : actions).map(intent);
79
+ }
80
+ return reply;
81
+ }).find((candidate) => candidate.id === command.id);
82
+ if (scopedCommand === undefined)
83
+ throw new Error("The advertised command is unavailable");
84
+ let scoped = input;
85
+ const interaction = input.interaction;
86
+ if (interaction !== undefined) {
87
+ const bound = {
88
+ ...interaction,
89
+ prompt: (request, options) => {
90
+ const prompt = {
91
+ entryId: id,
92
+ commandId: command.id,
93
+ intents: execution.intents,
94
+ execution,
95
+ controller: new AbortController(),
96
+ };
97
+ prompts.add(prompt);
98
+ refreshPrompts();
99
+ return interaction
100
+ .prompt(request, {
101
+ signal: options?.signal === undefined
102
+ ? prompt.controller.signal
103
+ : AbortSignal.any([
104
+ options.signal,
105
+ prompt.controller.signal,
106
+ ]),
107
+ })
108
+ .finally(() => prompts.delete(prompt));
109
+ },
110
+ };
111
+ scoped = { ...scoped, interaction: bound };
112
+ }
113
+ let result;
114
+ try {
115
+ result = await scopedCommand.handler(scoped);
116
+ }
117
+ catch (cause) {
118
+ if (execution.withdrawn)
119
+ return { cancelled: true };
120
+ throw cause;
121
+ }
122
+ finally {
123
+ for (const prompt of prompts)
124
+ if (prompt.execution === execution)
125
+ prompts.delete(prompt);
126
+ }
44
127
  if (input.origin !== "agent" &&
45
128
  bare(input.arguments) &&
46
129
  !cancelled(result) &&
@@ -62,11 +145,13 @@ export function advertisedCommands(runtime, session, onInspect) {
62
145
  });
63
146
  }
64
147
  }
148
+ refreshPrompts();
65
149
  return errors;
66
150
  },
67
151
  dispose: () => {
68
152
  for (const id of live.keys())
69
153
  withdraw(id);
154
+ refreshPrompts();
70
155
  },
71
156
  };
72
157
  }
@@ -49,7 +49,7 @@ export function parakeetBuiltinCommands(runtime, options) {
49
49
  category: "Advanced",
50
50
  projections: {
51
51
  help: { order: -2 },
52
- slash: { aliases: ["keys"], order: -2 },
52
+ slash: { aliases: ["keys"], order: -2, consumesDraft: true },
53
53
  },
54
54
  handler: async (input) => {
55
55
  const keys = keyBindings(runtime.snapshot());
@@ -6,13 +6,24 @@ export type ParakeetOpenInteraction = Readonly<{
6
6
  key: number;
7
7
  /** The renderer-neutral interaction store to draw. */
8
8
  host: WhistleInteractionHost;
9
- /** Run the same command again with the same input. */
10
- retry: () => void;
11
9
  }>;
12
- /** A one-line result shown beside the composer instead of a dialog. */
10
+ /**
11
+ * An attention item shown beside the composer until it is resolved: something
12
+ * failed or waits on the person. Everything else is a toast.
13
+ */
13
14
  export type ParakeetNotice = Readonly<{
14
15
  /** How to color it. */
15
- tone: "info" | "success" | "error";
16
+ tone: "info" | "error";
17
+ /** What happened. */
18
+ message: string;
19
+ }>;
20
+ /**
21
+ * A transient message a renderer shows as a toast. A command's confirmation of
22
+ * what the person just did is never one: it is an echo and is not announced.
23
+ */
24
+ export type ParakeetToast = Readonly<{
25
+ /** How to color it. */
26
+ variant: "info" | "warning" | "error";
16
27
  /** What happened. */
17
28
  message: string;
18
29
  }>;
@@ -27,7 +38,7 @@ export type ParakeetInteractionState = Readonly<{
27
38
  /** Stable identity of the panel row being inspected. */
28
39
  rowId: string;
29
40
  }> | undefined;
30
- /** The latest one-line result. */
41
+ /** The latest attention item: it stays until the person resolves it. */
31
42
  notice?: ParakeetNotice | undefined;
32
43
  }>;
33
44
  /** Parakeet's command conversation store, shared by every renderer. */
@@ -40,8 +51,12 @@ export type ParakeetInteractions = Readonly<{
40
51
  close: () => void;
41
52
  /** Open or dismiss an admitted panel inspection without starting a command. */
42
53
  inspect: (inspection: ParakeetInteractionState["inspection"]) => void;
43
- /** Replace or clear the one-line notice, starting a new command turn. */
54
+ /** Replace or clear the attention notice, starting a new command turn. */
44
55
  report: (notice: ParakeetNotice | undefined) => void;
56
+ /** Announce a transient message to every toast subscriber. */
57
+ toast: (toast: ParakeetToast) => void;
58
+ /** Receive transient messages; the renderer decides how to show them. */
59
+ subscribeToasts: (listener: (toast: ParakeetToast) => void) => () => void;
45
60
  /** Become the runtime's presenter until the returned disposer runs. */
46
61
  attach: (present: WhistleRuntime["present"]) => () => void;
47
62
  /** How the most recent interaction ended; a dismissal is not a failure to report. */
@@ -50,6 +65,6 @@ export type ParakeetInteractions = Readonly<{
50
65
  /**
51
66
  * Own how commands talk to the human. An interaction opens only when it asks
52
67
  * 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.
68
+ * reports information emits a toast; ordinary success echoes are silent.
54
69
  */
55
70
  export declare function createParakeetInteractions(): ParakeetInteractions;
@@ -2,13 +2,23 @@ import { createWhistleInteractionHost, } from "@fungi.computer/whistle/interacti
2
2
  /**
3
3
  * Own how commands talk to the human. An interaction opens only when it asks
4
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.
5
+ * reports information emits a toast; ordinary success echoes are silent.
6
6
  */
7
7
  export function createParakeetInteractions() {
8
8
  let state = {};
9
9
  let sequence = 0;
10
10
  let lastEnd;
11
11
  const listeners = new Set();
12
+ const toastListeners = new Set();
13
+ const toast = (next) => {
14
+ for (const listener of toastListeners)
15
+ listener(next);
16
+ };
17
+ /** Information is a toast; a success only echoes what the person did. */
18
+ const announce = (message) => {
19
+ if (message.type === "info")
20
+ toast({ variant: "info", message: message.message });
21
+ };
12
22
  const publish = (next) => {
13
23
  state = next;
14
24
  for (const listener of listeners)
@@ -18,9 +28,10 @@ export function createParakeetInteractions() {
18
28
  if (state.open?.key === key)
19
29
  publish({ ...state, open: undefined });
20
30
  };
21
- const presenter = ({ title, retry }) => {
31
+ const presenter = ({ title }) => {
22
32
  const key = ++sequence;
23
33
  let shown = false;
34
+ let hadRetryableFailure = false;
24
35
  const host = createWhistleInteractionHost({
25
36
  title,
26
37
  onEnd: (end) => {
@@ -32,35 +43,41 @@ export function createParakeetInteractions() {
32
43
  ...state,
33
44
  notice: { tone: "error", message: end.message },
34
45
  });
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
- });
46
+ else if (message !== undefined)
47
+ announce(message);
43
48
  return;
44
49
  }
45
- const lingers = end.status === "failed" ||
46
- (end.status === "handled" &&
47
- message !== undefined &&
48
- message.type !== "progress");
49
- if (!lingers)
50
+ // A retried result stays in the same notice row until Done. A
51
+ // progress-only ending is not a completion notice to manufacture.
52
+ if (end.status === "handled" &&
53
+ hadRetryableFailure &&
54
+ message !== undefined &&
55
+ message.type !== "progress")
56
+ return;
57
+ const closing = host.snapshot().closing;
58
+ if (end.status === "cancelled" && closing !== undefined)
59
+ announce(closing);
60
+ if (end.status === "handled" && message !== undefined)
61
+ announce(message);
62
+ // Failed interactions retain their failure UI; ordinary results never
63
+ // leave a dialog behind just to confirm the completed action.
64
+ if (end.status !== "failed")
50
65
  closeKey(key);
51
66
  },
52
67
  });
53
68
  const unsubscribe = host.subscribe(() => {
69
+ const current = host.snapshot();
70
+ if (current.retryable)
71
+ hadRetryableFailure = true;
54
72
  if (shown)
55
73
  return;
56
- const current = host.snapshot();
57
74
  if (current.prompt === undefined &&
58
75
  current.handoff === undefined &&
59
76
  current.message?.type !== "progress")
60
77
  return;
61
78
  shown = true;
62
79
  const previous = state.open;
63
- publish({ ...state, open: { key, host, retry }, notice: undefined });
80
+ publish({ ...state, open: { key, host }, notice: undefined });
64
81
  previous?.host.cancel();
65
82
  });
66
83
  const session = host.begin();
@@ -95,6 +112,13 @@ export function createParakeetInteractions() {
95
112
  lastEnd = undefined;
96
113
  publish({ ...state, notice });
97
114
  },
115
+ toast,
116
+ subscribeToasts: (listener) => {
117
+ toastListeners.add(listener);
118
+ return () => {
119
+ toastListeners.delete(listener);
120
+ };
121
+ },
98
122
  attach: (present) => present(presenter),
99
123
  lastEnd: () => lastEnd,
100
124
  };
@@ -6,6 +6,7 @@ export declare function isCancelledOutcome(outcome: WhistleExecutionOutcome): bo
6
6
  /**
7
7
  * Run one composer draft: a prompt goes to `send`, a `/command` runs through
8
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.
9
+ * retries of the same draft until the command is handled. A slash projection
10
+ * can request draft consumption before dispatch, including while a dialog is open.
10
11
  */
11
- export declare function runSlashDraft(draft: string, commands: SlashRuntime, send: () => void, origin: WhistleExecutionOrigin): Promise<WhistleExecutionOutcome | undefined>;
12
+ export declare function runSlashDraft(draft: string, commands: SlashRuntime, send: () => void, origin: WhistleExecutionOrigin, consumeDraft?: () => void): Promise<WhistleExecutionOutcome | undefined>;
@@ -12,9 +12,10 @@ export function isCancelledOutcome(outcome) {
12
12
  /**
13
13
  * Run one composer draft: a prompt goes to `send`, a `/command` runs through
14
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.
15
+ * retries of the same draft until the command is handled. A slash projection
16
+ * can request draft consumption before dispatch, including while a dialog is open.
16
17
  */
17
- export async function runSlashDraft(draft, commands, send, origin) {
18
+ export async function runSlashDraft(draft, commands, send, origin, consumeDraft) {
18
19
  const parsed = parseSlashDraft(slashRegistry(commands.snapshot()), draft);
19
20
  if (parsed.kind === "prompt") {
20
21
  send();
@@ -26,6 +27,8 @@ export async function runSlashDraft(draft, commands, send, origin) {
26
27
  error: { code: "invalid_arguments", message: parsed.message },
27
28
  };
28
29
  const { command, requestIdField } = parsed;
30
+ if (command.consumesDraft && command.availability.status === "available")
31
+ consumeDraft?.();
29
32
  if (parsed.arguments === undefined)
30
33
  return commands.execute(command.commandId, { origin });
31
34
  if (requestIdField === undefined)
@@ -5,6 +5,8 @@ export type SlashCommand = Readonly<{
5
5
  alias: string;
6
6
  /** Canonical Whistle command identity. */
7
7
  commandId: string;
8
+ /** Consume this draft on dispatch, including for a dialog or refusal. */
9
+ consumesDraft?: boolean | undefined;
8
10
  /** Human-facing title. */
9
11
  title: string;
10
12
  /** Optional longer explanation. */
@@ -1,12 +1,10 @@
1
1
  /** Human key labels from Whistle bindings, with the platform's modifier name. */
2
2
  export function keyLabel(binding, mac = /Mac|iPhone|iPad/u.test(globalThis.navigator.userAgent)) {
3
+ if (binding.startsWith("<leader>"))
4
+ return `Ctrl K ${binding.slice("<leader>".length)}`;
3
5
  return binding
4
6
  .split(" ")
5
- .map((chord, index) => {
6
- if (chord === "<leader>")
7
- return "Ctrl K";
8
- if (index > 0 && binding.startsWith("<leader> "))
9
- return chord;
7
+ .map((chord) => {
10
8
  const parts = chord
11
9
  .split("+")
12
10
  .map((part) => part === "mod"
@@ -38,6 +36,7 @@ export function slashRegistry(snapshot) {
38
36
  category: row.category,
39
37
  order: row.order,
40
38
  grammar: row.arguments,
39
+ consumesDraft: row.consumesDraft,
41
40
  keys: keys.get(row.commandId) ?? [],
42
41
  availability: row.availability,
43
42
  }));
@@ -135,6 +134,7 @@ function rank(entries, text, query, order) {
135
134
  text(left.entry).localeCompare(text(right.entry)))
136
135
  .map(({ entry, match }) => ({ entry, indices: match.indices }));
137
136
  }
137
+ const commonAliases = ["help", "login", "model"];
138
138
  /** Rank completions for one draft; renderers draw exactly these rows. */
139
139
  export function completeSlash(registry, draft, options = {}) {
140
140
  if (!draft.startsWith("/") || draft.includes("\n"))
@@ -144,11 +144,17 @@ export function completeSlash(registry, draft, options = {}) {
144
144
  const query = draft.slice(1);
145
145
  const items = rank(registry.filter((command) => command.availability.status === "available" &&
146
146
  (query.length === 0
147
- ? command.category === (options.idleCategory ?? "Conversation")
147
+ ? command.category === (options.idleCategory ?? "Conversation") ||
148
+ commonAliases.includes(command.alias)
148
149
  : command.category !== "Advanced" ||
149
150
  command.alias
150
151
  .toLocaleLowerCase()
151
- .startsWith(query.toLocaleLowerCase()))), (command) => command.alias, query, (command) => command.order).map(({ entry, indices }) => ({
152
+ .startsWith(query.toLocaleLowerCase()))), (command) => command.alias, query, (command) => {
153
+ const common = commonAliases.indexOf(command.alias);
154
+ return query.length === 0 && common >= 0
155
+ ? common - commonAliases.length
156
+ : command.order;
157
+ }).map(({ entry, indices }) => ({
152
158
  command: entry,
153
159
  label: `/${entry.alias}`,
154
160
  matches: indices.map((index) => index + 1),
@@ -70,7 +70,7 @@ export function useSlashCommands(options) {
70
70
  lease.dispose();
71
71
  };
72
72
  }, [commands, enabled, store, hasCommandPalette]);
73
- const advertised = useMemo(() => advertisedCommands(commands, session, (entryId, commandId) => {
73
+ const advertised = useMemo(() => advertisedCommands(commands, session, () => contributionsRef.current, (entryId, commandId) => {
74
74
  const row = contributionsRef.current
75
75
  .find((entry) => entry.entryId === entryId)
76
76
  ?.value.panel.find((row) => row.inspection?.commandId === commandId);
@@ -91,7 +91,7 @@ export function useSlashCommands(options) {
91
91
  tone: "error",
92
92
  message: `${error.extensionId} commands are unavailable: ${error.message}`,
93
93
  });
94
- }, [advertised, enabled, slots, store]);
94
+ }, [advertised, enabled, slots, contributions, store]);
95
95
  const registry = useMemo(() => slashRegistry(snapshot), [snapshot]);
96
96
  const [dismissed, setDismissed] = useState();
97
97
  const [highlighted, setHighlighted] = useState({ draft: "", index: 0 });
@@ -123,9 +123,9 @@ export function useSlashCommands(options) {
123
123
  store.report(undefined);
124
124
  setDismissed(submitted);
125
125
  void current
126
- .submitCommand((captured) => runSlashDraft(captured, commands, () => {
126
+ .submitCommand((captured, consumeDraft) => runSlashDraft(captured, commands, () => {
127
127
  current.send(delivery);
128
- }, origin))
128
+ }, origin, consumeDraft))
129
129
  .then((outcome) => {
130
130
  const message = failureMessage(outcome);
131
131
  if (message !== undefined &&
@@ -2,26 +2,23 @@ import type { Avatar } from "@fungi.computer/sprite/avatar";
2
2
  /** What the head is doing: at rest, working something out, or talking. */
3
3
  export type AgentHeadActivity = "idle" | "thinking" | "speaking";
4
4
  /**
5
- * An Agent's head at avatar size. Renders the Agent's own avatar when given
6
- * one; otherwise the pre-avatar seeded mushroom cap, byte-identical to
7
- * before avatars existed. The letter shows until a frame is ready. It holds
8
- * still unless `thinking` or `speaking`: an idle head whose render style is
9
- * "plain" and already has a cached still frame for its look never mounts a
10
- * live canvas, so a long rail is not a wall of animation. A render style
11
- * other than "plain" is a CSS filter over the live canvas, not part of the
12
- * captured pixels, so a styled head always stays live.
5
+ * An Agent's head at avatar size, drawn from the Agent's own avatar (a
6
+ * property of the Agent record, never of a conversation). The letter shows
7
+ * until a frame is ready. The live
8
+ * canvas stays mounted and is paused unless `thinking` or `speaking`, so an
9
+ * idle head shows its real look without animating.
13
10
  */
14
11
  export declare const AgentHead: import("react").NamedExoticComponent<{
12
+ /** External animation clock in milliseconds; omitted uses live motion. */
13
+ timeMs?: number;
15
14
  /** Motion presented by the head; defaults to idle. */
16
15
  activity?: AgentHeadActivity;
17
- /** The Agent's stored avatar; omitted or `null` falls back to the seed. */
18
- avatar?: Avatar | null;
16
+ /** The Agent's avatar, from its Agent record. */
17
+ avatar: Avatar;
19
18
  /** Host classes applied to the head container. */
20
19
  className?: string;
21
20
  /** The name whose first letter stands in until a frame is ready. */
22
21
  name: string;
23
- /** Stable identity that picks the fallback cap colour. */
24
- seed: string;
25
22
  /** Width and height in pixels. */
26
23
  size?: number;
27
24
  }>;