@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
@@ -1,18 +1,27 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { formatTokens } from "../core/format-tokens.js";
3
3
  import { formatModelIdentity } from "../core/model-identity.js";
4
4
  import { formatContextUsage } from "../core/format-context.js";
5
- import { Composer, } from "@fungi.computer/caps/blocks/composer";
5
+ import { Composer } from "@fungi.computer/caps/blocks/composer";
6
6
  import { isImeComposing } from "@fungi.computer/caps/lib/ime";
7
7
  import { RunFailureNotice } from "./RunFailureNotice.js";
8
- import { Button } from "@fungi.computer/caps/components/button";
8
+ import { SendAction, } from "@fungi.computer/caps/components/send-action";
9
9
  import { formatMoney } from "@fungi.computer/caps/lib/format-money";
10
- import { Popover, PopoverAnchor, } from "@fungi.computer/caps/components/popover";
10
+ import { PopoverAnchor } from "@fungi.computer/caps/components/popover";
11
11
  import { Status } from "@fungi.computer/caps/components/status";
12
- import { memo, useCallback, useId, useLayoutEffect, useRef, useState, } from "react";
12
+ import { Button } from "@fungi.computer/caps/components/button";
13
+ import { Card } from "@fungi.computer/caps/components/card";
14
+ import { IconButton } from "@fungi.computer/caps/components/icon-button";
15
+ import { KeycapButton } from "@fungi.computer/caps/components/keycap";
16
+ import { StatusTray, StatusTrayList, StatusTrayRow, } from "@fungi.computer/caps/blocks/status-tray";
17
+ import { ChevronDown, Plus, SlidersHorizontal, X } from "lucide-react";
18
+ import { memo, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react";
13
19
  import { Textarea } from "@fungi.computer/caps/components/textarea";
14
20
  import { formatHints } from "../commands/hints.js";
15
- import { SlashMenu, slashOptionId } from "./SlashMenu.js";
21
+ import { awaitingTakeover } from "../core/awaiting-answer.js";
22
+ import { completionFieldProps, completionKeyAction } from "./CompletionMenu.js";
23
+ import { DecisionComposer } from "./DecisionComposer.js";
24
+ import { SlashMenu } from "./SlashMenu.js";
16
25
  const presentations = {
17
26
  connecting: {
18
27
  busy: false,
@@ -92,13 +101,14 @@ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, s
92
101
  if (recalling)
93
102
  return;
94
103
  if (slash?.open === true) {
95
- const handled = event.key === "ArrowDown"
104
+ const action = completionKeyAction(event);
105
+ const handled = action === "next"
96
106
  ? slash.move(1)
97
- : event.key === "ArrowUp"
107
+ : action === "previous"
98
108
  ? slash.move(-1)
99
109
  : event.key === "Tab"
100
110
  ? slash.complete()
101
- : event.key === "Escape"
111
+ : action === "dismiss"
102
112
  ? slash.dismiss()
103
113
  : false;
104
114
  if (handled) {
@@ -187,20 +197,24 @@ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, s
187
197
  const listOpen = slash?.open === true && slash.completion.kind !== "none"
188
198
  ? slash.completion.items.length > 0
189
199
  : false;
190
- return (_jsxs("div", { className: "relative min-w-0 flex-1", children: [_jsx("label", { className: "sr-only", htmlFor: textareaId, children: "Message" }), _jsx(Textarea, { "aria-describedby": [
200
+ return (_jsxs(_Fragment, { children: [_jsx("label", { className: "sr-only", htmlFor: textareaId, children: "Message" }), _jsx(Textarea, { "aria-describedby": [
191
201
  slash === undefined ? undefined : `${textareaId}-guidance`,
192
202
  queuedRecall === undefined
193
203
  ? undefined
194
204
  : `${textareaId}-recall-guidance`,
195
205
  ]
196
206
  .filter((id) => id !== undefined)
197
- .join(" ") || undefined, "aria-busy": recalling, readOnly: recalling, "aria-autocomplete": "list", "aria-controls": listOpen ? slashListId : undefined, "aria-expanded": listOpen, "aria-activedescendant": listOpen && slash !== undefined
198
- ? slashOptionId(slashListId, slash.selected)
199
- : undefined, className: "join-item min-h-[48px] max-h-[320px] w-full resize-none overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words px-3 py-[calc((3rem-1.25rem)/2)] font-sans text-xs text-base-content !border-0 !bg-transparent !shadow-none focus:!outline-none", id: textareaId, onChange: (event) => {
207
+ .join(" ") || undefined, "aria-busy": recalling, readOnly: recalling, ...(slash === undefined
208
+ ? {}
209
+ : completionFieldProps({
210
+ open: listOpen,
211
+ listId: slashListId,
212
+ selected: slash.selected,
213
+ })), className: "min-h-12 max-h-[320px] resize-none overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words py-xs font-sans text-base leading-relaxed text-base-content", id: textareaId, onChange: (event) => {
200
214
  onChange(event.target.value);
201
215
  setHistoryIndex(-1);
202
216
  }, onKeyDown: onKeyDown, placeholder: placeholder ??
203
- (slash === undefined ? "Ask agent..." : "Ask agent… (/ for commands)"), role: slash === undefined ? undefined : "combobox", ref: textareaRef, rows: 1, value: value }), queuedRecall === undefined ? null : (_jsx("span", { id: `${textareaId}-recall-guidance`, className: "sr-only", children: "Press Up on an empty composer to recall your newest queued message." })), (queuedRecall?.images?.length ?? 0) === 0 ? null : (_jsxs("p", { className: "text-xs", children: [queuedRecall?.images?.length, " attached image(s)"] }))] }));
217
+ (slash === undefined ? "Ask agent..." : "Ask agent… (/ for commands)"), ref: textareaRef, rows: 1, value: value }), queuedRecall === undefined ? null : (_jsx("span", { id: `${textareaId}-recall-guidance`, className: "sr-only", children: "Press Up on an empty composer to recall your newest queued message." }))] }));
204
218
  }
205
219
  /** Full Session readout for announcements; the footer only draws attention states. */
206
220
  export function sessionReadout(status, view) {
@@ -238,38 +252,136 @@ export function sessionReadout(status, view) {
238
252
  return "idle";
239
253
  }
240
254
  }
241
- function ComposerFooter({ statusChips, model, status, viewState, usage, context, }) {
255
+ /** Read a picked image file as Session prompt image cargo; the owner admits it on send. */
256
+ async function readImage(file) {
257
+ const bytes = new Uint8Array(await file.arrayBuffer());
258
+ let binary = "";
259
+ for (const byte of bytes)
260
+ binary += String.fromCharCode(byte);
261
+ return { type: "image", mimeType: file.type, data: btoa(binary) };
262
+ }
263
+ /** The Session's settings the composer can open, by the slash alias that owns them. */
264
+ const settingsCommands = [
265
+ { alias: "thinking", label: "Thinking level…" },
266
+ { alias: "compact", label: "Compact context" },
267
+ ];
268
+ /**
269
+ * The composer's bottom row: attach, the model key, attention, the Settings
270
+ * chip that sums up the Session's readouts, and the one send key at the end.
271
+ * Built from the same Caps StatusTray row Prints seats its controls in.
272
+ */
273
+ function ComposerToolbar({ statusChips, model, status, viewState, usage, context, slash, attach, send, }) {
274
+ const [openId, setOpenId] = useState(null);
242
275
  const tone = viewState.kind === "error" ? "error" : presentations[status].tone;
243
276
  // Admission must not replace an outstanding owner attention readout.
244
277
  const label = sessionReadout("idle", viewState);
245
278
  const needsAttention = viewState.kind === "waiting_for_you" || viewState.kind === "error";
246
- return (_jsxs("div", { className: "composer-footer", children: [needsAttention ? (_jsx(Status, { label: label, title: label, tone: tone })) : null, statusChips?.map((chip, index) => (_jsx("span", { className: "min-w-0 truncate", title: chip, children: chip }, index))), model ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "footer-divider" }), _jsx("span", { className: "font-mono font-semibold", children: formatModelIdentity(model) })] })) : null, context ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "footer-divider" }), _jsx("span", { className: "font-mono", title: "Context used / model context", children: formatContextUsage(context) })] })) : null, usage ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "footer-divider" }), _jsx("span", { className: "font-mono", children: formatTokens(usage.tokens) }), _jsx("span", { className: "footer-divider" }), _jsx("span", { className: "font-mono", children: formatMoney(usage.cost) })] })) : null] }));
279
+ const modelName = model === undefined ? undefined : formatModelIdentity(model);
280
+ const canPickModel = slash?.available("model") === true;
281
+ const contextText = context === undefined ? undefined : formatContextUsage(context);
282
+ const summary = [
283
+ contextText,
284
+ usage === undefined ? undefined : formatTokens(usage.tokens),
285
+ usage === undefined ? undefined : formatMoney(usage.cost),
286
+ ].filter((part) => part !== undefined);
287
+ const commands = settingsCommands.filter(({ alias }) => slash?.available(alias) === true);
288
+ const items = summary.length === 0 && commands.length === 0 && modelName === undefined
289
+ ? []
290
+ : [
291
+ {
292
+ id: "settings",
293
+ label: (_jsxs("span", { className: "composer-settings-chip", children: [_jsx(SlidersHorizontal, { "aria-hidden": "true" }), "Settings"] })),
294
+ accessibleLabel: "Session settings",
295
+ title: "Settings",
296
+ content: (_jsxs(StatusTrayList, { className: "composer-settings", children: [modelName === undefined ? null : (_jsx(StatusTrayRow, { primary: "Model", secondary: modelName })), contextText === undefined ? null : (_jsx(StatusTrayRow, { primary: "Context", secondary: contextText, title: "Context used / model context" })), usage === undefined ? null : (_jsx(StatusTrayRow, { primary: "Usage", secondary: `${formatTokens(usage.tokens)} · ${formatMoney(usage.cost)}` })), commands.map(({ alias, label: commandLabel }) => (_jsx(StatusTrayRow, { primary: commandLabel, actions: _jsx(Button, { size: "xs", variant: "ghost", onClick: () => {
297
+ setOpenId(null);
298
+ slash?.runCommand(alias);
299
+ }, children: "Open" }) }, alias)))] })),
300
+ },
301
+ ];
302
+ return (_jsx(StatusTray, { className: "composer-footer", items: items, align: "start", openId: openId, onOpenChange: setOpenId, leading: _jsxs(_Fragment, { children: [attach, modelName === undefined ? null : canPickModel ? (_jsx(KeycapButton, { "aria-label": `Model: ${modelName}. Change model`, className: "composer-model-key", label: modelName, readout: _jsx(ChevronDown, { "aria-hidden": "true", className: "size-[var(--stipe-icon-size-sm)] shrink-0" }), onClick: () => {
303
+ slash.runCommand("model");
304
+ }, title: "Change model" })) : (_jsx("span", { className: "composer-model-readout", children: modelName })), needsAttention ? (_jsx(Status, { label: label, title: label, tone: tone })) : null, statusChips?.map((chip, index) => (_jsx("span", { className: "composer-status-chip min-w-0 truncate", title: chip, children: chip }, index)))] }), trailing: _jsxs(_Fragment, { children: [summary.length === 0 ? null : (_jsx("span", { className: "composer-readout", title: "Context used / model context \u00B7 tokens \u00B7 cost", children: summary.join(" · ") })), send] }) }));
247
305
  }
