@fungi.computer/parakeet 0.3.0 → 0.5.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 (63) hide show
  1. package/dist/account-commands.js +20 -7
  2. package/dist/client-contributions.d.ts +1 -0
  3. package/dist/commands/awaiting-driver.d.ts +20 -0
  4. package/dist/commands/awaiting-driver.js +121 -0
  5. package/dist/commands/builtins.js +6 -4
  6. package/dist/commands/interactions.d.ts +25 -0
  7. package/dist/commands/interactions.js +76 -2
  8. package/dist/commands/run.js +3 -3
  9. package/dist/commands/slash.d.ts +7 -3
  10. package/dist/commands/slash.js +2 -0
  11. package/dist/commands/use-slash-commands.d.ts +13 -0
  12. package/dist/commands/use-slash-commands.js +58 -1
  13. package/dist/components/ChatComposer.d.ts +2 -0
  14. package/dist/components/ChatComposer.js +150 -38
  15. package/dist/components/ClientRows.js +42 -26
  16. package/dist/components/CompletionMenu.d.ts +54 -0
  17. package/dist/components/CompletionMenu.js +52 -0
  18. package/dist/components/DecisionComposer.d.ts +30 -0
  19. package/dist/components/DecisionComposer.js +146 -0
  20. package/dist/components/InteractionPanel.d.ts +10 -1
  21. package/dist/components/InteractionPanel.js +24 -5
  22. package/dist/components/RunFailureNotice.js +11 -2
  23. package/dist/components/SlashMenu.d.ts +1 -3
  24. package/dist/components/SlashMenu.js +5 -13
  25. package/dist/components/TimelineItem.d.ts +1 -0
  26. package/dist/components/TimelineItem.js +3 -3
  27. package/dist/components/ToolGroup.d.ts +1 -0
  28. package/dist/components/ToolGroup.js +7 -7
  29. package/dist/conversation.d.ts +5 -1
  30. package/dist/conversation.js +72 -20
  31. package/dist/core/approval-decision.d.ts +198 -0
  32. package/dist/core/approval-decision.js +291 -0
  33. package/dist/core/awaiting-answer.d.ts +229 -0
  34. package/dist/core/awaiting-answer.js +303 -0
  35. package/dist/core/build-timeline.js +56 -5
  36. package/dist/core/client-actions.d.ts +1 -0
  37. package/dist/core/client-actions.js +8 -21
  38. package/dist/core/draft-storage.d.ts +4 -0
  39. package/dist/core/draft-storage.js +12 -5
  40. package/dist/core/picker-text.d.ts +7 -0
  41. package/dist/core/picker-text.js +24 -0
  42. package/dist/core/session-query.d.ts +8 -0
  43. package/dist/core/session-query.js +13 -11
  44. package/dist/core/session-view-state.d.ts +1 -1
  45. package/dist/core/session-view-state.js +14 -6
  46. package/dist/headless.d.ts +7 -2
  47. package/dist/headless.js +6 -1
  48. package/dist/model-commands.js +14 -15
  49. package/dist/parakeet.d.ts +6 -2
  50. package/dist/parakeet.js +15 -6
  51. package/dist/session-commands.js +6 -2
  52. package/dist/store/types.d.ts +5 -1
  53. package/dist/styles.css +407 -113
  54. package/dist/use-approval-awaiting.d.ts +22 -0
  55. package/dist/use-approval-awaiting.js +81 -0
  56. package/dist/use-decision.d.ts +20 -0
  57. package/dist/use-decision.js +79 -0
  58. package/docs/api/commands/slash.md +30 -26
  59. package/docs/api/headless.md +822 -41
  60. package/docs/api/index.md +13 -4
  61. package/package.json +15 -8
  62. package/src/completion-menu.css +71 -0
  63. package/src/styles.css +174 -46
@@ -40,7 +40,7 @@ function requireInteraction(input) {
40
40
  throw new WhistleActionError("interaction_required", "Signing in needs an interactive client.");
41
41
  return input.interaction;
42
42
  }
43
- async function chooseProvider(providers, requested, interaction, message, choices) {
43
+ async function chooseProvider(providers, requested, interaction, prompt, choices) {
44
44
  if (requested !== undefined) {
45
45
  const provider = providers.find((candidate) => candidate.id === requested);
46
46
  if (provider === undefined)
@@ -51,7 +51,8 @@ async function chooseProvider(providers, requested, interaction, message, choice
51
51
  throw new WhistleActionError("no_providers", "No model providers are available here.");
52
52
  const id = await interaction.prompt({
53
53
  type: "choice",
54
- message,
54
+ message: prompt.message,
55
+ summary: prompt.summary,
55
56
  collapsedGroup: "More providers",
56
57
  options: choices,
57
58
  });
@@ -73,6 +74,7 @@ async function chooseMethod(provider, requested, interaction) {
73
74
  const kind = await interaction.prompt({
74
75
  type: "choice",
75
76
  message: `How do you want to sign in to ${provider.name}?`,
77
+ summary: "You can add the other kind later.",
76
78
  options: provider.methods.map((method) => ({
77
79
  value: method.kind,
78
80
  label: method.label,
@@ -102,9 +104,11 @@ export async function signInProvider(owner, provider, interaction, request = {})
102
104
  async function signOutProvider(owner, provider, interaction) {
103
105
  const confirmed = await interaction.prompt({
104
106
  type: "confirmation",
105
- message: `Sign out of ${provider.name}? Local Sessions stop using this account.`,
107
+ message: `Sign out of ${provider.name}?`,
108
+ summary: "Conversations on this device stop using this account.",
106
109
  confirmLabel: "Sign out",
107
110
  cancelLabel: "Keep signed in",
111
+ destructive: true,
108
112
  });
109
113
  if (!confirmed)
110
114
  return;
@@ -162,15 +166,21 @@ export function accountCommandDefinitions(owner, options) {
162
166
  const access = request.providerId === undefined
163
167
  ? await owner.access(interaction.signal)
164
168
  : [];
165
- const provider = await chooseProvider(registry, request.providerId, interaction, "Choose a model provider", providerChoices(registry, access, owner.popularProviderIds, "login"));
169
+ // Several providers can be signed in at once, so none is "current";
170
+ // the Signed in group already comes first and takes the highlight.
171
+ const provider = await chooseProvider(registry, request.providerId, interaction, {
172
+ message: "Sign in to a model provider",
173
+ summary: "Use a subscription or an API key. You can add more later.",
174
+ }, providerChoices(registry, access, owner.popularProviderIds, "login"));
166
175
  if (request.providerId === undefined &&
167
176
  access.some((item) => item.providerId === provider.id)) {
168
177
  const action = await interaction.prompt({
169
178
  type: "choice",
170
- message: provider.name,
179
+ message: `Already signed in to ${provider.name}`,
180
+ summary: "Switch to another account, or sign out of this one.",
171
181
  options: [
172
182
  { value: "switch", label: "Switch account" },
173
- { value: "logout", label: "Sign out" },
183
+ { value: "logout", label: "Sign out…", destructive: true },
174
184
  ],
175
185
  });
176
186
  if (action === "logout") {
@@ -211,7 +221,10 @@ export function accountCommandDefinitions(owner, options) {
211
221
  });
212
222
  return;
213
223
  }
214
- const provider = await chooseProvider(signedIn, request.providerId, interaction, "Sign out of which provider?", signedIn.map((provider) => ({
224
+ const provider = await chooseProvider(signedIn, request.providerId, interaction, {
225
+ message: "Sign out of which provider?",
226
+ summary: "Other providers stay signed in.",
227
+ }, signedIn.map((provider) => ({
215
228
  value: provider.id,
216
229
  label: provider.name,
217
230
  })));
@@ -9,6 +9,7 @@ export declare function useClientActions(commands: ParakeetCommandRuntime): {
9
9
  readonly input?: {
10
10
  readonly [x: string]: import("effect/Schema").Json;
11
11
  } | undefined;
12
+ readonly awaiting?: "question";
12
13
  }, Readonly<{
13
14
  pending: boolean;
14
15
  error?: string;
@@ -0,0 +1,20 @@
1
+ import type { ClientContribution } from "../core/client-contributions.js";
2
+ import type { ParakeetInteractions } from "./interactions.js";
3
+ import type { ParakeetCommandRuntime } from "./use-slash-commands.js";
4
+ /**
5
+ * Open the conversation of every decision the Agent is waiting on, so its
6
+ * first prompt can take over the composer without the person asking. Each row
7
+ * runs its own advertised command once; the row leaving cancels it.
8
+ */
9
+ export declare function useAwaitingDriver(options: Readonly<{
10
+ enabled: boolean;
11
+ commands: ParakeetCommandRuntime;
12
+ store: ParakeetInteractions;
13
+ contributions: readonly ClientContribution[];
14
+ /** Session observation is live; nothing opens while it is not. */
15
+ online: boolean;
16
+ /** Counts requests to answer, which restart a conversation that failed. */
17
+ requests: number;
18
+ /** Changes whenever the runtime's commands do, so a late command is found. */
19
+ commandsVersion: unknown;
20
+ }>): void;
@@ -0,0 +1,121 @@
1
+ import { createWhistleInteractionHost } from "@fungi.computer/whistle/interaction";
2
+ import { useEffect, useRef } from "react";
3
+ /** Footer rows whose action asks the person to decide in the composer. */
4
+ function awaitingRows(contributions) {
5
+ return contributions.flatMap(({ entryId, value }) => value.panel.flatMap((row) => (row.actions ?? [])
6
+ .filter((action) => action.awaiting !== undefined)
7
+ .map((action) => ({
8
+ key: JSON.stringify([entryId, row.id]),
9
+ command: { namespace: entryId, id: action.id },
10
+ title: row.title,
11
+ action,
12
+ }))));
13
+ }
14
+ function failure(outcome) {
15
+ if (outcome.status === "handled")
16
+ return undefined;
17
+ if (outcome.status === "failed")
18
+ return outcome.error.message;
19
+ if (outcome.status === "unavailable")
20
+ return outcome.reason;
21
+ return `The question could not be opened (${outcome.status}).`;
22
+ }
23
+ /**
24
+ * Open the conversation of every decision the Agent is waiting on, so its
25
+ * first prompt can take over the composer without the person asking. Each row
26
+ * runs its own advertised command once; the row leaving cancels it.
27
+ */
28
+ export function useAwaitingDriver(options) {
29
+ const { enabled, commands, store, contributions, online, requests, commandsVersion, } = options;
30
+ const running = useRef(new Map());
31
+ const handled = useRef(requests);
32
+ useEffect(() => {
33
+ const wanted = new Map((enabled && online ? awaitingRows(contributions) : []).map((row) => [
34
+ row.key,
35
+ row,
36
+ ]));
37
+ const retry = handled.current !== requests;
38
+ handled.current = requests;
39
+ for (const [key, entry] of running.current)
40
+ if (!wanted.has(key) || (retry && entry.failed())) {
41
+ entry.stop();
42
+ running.current.delete(key);
43
+ }
44
+ for (const row of wanted.values()) {
45
+ if (running.current.has(row.key))
46
+ continue;
47
+ const host = createWhistleInteractionHost({ title: row.title });
48
+ const session = host.begin();
49
+ const controller = new AbortController();
50
+ let leave;
51
+ let failed = false;
52
+ const unsubscribe = host.subscribe(() => {
53
+ if (leave === undefined && host.snapshot().prompt !== undefined)
54
+ leave = store.awaiting.add({
55
+ key: row.key,
56
+ kind: "question",
57
+ arrivedAt: Date.now(),
58
+ host,
59
+ });
60
+ });
61
+ const base = {
62
+ origin: "menu",
63
+ signal: controller.signal,
64
+ interaction: session.interaction,
65
+ };
66
+ void commands
67
+ .execute(row.command, row.action.input === undefined
68
+ ? base
69
+ : { ...base, arguments: row.action.input })
70
+ .then((outcome) => {
71
+ unsubscribe();
72
+ leave?.();
73
+ if (outcome.status === "missing" ||
74
+ outcome.status === "unavailable") {
75
+ // Its command is not advertised yet; start over when it is.
76
+ session.settle({ status: "cancelled" });
77
+ if (running.current.get(row.key)?.failed() === false)
78
+ running.current.delete(row.key);
79
+ return;
80
+ }
81
+ const message = failure(outcome);
82
+ if (message === undefined) {
83
+ session.settle({ status: "handled" });
84
+ const notice = host.snapshot().message;
85
+ if (notice?.type === "info")
86
+ store.toast({ variant: "info", message: notice.message });
87
+ return;
88
+ }
89
+ if (controller.signal.aborted) {
90
+ session.settle({ status: "cancelled" });
91
+ return;
92
+ }
93
+ failed = true;
94
+ session.settle({ status: "failed", message });
95
+ store.report({ tone: "error", message });
96
+ });
97
+ running.current.set(row.key, {
98
+ failed: () => failed,
99
+ stop: () => {
100
+ unsubscribe();
101
+ leave?.();
102
+ controller.abort();
103
+ host.cancel();
104
+ },
105
+ });
106
+ }
107
+ }, [
108
+ enabled,
109
+ online,
110
+ contributions,
111
+ commands,
112
+ store,
113
+ requests,
114
+ commandsVersion,
115
+ ]);
116
+ useEffect(() => () => {
117
+ for (const entry of running.current.values())
118
+ entry.stop();
119
+ running.current.clear();
120
+ }, [commands, store, enabled]);
121
+ }
@@ -1,8 +1,10 @@
1
1
  import { keyBindings, slashRegistry } from "./slash.js";
2
2
  /** Run a chosen command after the choosing command has settled. */
3
- function runAfter(runtime, commandId, origin) {
3
+ function runAfter(runtime, command, origin) {
4
+ if (command === undefined)
5
+ return;
4
6
  setTimeout(() => {
5
- void runtime.execute(commandId, { origin });
7
+ void runtime.execute(command, { origin });
6
8
  }, 0);
7
9
  }
8
10
  /**
@@ -39,7 +41,7 @@ export function parakeetBuiltinCommands(runtime, options) {
39
41
  : `${command.title} · ${command.keys.join(", ")}`,
40
42
  })),
41
43
  });
42
- runAfter(runtime, chosen, input.origin);
44
+ runAfter(runtime, commands.find((command) => command.commandId === chosen)?.command, input.origin);
43
45
  return undefined;
44
46
  },
45
47
  },
@@ -71,7 +73,7 @@ export function parakeetBuiltinCommands(runtime, options) {
71
73
  description: row.keys,
72
74
  })),
73
75
  });
74
- runAfter(runtime, chosen, input.origin);
76
+ runAfter(runtime, keys.find((row) => row.commandId === chosen)?.command, input.origin);
75
77
  return undefined;
76
78
  },
77
79
  },
@@ -1,5 +1,6 @@
1
1
  import type { WhistleInteractionEnd, WhistleRuntime } from "@fungi.computer/whistle";
2
2
  import { type WhistleInteractionHost } from "@fungi.computer/whistle/interaction";
3
+ import type { AwaitingItem } from "../core/awaiting-answer.js";
3
4
  /** One interaction a renderer should draw as a dialog or panel. */
4
5
  export type ParakeetOpenInteraction = Readonly<{
5
6
  /** Changes whenever a different interaction opens. */
@@ -27,8 +28,21 @@ export type ParakeetToast = Readonly<{
27
28
  /** What happened. */
28
29
  message: string;
29
30
  }>;
31
+ /** The decisions waiting on the person, which the composer takes over for. */
32
+ export type ParakeetAwaiting = Readonly<{
33
+ /** Every waiting decision, in the order it joined. */
34
+ items: readonly AwaitingItem[];
35
+ /** Keys the person postponed with Later; they wait in the footer row. */
36
+ later: ReadonlySet<string>;
37
+ /** The person asked for the composer even though a draft is in it. */
38
+ forced: boolean;
39
+ /** Counts each request to answer, so a stalled item can start over. */
40
+ requests: number;
41
+ }>;
30
42
  /** Everything a renderer draws for command conversations. */
31
43
  export type ParakeetInteractionState = Readonly<{
44
+ /** Decisions that take over the composer instead of opening a dialog. */
45
+ awaiting: ParakeetAwaiting;
32
46
  /** The interaction that needs the human, when one does. */
33
47
  open?: ParakeetOpenInteraction | undefined;
34
48
  /** A manually opened, live panel row from an admitted client entry. */
@@ -61,6 +75,17 @@ export type ParakeetInteractions = Readonly<{
61
75
  attach: (present: WhistleRuntime["present"]) => () => void;
62
76
  /** How the most recent interaction ended; a dismissal is not a failure to report. */
63
77
  lastEnd: () => WhistleInteractionEnd["status"] | undefined;
78
+ /** The queue of decisions the composer answers. */
79
+ awaiting: Readonly<{
80
+ /** Join the queue; the returned function leaves it. */
81
+ add: (item: AwaitingItem) => () => void;
82
+ /** Leave the queue. */
83
+ remove: (key: string) => void;
84
+ /** Postpone one decision: it leaves the composer but stays waiting. */
85
+ later: (key: string) => void;
86
+ /** Bring back the earliest postponed decision, even over a draft. */
87
+ focus: () => void;
88
+ }>;
64
89
  }>;
65
90
  /**
66
91
  * Own how commands talk to the human. An interaction opens only when it asks
@@ -5,7 +5,14 @@ import { createWhistleInteractionHost, } from "@fungi.computer/whistle/interacti
5
5
  * reports information emits a toast; ordinary success echoes are silent.
6
6
  */
7
7
  export function createParakeetInteractions() {
8
- let state = {};
8
+ let state = {
9
+ awaiting: {
10
+ items: [],
11
+ later: new Set(),
12
+ forced: false,
13
+ requests: 0,
14
+ },
15
+ };
9
16
  let sequence = 0;
10
17
  let lastEnd;
11
18
  const listeners = new Set();
@@ -24,6 +31,30 @@ export function createParakeetInteractions() {
24
31
  for (const listener of listeners)
25
32
  listener();
26
33
  };
34
+ const setAwaiting = (next) => {
35
+ publish({ ...state, awaiting: { ...state.awaiting, ...next } });
36
+ };
37
+ const removeAwaiting = (key) => {
38
+ const { items, later } = state.awaiting;
39
+ if (!items.some((item) => item.key === key))
40
+ return;
41
+ setAwaiting({
42
+ items: items.filter((item) => item.key !== key),
43
+ later: new Set([...later].filter((postponed) => postponed !== key)),
44
+ forced: false,
45
+ });
46
+ };
47
+ const addAwaiting = (item) => {
48
+ setAwaiting({
49
+ items: [
50
+ ...state.awaiting.items.filter(({ key }) => key !== item.key),
51
+ item,
52
+ ],
53
+ });
54
+ return () => {
55
+ removeAwaiting(item.key);
56
+ };
57
+ };
27
58
  const closeKey = (key) => {
28
59
  if (state.open?.key === key)
29
60
  publish({ ...state, open: undefined });
@@ -36,6 +67,7 @@ export function createParakeetInteractions() {
36
67
  title,
37
68
  onEnd: (end) => {
38
69
  lastEnd = end.status;
70
+ leaveQueue?.();
39
71
  const message = host.snapshot().message;
40
72
  if (!shown) {
41
73
  if (end.status === "failed")
@@ -65,11 +97,28 @@ export function createParakeetInteractions() {
65
97
  closeKey(key);
66
98
  },
67
99
  });
100
+ let leaveQueue;
68
101
  const unsubscribe = host.subscribe(() => {
69
102
  const current = host.snapshot();
70
103
  if (current.retryable)
71
104
  hadRetryableFailure = true;
72
- if (shown)
105
+ // A decision is the composer's, never a dialog's, even when the same
106
+ // conversation reached it through a dialog (a row action chooser).
107
+ if (leaveQueue === undefined &&
108
+ current.prompt?.request.type === "decision") {
109
+ if (shown) {
110
+ shown = false;
111
+ closeKey(key);
112
+ }
113
+ leaveQueue = addAwaiting({
114
+ key: `interaction:${String(key)}`,
115
+ kind: "question",
116
+ arrivedAt: Date.now(),
117
+ host,
118
+ });
119
+ return;
120
+ }
121
+ if (leaveQueue !== undefined || shown)
73
122
  return;
74
123
  if (current.prompt === undefined &&
75
124
  current.handoff === undefined &&
@@ -121,5 +170,30 @@ export function createParakeetInteractions() {
121
170
  },
122
171
  attach: (present) => present(presenter),
123
172
  lastEnd: () => lastEnd,
173
+ awaiting: {
174
+ add: addAwaiting,
175
+ remove: removeAwaiting,
176
+ later: (key) => {
177
+ if (!state.awaiting.items.some((item) => item.key === key))
178
+ return;
179
+ setAwaiting({
180
+ later: new Set([...state.awaiting.later, key]),
181
+ forced: false,
182
+ });
183
+ },
184
+ focus: () => {
185
+ const { items, later, requests } = state.awaiting;
186
+ const earliest = [...items]
187
+ .sort((left, right) => left.arrivedAt - right.arrivedAt)
188
+ .find((item) => later.has(item.key));
189
+ setAwaiting({
190
+ later: earliest === undefined
191
+ ? later
192
+ : new Set([...later].filter((key) => key !== earliest.key)),
193
+ forced: items.length > 0,
194
+ requests: requests + 1,
195
+ });
196
+ },
197
+ },
124
198
  };
125
199
  }
@@ -30,9 +30,9 @@ export async function runSlashDraft(draft, commands, send, origin, consumeDraft)
30
30
  if (command.consumesDraft && command.availability.status === "available")
31
31
  consumeDraft?.();
32
32
  if (parsed.arguments === undefined)
33
- return commands.execute(command.commandId, { origin });
33
+ return commands.execute(command.command, { origin });
34
34
  if (requestIdField === undefined)
35
- return commands.execute(command.commandId, {
35
+ return commands.execute(command.command, {
36
36
  origin,
37
37
  arguments: parsed.arguments,
38
38
  });
@@ -47,7 +47,7 @@ export async function runSlashDraft(draft, commands, send, origin, consumeDraft)
47
47
  };
48
48
  states.set(command.commandId, state);
49
49
  const running = commands
50
- .execute(command.commandId, {
50
+ .execute(command.command, {
51
51
  origin,
52
52
  arguments: { ...parsed.arguments, [requestIdField]: state.requestId },
53
53
  })
@@ -1,10 +1,12 @@
1
- import type { WhistleAvailability, WhistleJsonObject, WhistleSlashArguments, WhistleSlashWord, WhistleSnapshot } from "@fungi.computer/whistle";
1
+ import type { WhistleAvailability, WhistleCommandKey, WhistleJsonObject, WhistleSlashArguments, WhistleSlashWord, WhistleSnapshot } from "@fungi.computer/whistle";
2
2
  /** One slash command as every Parakeet renderer lists it. */
3
3
  export type SlashCommand = Readonly<{
4
4
  /** The alias typed after `/`. */
5
5
  alias: string;
6
- /** Canonical Whistle command identity. */
6
+ /** Opaque Whistle render key. */
7
7
  commandId: string;
8
+ /** The command's typed Whistle identity; execute it by this. */
9
+ command: WhistleCommandKey;
8
10
  /** Consume this draft on dispatch, including for a dialog or refusal. */
9
11
  consumesDraft?: boolean | undefined;
10
12
  /** Human-facing title. */
@@ -24,8 +26,10 @@ export type SlashCommand = Readonly<{
24
26
  }>;
25
27
  /** One key binding as help and hint surfaces list it. */
26
28
  export type KeyBindingRow = Readonly<{
27
- /** Canonical Whistle command identity. */
29
+ /** Opaque Whistle render key. */
28
30
  commandId: string;
31
+ /** The command's typed Whistle identity; execute it by this. */
32
+ command: WhistleCommandKey;
29
33
  /** Human key label, e.g. `Ctrl+O`. */
30
34
  keys: string;
31
35
  /** What the key does. */
@@ -31,6 +31,7 @@ export function slashRegistry(snapshot) {
31
31
  return snapshot.slash.map((row) => ({
32
32
  alias: row.alias,
33
33
  commandId: row.commandId,
34
+ command: row.command,
34
35
  title: row.title,
35
36
  description: row.description,
36
37
  category: row.category,
@@ -48,6 +49,7 @@ export function keyBindings(snapshot) {
48
49
  const current = rows.get(row.commandId);
49
50
  rows.set(row.commandId, {
50
51
  commandId: row.commandId,
52
+ command: row.command,
51
53
  keys: current === undefined
52
54
  ? keyLabel(row.binding)
53
55
  : `${current.keys}, ${keyLabel(row.binding)}`,
@@ -1,5 +1,6 @@
1
1
  import type { PromptDelivery } from "@fungi.computer/shiitake/client";
2
2
  import type { WhistleExecutionOrigin, WhistleRuntime } from "@fungi.computer/whistle";
3
+ import { type AwaitingQueue } from "../core/awaiting-answer.js";
3
4
  import { type ComposerKeys, type KeyHint } from "./hints.js";
4
5
  import { type ParakeetInteractions, type ParakeetInteractionState } from "./interactions.js";
5
6
  import { type SlashCompletion } from "./slash.js";
@@ -36,6 +37,14 @@ export type SlashCommandController = Readonly<{
36
37
  interactions: ParakeetInteractionState;
37
38
  /** The conversation store, for closing and retrying dialogs. */
38
39
  store: ParakeetInteractions;
40
+ /**
41
+ * The decision the composer is answering, if any. It takes over an empty
42
+ * composer, or any composer once the person asked for it (`forced`).
43
+ */
44
+ awaiting: AwaitingQueue & Readonly<{
45
+ /** The person asked for the composer even though a draft is in it. */
46
+ forced: boolean;
47
+ }>;
39
48
  /** Move the highlight; false when no list is open. */
40
49
  move: (delta: -1 | 1) => boolean;
41
50
  /** Highlight one row (pointer hover). */
@@ -48,6 +57,10 @@ export type SlashCommandController = Readonly<{
48
57
  submit: (delivery?: PromptDelivery) => void;
49
58
  /** Run one completion row (pointer choice). */
50
59
  choose: (index: number) => void;
60
+ /** Whether a registered command answers this slash alias and can run now. */
61
+ available: (alias: string) => boolean;
62
+ /** Run the command behind a slash alias, as typing it would, leaving the draft alone. */
63
+ runCommand: (alias: string) => void;
51
64
  }>;
52
65
  /**
53
66
  * The one slash-command controller behind every Parakeet composer. It owns
@@ -1,5 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
2
- import { useComposer, useClientContributions, useExtensionSlots, useParakeetSession, useParakeetInteractions, } from "../conversation.js";
2
+ import { useComposer, useClientContributions, useExtensionSlots, useParakeetSession, useParakeetInteractions, useStatus, } from "../conversation.js";
3
+ import { awaitingQueue } from "../core/awaiting-answer.js";
4
+ import { useAwaitingDriver } from "./awaiting-driver.js";
3
5
  import { advertisedCommands } from "./advertised.js";
4
6
  import { parakeetBuiltinCommands } from "./builtins.js";
5
7
  import { composerHints } from "./hints.js";
@@ -39,6 +41,10 @@ export function useSlashCommands(options) {
39
41
  entry.value.panel.some((row) => row.id === inspection.rowId && row.inspection !== undefined)))
40
42
  store.inspect(undefined);
41
43
  }, [contributions, interactions.inspection, store]);
44
+ const awaiting = {
45
+ ...awaitingQueue(interactions.awaiting.items, interactions.awaiting.later),
46
+ forced: interactions.awaiting.forced,
47
+ };
42
48
  const snapshot = useSyncExternalStore(commands.subscribe, commands.snapshot, commands.snapshot);
43
49
  const composerRef = useRef(composer);
44
50
  composerRef.current = composer;
@@ -92,6 +98,44 @@ export function useSlashCommands(options) {
92
98
  message: `${error.extensionId} commands are unavailable: ${error.message}`,
93
99
  });
94
100
  }, [advertised, enabled, slots, contributions, store]);
101
+ const { observation } = useStatus();
102
+ useAwaitingDriver({
103
+ enabled,
104
+ commands,
105
+ store,
106
+ contributions,
107
+ online: observation.status === "live",
108
+ requests: interactions.awaiting.requests,
109
+ commandsVersion: snapshot,
110
+ });
111
+ const waiting = interactions.awaiting.items.length > 0;
112
+ useEffect(() => {
113
+ if (!enabled || !waiting)
114
+ return;
115
+ const lease = commands.contribute({
116
+ sourceId: "parakeet-awaiting",
117
+ namespace: "parakeet-awaiting",
118
+ commands: [
119
+ {
120
+ id: "answer",
121
+ title: "Answer a question",
122
+ category: "Conversation",
123
+ description: "Answer what the Agent is waiting on",
124
+ projections: {
125
+ help: { order: 5 },
126
+ palette: { order: 5 },
127
+ slash: { aliases: ["answer"], order: 5, consumesDraft: true },
128
+ },
129
+ handler: () => {
130
+ store.awaiting.focus();
131
+ },
132
+ },
133
+ ],
134
+ });
135
+ return () => {
136
+ lease.dispose();
137
+ };
138
+ }, [commands, enabled, store, waiting]);
95
139
  const registry = useMemo(() => slashRegistry(snapshot), [snapshot]);
96
140
  const [dismissed, setDismissed] = useState();
97
141
  const [highlighted, setHighlighted] = useState({ draft: "", index: 0 });
@@ -157,6 +201,7 @@ export function useSlashCommands(options) {
157
201
  hints,
158
202
  interactions,
159
203
  store,
204
+ awaiting,
160
205
  move: (delta) => {
161
206
  if (rowCount === 0)
162
207
  return false;
@@ -203,5 +248,17 @@ export function useSlashCommands(options) {
203
248
  run(delivery, insert?.trimEnd());
204
249
  },
205
250
  choose,
251
+ available: (alias) => registry.some((command) => command.alias === alias &&
252
+ command.availability.status === "available"),
253
+ runCommand: (alias) => {
254
+ store.report(undefined);
255
+ void runSlashDraft(`/${alias}`, commands, () => undefined, origin).then((outcome) => {
256
+ const message = failureMessage(outcome);
257
+ if (message !== undefined &&
258
+ store.snapshot().open === undefined &&
259
+ store.lastEnd() !== "cancelled")
260
+ store.report({ tone: "error", message: `/${alias}: ${message}` });
261
+ });
262
+ },
206
263
  };
207
264
  }
@@ -39,6 +39,8 @@ export type ChatComposerProps = {
39
39
  context?: SessionContext | undefined;
40
40
  /** Parakeet's slash controller; the composer only draws and forwards keys. */
41
41
  slash?: SlashCommandController | undefined;
42
+ /** Replace the images riding with the draft; omitted, the composer offers no Attach key. */
43
+ onImagesChange?: ((images: ComposerController["images"]) => void) | undefined;
42
44
  };
43
45
  /** Full Session readout for announcements; the footer only draws attention states. */
44
46
  export declare function sessionReadout(status: ParakeetStatus, view: ParakeetSessionViewState): string;