248
- export const ChatComposer = memo(function ChatComposer({ queuedRecall, placeholder, showFooter = true, statusChips, actionVariant = "primary", draft, failure, failureCause, admissionError, model, onAbort, onChange, onRetry, onSend, status, viewState, textareaRef, usage, context, slash, }) {
306
+ /** Images riding with the draft, drawn as small tiles above the text. */
307
+ function ComposerAttachments({ images, onRemove, }) {
308
+ return (_jsx("ul", { "aria-label": "Attached images", className: "composer-attachments", children: images.map((image, index) => (_jsxs("li", { children: [_jsx("img", { alt: `Attached image ${String(index + 1)}`, className: "mask mask-squircle", src: `data:${image.mimeType};base64,${image.data}` }), onRemove === undefined ? null : (_jsx(IconButton, { "aria-label": `Remove attached image ${String(index + 1)}`, className: "composer-attachment-remove", icon: _jsx(X, {}), onClick: () => {
309
+ onRemove(index);
310
+ } }))] }, index))) }));
311
+ }
312
+ export const ChatComposer = memo(function ChatComposer({ queuedRecall, onImagesChange, placeholder, showFooter = true, statusChips, actionVariant = "primary", draft, failure, failureCause, admissionError, model, onAbort, onChange, onRetry, onSend, status, viewState, textareaRef, usage, context, slash, }) {
249
313
  const textareaId = useId();
250
314
  const slashListId = `${textareaId}-commands`;
315
+ const fileInput = useRef(null);
251
316
  const { busy } = presentations[status];
317
+ // A waiting decision takes the composer over an empty draft, or on request.
318
+ const takeover = slash === undefined ? undefined : awaitingTakeover(slash.awaiting, draft);
319
+ const takeoverKey = takeover?.key;
320
+ const seenTakeover = useRef(undefined);
321
+ const hadFocus = useRef(false);
322
+ if (seenTakeover.current !== takeoverKey) {
323
+ seenTakeover.current = takeoverKey;
324
+ hadFocus.current =
325
+ takeoverKey !== undefined &&
326
+ document.activeElement === textareaRef.current;
327
+ }
328
+ const restoreFocus = hadFocus.current;
329
+ useEffect(() => {
330
+ if (takeoverKey === undefined && restoreFocus)
331
+ textareaRef.current?.focus();
332
+ }, [takeoverKey, restoreFocus, textareaRef]);
333
+ const images = queuedRecall?.images ?? [];
252
334
  const recallBusy = queuedRecall?.recallState.kind === "recalling";
253
- const cannotSend = recallBusy ||
254
- (draft.trim().length === 0 && (queuedRecall?.images?.length ?? 0) === 0);
255
- const composerAction = busy ? (_jsxs("div", { className: "flex items-stretch", children: [_jsx(Button, { "aria-label": "Stop current turn", className: "join-item h-auto min-h-0 self-stretch", "data-testid": "composer-stop", disabled: recallBusy || status === "queued" || status === "cancelling", onClick: onAbort, title: "Stop the current turn. Queued messages keep running in order.", size: "lg", variant: "ghost", children: "Stop" }), _jsx(Button, { "aria-label": "Queue follow-up", className: "join-item h-auto min-h-0 self-stretch", disabled: cannotSend, onClick: () => {
256
- onSend("followUp");
257
- }, size: "lg", variant: actionVariant, children: "Queue" })] })) : (_jsx(Button, { "aria-label": "Send message", className: "join-item h-auto min-h-0 self-stretch", disabled: cannotSend, onClick: () => {
258
- onSend("steer");
259
- }, title: "Send", size: "lg", variant: actionVariant, children: "Send" }));
260
- const composerInput = (_jsx(HostComposerTextarea, { queuedRecall: queuedRecall, placeholder: placeholder, onChange: onChange, onAbort: onAbort, onSend: () => {
335
+ const cannotSend = recallBusy || (draft.trim().length === 0 && images.length === 0);
336
+ // One key in one place: Send when idle, Stop while the Agent works.
337
+ // Enter still queues a typed follow-up during a run.
338
+ // One key says what Enter does: Send when idle; while the Agent works,
339
+ // Stop over an empty draft and Queue once there is a draft to queue.
340
+ const sendState = cannotSend
341
+ ? busy
342
+ ? "busy"
343
+ : "empty"
344
+ : "ready";
345
+ const sendLabel = busy ? "Queue follow-up" : "Send message";
346
+ const sendAction = (_jsx(SendAction, { "aria-label": sendLabel, className: "composer-send-key", disabled: sendState === "busy" &&
347
+ (recallBusy || status === "queued" || status === "cancelling"), onClick: () => {
261
348
  onSend(busy ? "followUp" : "steer");
262
- }, slash: slash, slashListId: slashListId, textareaId: textareaId, textareaRef: textareaRef, value: draft }));
263
- const composerProps = {
264
- action: composerAction,
265
- input: composerInput,
266
- };
267
- const footerProps = { status, viewState, statusChips };
268
- if (model)
269
- footerProps.model = model;
270
- if (usage)
271
- footerProps.usage = usage;
272
- if (context)
273
- footerProps.context = context;
274
- return (_jsxs("div", { className: "chat-composer not-prose flex-shrink-0 border-t border-base-300", children: [_jsxs("div", { className: "relative", children: [admissionError === "queue_full" ? (_jsx("div", { className: "px-3 py-1.5 text-xs text-error", role: "alert", children: "Too many prompts are still awaiting admission. Try again shortly." })) : null, failure !== undefined ? (_jsx(RunFailureNotice, { failure: failure, cause: failureCause, onRetry: onRetry })) : null, slash?.interactions.notice === undefined ? null : (_jsx("div", { className: "parakeet-command-notice px-3 py-1.5 text-xs", "data-tone": slash.interactions.notice.tone, role: slash.interactions.notice.tone === "error" ? "alert" : "status", children: slash.interactions.notice.message })), _jsxs(PopoverAnchor, { className: "composer-body", children: [slash === undefined ? null : (_jsx(Popover, { open: slash.open, side: "top", "data-parakeet-slash": "", children: _jsx(SlashMenu, { slash: slash, listId: slashListId }) })), _jsx(Composer, { ...composerProps })] })] }), showFooter ? _jsx(ComposerFooter, { ...footerProps }) : null, slash === undefined ? null : (_jsx("p", { className: "sr-only", id: `${textareaId}-guidance`, children: formatHints(slash.hints) }))] }));
349
+ }, onStop: onAbort, state: sendState, stopLabel: "Stop current turn", title: sendState === "busy"
350
+ ? "Stop the current turn. Queued messages keep running in order."
351
+ : sendLabel, tone: actionVariant === "primary" ? "primary" : "neutral" }));
352
+ const attach = onImagesChange === undefined ? null : (_jsxs(_Fragment, { children: [_jsx("input", { accept: "image/png,image/jpeg,image/gif,image/webp", "aria-label": "Choose images to attach", hidden: true, multiple: true, onChange: (event) => {
353
+ const picked = Array.from(event.currentTarget.files ?? []);
354
+ event.currentTarget.value = "";
355
+ void Promise.all(picked.map(readImage)).then((read) => {
356
+ onImagesChange([...images, ...read]);
357
+ });
358
+ }, ref: fileInput, type: "file" }), _jsx(KeycapButton, { "aria-label": "Attach images", className: "composer-attach-key", icon: _jsx(Plus, {}), label: null, onClick: () => fileInput.current?.click(), title: "Attach images" })] }));
359
+ // Visitors see only the field and the send key, never internal readouts.
360
+ const footerProps = { status, viewState };
361
+ if (showFooter) {
362
+ footerProps.statusChips = statusChips;
363
+ if (model)
364
+ footerProps.model = model;
365
+ if (usage)
366
+ footerProps.usage = usage;
367
+ if (context)
368
+ footerProps.context = context;
369
+ }
370
+ return (_jsxs("div", { className: "chat-composer not-prose flex-shrink-0", children: [admissionError !== undefined ? (_jsx("div", { className: "px-3 py-1.5 text-xs text-error", role: "alert", children: admissionError === "offline_queue_full"
371
+ ? "One prompt is already queued; it sends when the service is back."
372
+ : "Too many prompts are still awaiting admission. Try again shortly." })) : null, failure !== undefined ? (_jsx(RunFailureNotice, { failure: failure, cause: failureCause, onRetry: onRetry })) : null, slash?.interactions.notice === undefined ? null : (_jsx("div", { className: "parakeet-command-notice px-3 py-1.5 text-xs", "data-tone": slash.interactions.notice.tone, role: slash.interactions.notice.tone === "error" ? "alert" : "status", children: slash.interactions.notice.message })), _jsx("div", { "aria-live": "polite", className: "sr-only", children: takeover === undefined || slash === undefined
373
+ ? ""
374
+ : `Agent ${takeover.kind} ${String(slash.awaiting.position)} of ${String(slash.awaiting.total)} needs your answer` }), _jsxs(PopoverAnchor, { className: "composer-body", children: [slash === undefined ? null : (_jsx(SlashMenu, { slash: slash, listId: slashListId })), _jsx(Card, { "aria-label": "Message composer", className: "composer-card", children: _jsx(Composer, { attachments: takeover === undefined && images.length > 0 ? (_jsx(ComposerAttachments, { images: images, onRemove: onImagesChange === undefined
375
+ ? undefined
376
+ : (index) => {
377
+ onImagesChange(images.filter((_image, at) => at !== index));
378
+ } })) : undefined, input: _jsxs(_Fragment, { children: [takeover === undefined || slash === undefined ? null : (_jsx(DecisionComposer, { item: takeover, position: slash.awaiting.position, total: slash.awaiting.total, onLater: () => {
379
+ slash.store.awaiting.later(takeover.key);
380
+ }, takeFocus: hadFocus.current }, takeover.key)), _jsx("div", { hidden: takeover !== undefined, children: _jsx(HostComposerTextarea, { queuedRecall: queuedRecall, placeholder: placeholder, onChange: onChange, onAbort: onAbort, onSend: () => {
381
+ onSend(busy ? "followUp" : "steer");
382
+ }, slash: slash, slashListId: slashListId, textareaId: textareaId, textareaRef: textareaRef, value: draft }) })] }), action:
383
+ // Visitors get the field and the send key, no toolbar.
384
+ takeover === undefined && !showFooter ? sendAction : undefined, toolbar:
385
+ // A decision brings its own Later and Send row to the bottom edge.
386
+ takeover !== undefined || !showFooter ? undefined : (_jsx(ComposerToolbar, { ...footerProps, slash: slash, attach: attach, send: sendAction })) }) })] }), slash === undefined ? null : (_jsx("p", { className: "sr-only", id: `${textareaId}-guidance`, children: formatHints(slash.hints) }))] }));
275
387
  });
@@ -17,30 +17,46 @@ export function ClientRows({ entryId, rows, actions, onOpen, }) {
17
17
  const { inspection } = useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot);
18
18
  if (rows.length === 0)
19
19
  return null;
20
- return (_jsx("div", { className: "px-3 text-xs text-base-content", "data-client-entry": entryId, children: rows.map((row) => (_jsxs("div", { "data-client-row": row.id, className: "py-1", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { "aria-hidden": "true", "data-client-row-dot": "", className: `w-3 shrink-0 text-center leading-4 ${row.tone === undefined ? "" : rowColors[row.tone]}`, children: row.tone === undefined ? "" : "●" }), _jsxs("details", { className: "min-w-0 flex-1", children: [_jsxs("summary", { className: `cursor-pointer break-words leading-4 ${row.tone === undefined ? "" : rowColors[row.tone]}`, children: [row.title, row.inspection === undefined || row.detail === undefined
21
- ? null
22
- : ` · ${row.detail}`] }), row.detail === undefined ? null : (_jsx("p", { className: "max-h-32 overflow-y-auto overscroll-contain whitespace-pre-wrap break-words pb-2", children: row.detail }))] }), row.open === undefined ? null : (_jsx(Button, { size: "xs", variant: "outline", disabled: onOpen === undefined, onClick: () => {
23
- if (row.open !== undefined)
24
- onOpen?.(row.open);
25
- }, children: "Open" })), row.actions?.map((action) => {
26
- const state = actions.state.get(action);
27
- return (_jsx(Button, { size: "xs", variant: "outline", className: "shrink-0", disabled: !actions.enabled || state?.pending === true, loading: state?.pending === true, onClick: () => {
28
- void actions.execute(entryId, action);
29
- }, children: action.label }, action.id));
30
- })] }), row.inspection !== undefined &&
31
- inspection?.entryId === entryId &&
32
- inspection.rowId === row.id ? (_jsxs("section", { "aria-label": row.title, "data-client-inspection": row.id, children: [_jsx(DataListHeading, { aside: _jsx(Button, { size: "xs", variant: "ghost", onClick: () => {
33
- store.inspect(undefined);
34
- }, children: "Close" }), children: row.title }), _jsx(DataList, { density: "compact", tabIndex: 0, className: "max-h-64 overflow-y-auto overscroll-contain", children: row.inspection.rows.map((child) => (_jsxs(DataRow, { children: [_jsx(Status, { glyph: "dot", size: "compact", tone: child.tone === "settled"
35
- ? "success"
36
- : child.tone === "running"
37
- ? "info"
38
- : child.tone === "error"
39
- ? "error"
40
- : child.tone === "attention"
41
- ? "warning"
42
- : "neutral", label: child.title }), child.detail === undefined ? null : (_jsx("span", { children: child.detail }))] }, child.id))) })] })) : null, row.actions?.map((action) => {
43
- const error = actions.state.get(action)?.error;
44
- return error === undefined ? null : (_jsx("p", { className: "text-error", role: "alert", children: error }, action.id));
45
- })] }, row.id))) }));
20
+ return (_jsx("div", { className: "px-3 text-xs text-base-content", "data-client-entry": entryId, children: rows.map((row) => {
21
+ // A row that only opens a waiting decision is one tap target: the
22
+ // whole line, so its key never outgrows the line's text.
23
+ const decision = row.open === undefined &&
24
+ row.inspection === undefined &&
25
+ row.actions?.length === 1 &&
26
+ row.actions[0]?.awaiting !== undefined
27
+ ? row.actions[0]
28
+ : undefined;
29
+ if (decision !== undefined) {
30
+ const state = actions.state.get(decision);
31
+ return (_jsxs("div", { "data-client-row": row.id, children: [_jsxs(Button, { size: "xs", variant: "ghost", className: "-mx-xs flex w-[calc(100%+2*var(--spacing-xs))] justify-start gap-2 px-xs font-normal", disabled: !actions.enabled || state?.pending === true, loading: state?.pending === true, onClick: () => {
32
+ void actions.execute(entryId, decision);
33
+ }, children: [_jsx("span", { "aria-hidden": "true", "data-client-row-dot": "", className: `w-3 shrink-0 text-center ${row.tone === undefined ? "" : rowColors[row.tone]}`, children: row.tone === undefined ? "" : "●" }), _jsxs("span", { className: `min-w-0 flex-1 truncate text-start ${row.tone === undefined ? "" : rowColors[row.tone]}`, children: [row.title, row.detail === undefined ? null : (_jsxs("span", { className: "text-dim", children: [" \u00B7 ", row.detail] }))] }), _jsx("span", { className: "shrink-0 text-dim", "data-client-row-action": "", children: decision.label })] }), state?.error === undefined ? null : (_jsx("p", { className: "text-error", role: "alert", children: state.error }))] }, row.id));
34
+ }
35
+ return (_jsxs("div", { "data-client-row": row.id, className: "py-1", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { "aria-hidden": "true", "data-client-row-dot": "", className: `w-3 shrink-0 text-center leading-4 ${row.tone === undefined ? "" : rowColors[row.tone]}`, children: row.tone === undefined ? "" : "●" }), _jsxs("details", { className: "min-w-0 flex-1", children: [_jsxs("summary", { className: `cursor-pointer break-words leading-4 ${row.tone === undefined ? "" : rowColors[row.tone]}`, children: [row.title, row.inspection === undefined || row.detail === undefined
36
+ ? null
37
+ : ` · ${row.detail}`] }), row.detail === undefined ? null : (_jsx("p", { className: "max-h-32 overflow-y-auto overscroll-contain whitespace-pre-wrap break-words pb-2", children: row.detail }))] }), row.open === undefined ? null : (_jsx(Button, { size: "xs", variant: "outline", disabled: onOpen === undefined, onClick: () => {
38
+ if (row.open !== undefined)
39
+ onOpen?.(row.open);
40
+ }, children: "Open" })), row.actions?.map((action) => {
41
+ const state = actions.state.get(action);
42
+ return (_jsx(Button, { size: "xs", variant: "outline", className: "shrink-0", disabled: !actions.enabled || state?.pending === true, loading: state?.pending === true, onClick: () => {
43
+ void actions.execute(entryId, action);
44
+ }, children: action.label }, action.id));
45
+ })] }), row.inspection !== undefined &&
46
+ inspection?.entryId === entryId &&
47
+ inspection.rowId === row.id ? (_jsxs("section", { "aria-label": row.title, "data-client-inspection": row.id, children: [_jsx(DataListHeading, { aside: _jsx(Button, { size: "xs", variant: "ghost", onClick: () => {
48
+ store.inspect(undefined);
49
+ }, children: "Close" }), children: row.title }), _jsx(DataList, { density: "compact", tabIndex: 0, className: "max-h-64 overflow-y-auto overscroll-contain", children: row.inspection.rows.map((child) => (_jsxs(DataRow, { children: [_jsx(Status, { glyph: "dot", size: "compact", tone: child.tone === "settled"
50
+ ? "success"
51
+ : child.tone === "running"
52
+ ? "info"
53
+ : child.tone === "error"
54
+ ? "error"
55
+ : child.tone === "attention"
56
+ ? "warning"
57
+ : "neutral", label: child.title }), child.detail === undefined ? null : (_jsx("span", { children: child.detail }))] }, child.id))) })] })) : null, row.actions?.map((action) => {
58
+ const error = actions.state.get(action)?.error;
59
+ return error === undefined ? null : (_jsx("p", { className: "text-error", role: "alert", children: error }, action.id));
60
+ })] }, row.id));
61
+ }) }));
46
62
  }
@@ -0,0 +1,54 @@
1
+ import type { KeyboardEvent, ReactNode } from "react";
2
+ /** One row of a completion menu. */
3
+ export type CompletionRow = Readonly<{
4
+ key: string;
5
+ label: ReactNode;
6
+ detail?: string | undefined;
7
+ /** Key hint shown at the row's end. */
8
+ keys?: string | undefined;
9
+ /** Decorative leading visual, such as a thumbnail. */
10
+ media?: ReactNode;
11
+ disabled?: boolean | undefined;
12
+ }>;
13
+ /** Stable id of one completion row, for `aria-activedescendant`. */
14
+ export declare function completionOptionId(listId: string, index: number): string;
15
+ /** What a key press asks of an open completion menu. */
16
+ export type CompletionKeyAction = "next" | "previous" | "accept" | "dismiss" | null;
17
+ /**
18
+ * The one reading of keys for a completion menu whose focus stays in the text
19
+ * field: arrows move, Enter or Tab accept, Escape dismisses. Shift+Enter is
20
+ * a newline, never an accept.
21
+ */
22
+ export declare function completionKeyAction(event: Pick<KeyboardEvent, "key" | "shiftKey">): CompletionKeyAction;
23
+ /** The ARIA attributes a combobox text field carries for an open menu. */
24
+ export declare function completionFieldProps(menu: {
25
+ open: boolean;
26
+ listId: string;
27
+ selected: number;
28
+ }): {
29
+ readonly role: "combobox";
30
+ readonly "aria-autocomplete": "list";
31
+ readonly "aria-expanded": boolean;
32
+ readonly "aria-controls": string | undefined;
33
+ readonly "aria-activedescendant": string | undefined;
34
+ };
35
+ /**
36
+ * The shared completion menu: a Caps popover with listbox rows. It never
37
+ * takes focus; the text field keeps typing and points at the highlighted row
38
+ * with `aria-activedescendant`. Grove's slash commands and Prints' slash and
39
+ * `@character` menus all draw it.
40
+ */
41
+ export declare function CompletionMenu({ open, rows, selected, listId, label, kind, hint, empty, onHighlight, onChoose, }: Readonly<{
42
+ open: boolean;
43
+ rows: readonly CompletionRow[];
44
+ selected: number;
45
+ listId: string;
46
+ /** Accessible name of the listbox. */
47
+ label: string;
48
+ kind: string;
49
+ hint?: string | undefined;
50
+ /** Shown instead of rows when there are none; omitted draws nothing. */
51
+ empty?: string | undefined;
52
+ onHighlight: (index: number) => void;
53
+ onChoose: (index: number) => void;
54
+ }>): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Popover, PopoverOption, } from "@fungi.computer/caps/components/popover";
3
+ /** Stable id of one completion row, for `aria-activedescendant`. */
4
+ export function completionOptionId(listId, index) {
5
+ return `${listId}-${String(index)}`;
6
+ }
7
+ /**
8
+ * The one reading of keys for a completion menu whose focus stays in the text
9
+ * field: arrows move, Enter or Tab accept, Escape dismisses. Shift+Enter is
10
+ * a newline, never an accept.
11
+ */
12
+ export function completionKeyAction(event) {
13
+ switch (event.key) {
14
+ case "ArrowDown":
15
+ return "next";
16
+ case "ArrowUp":
17
+ return "previous";
18
+ case "Enter":
19
+ return event.shiftKey ? null : "accept";
20
+ case "Tab":
21
+ return "accept";
22
+ case "Escape":
23
+ return "dismiss";
24
+ default:
25
+ return null;
26
+ }
27
+ }
28
+ /** The ARIA attributes a combobox text field carries for an open menu. */
29
+ export function completionFieldProps(menu) {
30
+ return {
31
+ role: "combobox",
32
+ "aria-autocomplete": "list",
33
+ "aria-expanded": menu.open,
34
+ "aria-controls": menu.open ? menu.listId : undefined,
35
+ "aria-activedescendant": menu.open
36
+ ? completionOptionId(menu.listId, menu.selected)
37
+ : undefined,
38
+ };
39
+ }
40
+ /**
41
+ * The shared completion menu: a Caps popover with listbox rows. It never
42
+ * takes focus; the text field keeps typing and points at the highlighted row
43
+ * with `aria-activedescendant`. Grove's slash commands and Prints' slash and
44
+ * `@character` menus all draw it.
45
+ */
46
+ export function CompletionMenu({ open, rows, selected, listId, label, kind, hint, empty, onHighlight, onChoose, }) {
47
+ return (_jsx(Popover, { open: open, side: "top", "data-parakeet-slash": "", children: _jsxs("div", { className: "parakeet-slash-menu", "data-kind": kind, children: [hint === undefined ? null : (_jsx("p", { className: "parakeet-slash-hint", children: hint })), rows.length === 0 ? (empty === undefined ? null : (_jsx("p", { className: "parakeet-slash-hint", children: empty }))) : (_jsx("div", { id: listId, role: "listbox", "aria-label": label, children: rows.map((row, index) => (_jsxs(PopoverOption, { id: completionOptionId(listId, index), active: index === selected, "aria-disabled": row.disabled === true ? true : undefined, "data-disabled": row.disabled === true, onMouseEnter: () => {
48
+ onHighlight(index);
49
+ }, onClick: () => {
50
+ onChoose(index);
51
+ }, children: [row.media === undefined ? null : (_jsx("span", { "aria-hidden": "true", className: "parakeet-slash-media", children: row.media })), _jsx("span", { className: "parakeet-slash-label", children: row.label }), _jsx("span", { className: "parakeet-slash-detail", children: row.detail }), row.keys === undefined || row.keys.length === 0 ? null : (_jsx("kbd", { className: "parakeet-slash-keys", children: row.keys }))] }, row.key))) }))] }) }));
52
+ }
@@ -0,0 +1,30 @@
1
+ import type { AwaitingItem, DecisionIntent, DecisionView } from "../core/awaiting-answer.js";
2
+ /** Props for the decision that has taken over the composer. */
3
+ export type DecisionComposerProps = Readonly<{
4
+ /** The waiting decision to answer. */
5
+ item: AwaitingItem;
6
+ /** One-based place among every waiting decision. */
7
+ position: number;
8
+ /** Every waiting decision. */
9
+ total: number;
10
+ /** Postpone the decision; it waits in the footer row. */
11
+ onLater: () => void;
12
+ /** The person was typing in the composer, so focus follows into the list. */
13
+ takeFocus: boolean;
14
+ }>;
15
+ /**
16
+ * The composer while the Agent waits on the person: the decision's rows,
17
+ * an Other line, and Send / Later. It binds one waiting decision to its
18
+ * reducer and draws the result.
19
+ */
20
+ export declare function DecisionComposer({ item, position, total, onLater, takeFocus, }: DecisionComposerProps): import("react/jsx-runtime").JSX.Element | null;
21
+ /**
22
+ * Draw Parakeet's decision view with Caps parts and forward keys and clicks as
23
+ * intents; the rules live in the reducer, so every surface answers alike.
24
+ */
25
+ export declare function DecisionPanel({ view, dispatch, onLater, takeFocus, }: Readonly<{
26
+ view: DecisionView;
27
+ dispatch: (intent: DecisionIntent) => void;
28
+ onLater: () => void;
29
+ takeFocus: boolean;
30
+ }>): import("react/jsx-runtime").JSX.Element;