@fungi.computer/parakeet 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +17 -1
  2. package/dist/components/ChatComposer.js +66 -60
  3. package/dist/components/CodeBlock.js +3 -2
  4. package/dist/components/ComposerModelKey.d.ts +11 -0
  5. package/dist/components/ComposerModelKey.js +9 -0
  6. package/dist/components/ComposerShell.d.ts +47 -0
  7. package/dist/components/ComposerShell.js +29 -0
  8. package/dist/components/DecisionComposer.js +1 -2
  9. package/dist/components/TimelineItem.js +1 -1
  10. package/dist/components/ToolGroup.js +2 -2
  11. package/dist/conversation-opening.d.ts +1 -1
  12. package/dist/conversation-opening.js +2 -2
  13. package/dist/conversation.js +12 -9
  14. package/dist/core/build-timeline.js +5 -2
  15. package/dist/core/composer-model-name.d.ts +3 -0
  16. package/dist/core/composer-model-name.js +21 -0
  17. package/dist/core/display-text.d.ts +2 -0
  18. package/dist/core/display-text.js +7 -0
  19. package/dist/core/draft-storage.d.ts +2 -0
  20. package/dist/core/draft-storage.js +14 -1
  21. package/dist/core/session-projection.d.ts +1 -0
  22. package/dist/core/session-projection.js +3 -2
  23. package/dist/core/session-view-state.d.ts +2 -4
  24. package/dist/core/session-view-state.js +33 -13
  25. package/dist/core/tool-groups.d.ts +3 -0
  26. package/dist/core/tool-groups.js +37 -1
  27. package/dist/core/tool-view.d.ts +6 -0
  28. package/dist/core/tool-view.js +33 -0
  29. package/dist/core/use-timeline-item-key.d.ts +3 -4
  30. package/dist/core/use-timeline-item-key.js +8 -14
  31. package/dist/headless.d.ts +4 -3
  32. package/dist/headless.js +4 -3
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.js +3 -0
  35. package/dist/parakeet.js +9 -11
  36. package/dist/store/types.d.ts +9 -0
  37. package/dist/styles.css +581 -114
  38. package/docs/api/headless.md +124 -41
  39. package/docs/api/index.md +153 -1
  40. package/package.json +11 -8
  41. package/src/completion-menu.css +2 -1
  42. package/src/composer-shell.css +125 -0
  43. package/src/styles.css +5 -87
  44. package/dist/components/WaitingNotice.d.ts +0 -7
  45. package/dist/components/WaitingNotice.js +0 -8
package/README.md CHANGED
@@ -60,7 +60,7 @@ export function Conversation({ session }: { session: ParakeetSession }) {
60
60
  Parakeet never reads a message's host metadata itself. To show who wrote a line,
61
61
  pass `authorOf`: your reading of that metadata into a `ParakeetAuthor`
62
62
  (`{ id, name }`). A Fungi app passes `messageAuthor` from
63
- `@fungi.computer/hub-client/message-meta`; without it, lines show no author.
63
+ `@fungi.computer/agar/agents`; without it, lines show no author.
64
64
 
65
65
  The view presents the Session's observed history, live work, composer and
66
66
  errors. Screen readers receive the same Session status as the composer footer
@@ -78,6 +78,22 @@ provider, query cache, observations and prompt admission with typed fixtures.
78
78
  The browser contract uses the real component with a fixture Session. A fake
79
79
  Session checks the component, not hosted authentication or a model provider.
80
80
 
81
+ ## Shared composer
82
+
83
+ `ComposerShell` is the shared chat and generation composer surface. It owns the
84
+ Card, field inset, height limits and one non-wrapping control row; callers
85
+ supply their field, controls, tray contents and action, not another toolbar
86
+ layout. `useComposerTextareaSize(ref, value)` grows either field within those
87
+ shared limits. The full stylesheet includes this layout; hosts compiling their
88
+ own Caps sheet import `@fungi.computer/parakeet/composer-shell.css` alongside
89
+ the completion-menu stylesheet.
90
+
91
+ The real Grove/Prints workbench proof is
92
+ `pnpm --filter @fungi.computer/grove test:composer-parity`. It requires both
93
+ workbenches (`GROVE_WORKBENCH_URL` / `PRINTS_WORKBENCH_URL`) and checks equal
94
+ geometry, a right-edge Send key and no wrapped controls at 390px in both themes.
95
+ `COMPOSER_PARITY_SHOTS` optionally saves the six matching side-by-side images.
96
+
81
97
  ## Core concepts
82
98
 
83
99
  | Surface | What it owns |
@@ -1,20 +1,18 @@
1
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
- import { formatModelIdentity } from "../core/model-identity.js";
3
+ import { composerModelName } from "../core/composer-model-name.js";
4
4
  import { formatContextUsage } from "../core/format-context.js";
5
- import { Composer } from "@fungi.computer/caps/blocks/composer";
6
5
  import { isImeComposing } from "@fungi.computer/caps/lib/ime";
7
6
  import { RunFailureNotice } from "./RunFailureNotice.js";
8
- import { SendAction, } from "@fungi.computer/caps/components/send-action";
7
+ import { SendAction } from "@fungi.computer/caps/components/send-action";
9
8
  import { formatMoney } from "@fungi.computer/caps/lib/format-money";
10
- import { PopoverAnchor } from "@fungi.computer/caps/components/popover";
11
9
  import { Status } from "@fungi.computer/caps/components/status";
12
10
  import { Button } from "@fungi.computer/caps/components/button";
13
- import { Card } from "@fungi.computer/caps/components/card";
14
11
  import { IconButton } from "@fungi.computer/caps/components/icon-button";
15
12
  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";
13
+ import { ComposerModelKey } from "./ComposerModelKey.js";
14
+ import { StatusTrayList, StatusTrayRow, } from "@fungi.computer/caps/blocks/status-tray";
15
+ import { Plus, SlidersHorizontal, X } from "lucide-react";
18
16
  import { memo, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react";
19
17
  import { Textarea } from "@fungi.computer/caps/components/textarea";
20
18
  import { formatHints } from "../commands/hints.js";
@@ -22,6 +20,7 @@ import { awaitingTakeover } from "../core/awaiting-answer.js";
22
20
  import { completionFieldProps, completionKeyAction } from "./CompletionMenu.js";
23
21
  import { DecisionComposer } from "./DecisionComposer.js";
24
22
  import { SlashMenu } from "./SlashMenu.js";
23
+ import { ComposerShell, composerSendState, useComposerTextareaSize, } from "./ComposerShell.js";
25
24
  const presentations = {
26
25
  connecting: {
27
26
  busy: false,
@@ -79,13 +78,7 @@ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, s
79
78
  if (textarea !== null)
80
79
  textarea.setSelectionRange(textarea.value.length, textarea.value.length);
81
80
  }, [recallState, textareaRef]);
82
- useLayoutEffect(() => {
83
- const textarea = textareaRef.current;
84
- if (!textarea)
85
- return;
86
- textarea.style.height = "auto";
87
- textarea.style.height = `${String(Math.min(textarea.scrollHeight, 320))}px`;
88
- }, [textareaRef, value]);
81
+ useComposerTextareaSize(textareaRef, value);
89
82
  const saveAndSend = useCallback(() => {
90
83
  const draft = latestValue.current.trim();
91
84
  if (!draft && (queuedRecall?.images?.length ?? 0) === 0)
@@ -210,7 +203,7 @@ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, s
210
203
  open: listOpen,
211
204
  listId: slashListId,
212
205
  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) => {
206
+ })), className: "font-sans text-base-content", id: textareaId, onChange: (event) => {
214
207
  onChange(event.target.value);
215
208
  setHistoryIndex(-1);
216
209
  }, onKeyDown: onKeyDown, placeholder: placeholder ??
@@ -266,26 +259,27 @@ const settingsCommands = [
266
259
  { alias: "compact", label: "Compact context" },
267
260
  ];
268
261
  /**
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.
262
+ * Session controls and Settings content; ComposerShell owns their row.
272
263
  */
273
- function ComposerToolbar({ statusChips, model, status, viewState, usage, context, slash, attach, send, }) {
264
+ function useComposerRow({ showFooter, statusChips, model, status, viewState, usage, context, slash, attach, send, }) {
274
265
  const [openId, setOpenId] = useState(null);
266
+ // Footer visibility belongs here, before deriving any internal readouts.
267
+ if (!showFooter)
268
+ return undefined;
275
269
  const tone = viewState.kind === "error" ? "error" : presentations[status].tone;
276
270
  // Admission must not replace an outstanding owner attention readout.
277
271
  const label = sessionReadout("idle", viewState);
278
272
  const needsAttention = viewState.kind === "waiting_for_you" || viewState.kind === "error";
279
- const modelName = model === undefined ? undefined : formatModelIdentity(model);
280
- const canPickModel = slash?.available("model") === true;
273
+ const modelName = model === undefined ? undefined : composerModelName(model);
281
274
  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);
275
+ const usageText = usage === undefined
276
+ ? undefined
277
+ : `${formatTokens(usage.tokens)} · ${formatMoney(usage.cost)}`;
287
278
  const commands = settingsCommands.filter(({ alias }) => slash?.available(alias) === true);
288
- const items = summary.length === 0 && commands.length === 0 && modelName === undefined
279
+ const items = usageText === undefined &&
280
+ contextText === undefined &&
281
+ commands.length === 0 &&
282
+ modelName === undefined
289
283
  ? []
290
284
  : [
291
285
  {
@@ -293,21 +287,29 @@ function ComposerToolbar({ statusChips, model, status, viewState, usage, context
293
287
  label: (_jsxs("span", { className: "composer-settings-chip", children: [_jsx(SlidersHorizontal, { "aria-hidden": "true" }), "Settings"] })),
294
288
  accessibleLabel: "Session settings",
295
289
  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: () => {
290
+ 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" })), usageText === undefined ? null : (_jsx(StatusTrayRow, { primary: "Usage", secondary: usageText })), commands.map(({ alias, label: commandLabel }) => (_jsx(StatusTrayRow, { primary: commandLabel, actions: _jsx(Button, { size: "xs", variant: "ghost", onClick: () => {
297
291
  setOpenId(null);
298
292
  slash?.runCommand(alias);
299
293
  }, children: "Open" }) }, alias)))] })),
300
294
  },
301
295
  ];
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] }) }));
296
+ return {
297
+ items,
298
+ openId,
299
+ onOpenChange: setOpenId,
300
+ action: send,
301
+ controls: (_jsxs(_Fragment, { children: [attach, modelName === undefined ? null : (_jsx(ComposerModelKey, { "aria-label": `Model: ${modelName}. Open settings`, "aria-haspopup": "dialog", expanded: openId === "settings", label: modelName, onClick: () => {
302
+ setOpenId((id) => (id === "settings" ? null : "settings"));
303
+ }, title: `${modelName} · Settings` })), statusChips?.map((chip, index) => (_jsx("span", { className: "composer-status-chip min-w-0 truncate", title: chip, children: chip }, index)))] })),
304
+ status: needsAttention ? (_jsx(Status, { className: "parakeet-composer-status", label: _jsx("span", { className: "composer-attention-label", children: label }), title: label, tone: tone })) : null,
305
+ readout: usageText === undefined ? undefined : (_jsx("span", { title: "Tokens \u00B7 cost", children: usageText })),
306
+ };
305
307
  }
306
308
  /** Images riding with the draft, drawn as small tiles above the text. */
307
309
  function ComposerAttachments({ images, onRemove, }) {
308
310
  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
311
  onRemove(index);
310
- } }))] }, index))) }));
312
+ }, size: "xs", variant: "outline" }))] }, index))) }));
311
313
  }
312
314
  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, }) {
313
315
  const textareaId = useId();
@@ -332,16 +334,17 @@ export const ChatComposer = memo(function ChatComposer({ queuedRecall, onImagesC
332
334
  }, [takeoverKey, restoreFocus, textareaRef]);
333
335
  const images = queuedRecall?.images ?? [];
334
336
  const recallBusy = queuedRecall?.recallState.kind === "recalling";
335
- const cannotSend = recallBusy || (draft.trim().length === 0 && images.length === 0);
336
337
  // One key in one place: Send when idle, Stop while the Agent works.
337
338
  // Enter still queues a typed follow-up during a run.
338
339
  // One key says what Enter does: Send when idle; while the Agent works,
339
340
  // 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";
341
+ const sendState = composerSendState({
342
+ draft,
343
+ hasAttachments: images.length > 0,
344
+ busy,
345
+ queueWhileBusy: true,
346
+ blocked: recallBusy,
347
+ });
345
348
  const sendLabel = busy ? "Queue follow-up" : "Send message";
346
349
  const sendAction = (_jsx(SendAction, { "aria-label": sendLabel, className: "composer-send-key", disabled: sendState === "busy" &&
347
350
  (recallBusy || status === "queued" || status === "cancelling"), onClick: () => {
@@ -358,30 +361,33 @@ export const ChatComposer = memo(function ChatComposer({ queuedRecall, onImagesC
358
361
  }, 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
362
  // Visitors see only the field and the send key, never internal readouts.
360
363
  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
- }
364
+ footerProps.statusChips = statusChips;
365
+ if (model)
366
+ footerProps.model = model;
367
+ if (usage)
368
+ footerProps.usage = usage;
369
+ if (context)
370
+ footerProps.context = context;
371
+ const row = useComposerRow({
372
+ ...footerProps,
373
+ showFooter,
374
+ slash,
375
+ attach,
376
+ send: sendAction,
377
+ });
370
378
  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
379
  ? "One prompt is already queued; it sends when the service is back."
372
380
  : "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
381
  ? ""
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) }))] }));
382
+ : `Agent ${takeover.kind} ${String(slash.awaiting.position)} of ${String(slash.awaiting.total)} needs your answer` }), _jsx(ComposerShell, { "aria-label": "Message composer", action: takeover === undefined && row === undefined ? sendAction : undefined, menu: slash === undefined ? undefined : (_jsx(SlashMenu, { slash: slash, listId: slashListId })), attachments: takeover === undefined && images.length > 0 ? (_jsx(ComposerAttachments, { images: images, onRemove: onImagesChange === undefined
383
+ ? undefined
384
+ : (index) => {
385
+ onImagesChange(images.filter((_image, at) => at !== index));
386
+ } })) : undefined, input: _jsxs(_Fragment, { children: [takeover === undefined || slash === undefined ? null : (_jsx(DecisionComposer, { item: takeover, position: slash.awaiting.position, total: slash.awaiting.total, onLater: () => {
387
+ slash.store.awaiting.later(takeover.key);
388
+ }, takeFocus: hadFocus.current }, takeover.key)), _jsx("div", { hidden: takeover !== undefined, children: _jsx(HostComposerTextarea, { queuedRecall: queuedRecall, placeholder: placeholder, onChange: onChange, onAbort: onAbort, onSend: () => {
389
+ onSend(busy ? "followUp" : "steer");
390
+ }, slash: slash, slashListId: slashListId, textareaId: textareaId, textareaRef: textareaRef, value: draft }) })] }), row:
391
+ // A decision brings its own Later and Send row to the bottom edge.
392
+ takeover !== undefined ? undefined : row }), slash === undefined ? null : (_jsx("p", { className: "sr-only", id: `${textareaId}-guidance`, children: formatHints(slash.hints) }))] }));
387
393
  });
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { IconButton } from "@fungi.computer/caps/components/icon-button";
2
3
  import { Check, Copy } from "lucide-react";
3
4
  import { useState } from "react";
4
5
  import { reportFailure } from "../lib/failure.js";
@@ -27,9 +28,9 @@ export function CodeBlock({ className, content, header, hideCopy, language, maxH
27
28
  color: "var(--color-base-content)",
28
29
  ...style,
29
30
  };
30
- return (_jsxs("div", { className: `relative not-prose ${className ?? ""}`, style: containerStyle, children: [header ? (_jsx("div", { className: "text-dim px-2 pt-1.5 pb-1 text-[9px] uppercase tracking-wider", children: header })) : null, !hideCopy ? (_jsx("button", { "aria-label": "Copy to clipboard", className: "btn btn-circle btn-ghost btn-xs text-dim absolute top-1 right-1 z-10 hover:text-base-content", onClick: () => {
31
+ return (_jsxs("div", { className: `relative not-prose ${className ?? ""}`, style: containerStyle, children: [header ? (_jsx("div", { className: "text-dim px-2 pt-1.5 pb-1 text-[9px] uppercase tracking-wider", children: header })) : null, !hideCopy ? (_jsx(IconButton, { "aria-label": "Copy to clipboard", className: "absolute top-1 right-1 z-10", icon: copyState === "copied" ? _jsx(Check, {}) : _jsx(Copy, {}), onClick: () => {
31
32
  void handleCopy();
32
- }, type: "button", children: copyState === "copied" ? _jsx(Check, { size: 10 }) : _jsx(Copy, { size: 10 }) })) : null, !hideCopy ? (_jsx("span", { "aria-live": "polite", className: "sr-only", role: "status", children: copyState === "failed" ? "Copy to clipboard failed." : null })) : null, _jsx("div", { className: "overflow-y-auto text-[10px]", "data-scrollable": true, style: {
33
+ }, type: "button" })) : null, !hideCopy ? (_jsx("span", { "aria-live": "polite", className: "sr-only", role: "status", children: copyState === "failed" ? "Copy to clipboard failed." : null })) : null, _jsx("div", { className: "overflow-y-auto text-[10px]", "data-scrollable": true, style: {
33
34
  maxHeight: maxHeight === "0" ? undefined : maxHeight,
34
35
  padding: "6px 4px",
35
36
  }, children: innerHtml === null ? (_jsxs("pre", { className: "whitespace-pre-wrap break-words font-mono text-base-content", style: { margin: 0 }, children: [content, content.length > HIGHLIGHT_THRESHOLD ? (_jsx("span", { className: "text-dim ml-2 italic", children: "(syntax highlighting skipped for very long content)" })) : null] })) : (_jsx("div", {
@@ -0,0 +1,11 @@
1
+ import { type KeycapButtonProps } from "@fungi.computer/caps/components/keycap";
2
+ /** A model label on the shared native key; callers own selection and menus. */
3
+ export type ComposerModelKeyProps = Omit<KeycapButtonProps, "icon" | "readout" | "label"> & {
4
+ /** Friendly catalog name, or the real model identity when no name is known. */
5
+ label: string;
6
+ };
7
+ /** The same model face and chevron in chat and generation composers. */
8
+ export declare const ComposerModelKey: import("react").ForwardRefExoticComponent<Omit<KeycapButtonProps, "label" | "icon" | "readout"> & {
9
+ /** Friendly catalog name, or the real model identity when no name is known. */
10
+ label: string;
11
+ } & import("react").RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
3
+ import { ChevronDown } from "lucide-react";
4
+ import { KeycapButton, } from "@fungi.computer/caps/components/keycap";
5
+ import { cn } from "@fungi.computer/caps/lib/cn";
6
+ /** The same model face and chevron in chat and generation composers. */
7
+ export const ComposerModelKey = forwardRef(function ComposerModelKey({ className, label, ...props }, ref) {
8
+ return (_jsx(KeycapButton, { ...props, ref: ref, className: cn("parakeet-composer-model-key", className), label: label, readout: _jsx(ChevronDown, { "aria-hidden": "true", className: "size-[var(--stipe-icon-size-sm)] shrink-0" }) }));
9
+ });
@@ -0,0 +1,47 @@
1
+ import { type HTMLAttributes, type ReactNode, type RefObject } from "react";
2
+ import { type StatusTrayItem } from "@fungi.computer/caps/blocks/status-tray";
3
+ import type { SendActionState } from "@fungi.computer/caps/components/send-action";
4
+ /** One input-to-action rule for both Apps; Caps owns the resulting key's look. */
5
+ export declare function composerSendState({ draft, hasAttachments, busy, queueWhileBusy, blocked, }: {
6
+ draft: string;
7
+ hasAttachments?: boolean;
8
+ busy: boolean;
9
+ /** Chat can queue a follow-up; generation stays busy until it settles. */
10
+ queueWhileBusy?: boolean;
11
+ /** An input owner can temporarily block submission, such as during recall. */
12
+ blocked?: boolean;
13
+ }): SendActionState;
14
+ /** App-owned controls and tray contents; Parakeet owns their single row. */
15
+ export type ComposerShellRow = Readonly<{
16
+ /** App-specific leading keys and status indicators. */
17
+ controls: ReactNode;
18
+ /** Send or Generate key, held at the row's right edge. */
19
+ action: ReactNode;
20
+ /** Persistent status beside usage and Send, never between leading keys. */
21
+ status?: ReactNode;
22
+ /** Quiet context summary; hidden when the shell is narrow. */
23
+ readout?: ReactNode;
24
+ /** Native tray keys and panels supplied by the App. */
25
+ items: readonly StatusTrayItem[];
26
+ /** Open tray item, or null when the tray is closed. */
27
+ openId: string | null;
28
+ /** Delegate tray selection to the caller's state owner. */
29
+ onOpenChange: (id: string | null) => void;
30
+ }>;
31
+ /** Content slots for the shared chat and generation composer surface. */
32
+ export type ComposerShellProps = HTMLAttributes<HTMLDivElement> & {
33
+ /** Draft field or decision surface, using the shared input geometry. */
34
+ input: ReactNode;
35
+ /** Optional draft attachments rendered by the Composer primitive. */
36
+ attachments?: ReactNode;
37
+ /** Retained field-and-Send mode without an internal control row. */
38
+ action?: ReactNode;
39
+ /** Optional completion menu anchored to this shell. */
40
+ menu?: ReactNode;
41
+ /** Omit while a decision owns the input and its own actions. */
42
+ row?: ComposerShellRow | undefined;
43
+ };
44
+ /** One field surface and one non-wrapping control row, with Send at its end. */
45
+ export declare function ComposerShell({ attachments, action, children, className, input, menu, row, ...props }: ComposerShellProps): import("react/jsx-runtime").JSX.Element;
46
+ /** Grow either App's field with its draft; shell CSS owns the height limits. */
47
+ export declare function useComposerTextareaSize(ref: RefObject<HTMLTextAreaElement>, value: string): void;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useLayoutEffect, } from "react";
3
+ import { Card } from "@fungi.computer/caps/components/card";
4
+ import { Composer } from "@fungi.computer/caps/blocks/composer";
5
+ import { PopoverAnchor } from "@fungi.computer/caps/components/popover";
6
+ import { StatusTray, } from "@fungi.computer/caps/blocks/status-tray";
7
+ import { cn } from "@fungi.computer/caps/lib/cn";
8
+ import { KeycapButton } from "@fungi.computer/caps/components/keycap";
9
+ /** One input-to-action rule for both Apps; Caps owns the resulting key's look. */
10
+ export function composerSendState({ draft, hasAttachments = false, busy, queueWhileBusy = false, blocked = false, }) {
11
+ const hasInput = !blocked && (draft.trim().length > 0 || hasAttachments);
12
+ if (busy && (!queueWhileBusy || !hasInput))
13
+ return "busy";
14
+ return hasInput ? "ready" : "empty";
15
+ }
16
+ /** One field surface and one non-wrapping control row, with Send at its end. */
17
+ export function ComposerShell({ attachments, action, children, className, input, menu, row, ...props }) {
18
+ return (_jsxs(PopoverAnchor, { className: "parakeet-composer-anchor", children: [menu, _jsxs(Card, { ...props, className: cn("parakeet-composer-shell", className), children: [_jsx(Composer, { action: action, attachments: attachments, input: input, toolbar: row === undefined ? undefined : (_jsx(StatusTray, { className: "parakeet-composer-row", align: "start", items: row.items, openId: row.openId, onOpenChange: row.onOpenChange, renderTrigger: ({ children: label, ...trigger }) => (_jsx(KeycapButton, { ...trigger, label: label, expanded: trigger["aria-expanded"] === true })), leading: row.controls, trailing: _jsxs("span", { className: "parakeet-composer-trailing", children: [row.status, row.readout == null ? null : (_jsx("span", { className: "parakeet-composer-readout", children: row.readout })), _jsx("span", { className: "parakeet-composer-action", children: row.action })] }) })) }), children] })] }));
19
+ }
20
+ /** Grow either App's field with its draft; shell CSS owns the height limits. */
21
+ export function useComposerTextareaSize(ref, value) {
22
+ useLayoutEffect(() => {
23
+ const field = ref.current;
24
+ if (!field)
25
+ return;
26
+ field.style.height = "auto";
27
+ field.style.height = `${String(field.scrollHeight)}px`;
28
+ }, [ref, value]);
29
+ }
@@ -1,5 +1,4 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Button } from "@fungi.computer/caps/components/button";
3
2
  import { Checkbox } from "@fungi.computer/caps/components/checkbox";
4
3
  import { KeycapButton } from "@fungi.computer/caps/components/keycap";
5
4
  import { Input } from "@fungi.computer/caps/components/input";
@@ -140,7 +139,7 @@ export function DecisionPanel({ view, dispatch, onLater, takeFocus, }) {
140
139
  },
141
140
  }) }), view.status === undefined ? null : (_jsx("p", { className: view.status.tone === "error"
142
141
  ? "text-sm text-error"
143
- : "text-sm opacity-70", role: view.status.tone === "error" ? "alert" : "status", children: view.status.text })), _jsxs("div", { className: "parakeet-decision-actions", children: [_jsx(Button, { variant: "ghost", onClick: onLater, children: "Later" }), _jsx(KeycapButton, { disabled: !view.canSend, label: "Send", onClick: () => {
142
+ : "text-sm opacity-70", role: view.status.tone === "error" ? "alert" : "status", children: view.status.text })), _jsxs("div", { className: "parakeet-decision-actions", children: [_jsx(KeycapButton, { label: "Later", tone: "neutral", onClick: onLater }), _jsx(KeycapButton, { disabled: !view.canSend, label: "Send", onClick: () => {
144
143
  dispatch({ type: "enter" });
145
144
  }, tone: "primary" })] })] }));
146
145
  }
@@ -25,7 +25,7 @@ export const TimelineItem = memo(function TimelineItem({ item, onDismissFailed,
25
25
  onCancelQueued !== undefined ? (_jsx("div", { className: "flex justify-end px-3", children: _jsx(Button, { "aria-label": "Cancel queued message", disabled: cancellingQueued, size: "sm", variant: "ghost", onClick: () => {
26
26
  onCancelQueued(commandId);
27
27
  }, children: "Cancel" }) })) : null;
28
- const dismissAction = requestId === undefined || onDismissFailed === undefined ? null : (_jsx("button", { "aria-label": "Dismiss failed prompt", className: "btn btn-ghost btn-xs", onClick: () => {
28
+ const dismissAction = requestId === undefined || onDismissFailed === undefined ? null : (_jsx(Button, { "aria-label": "Dismiss failed prompt", variant: "outline", size: "xs", onClick: () => {
29
29
  onDismissFailed(requestId);
30
30
  }, type: "button", children: "Dismiss" }));
31
31
  const failed = item.status === "failed" ? (_jsxs("div", { className: "flex items-center gap-2 px-3 text-xs text-error", role: "alert", children: [_jsx("span", { children: item.author?.kind === "agent"
@@ -6,7 +6,7 @@ import { Status } from "@fungi.computer/caps/components/status";
6
6
  import { Sigil } from "@fungi.computer/sigils";
7
7
  import { memo } from "react";
8
8
  import { disclosureOpen, toolCallDefaultOpen, toolGroupDefaultOpen, visibleToolCalls, } from "../core/tool-groups.js";
9
- import { formatToolDuration, toolStatusTone as statusTone, toolKindSigils, } from "../core/tool-view.js";
9
+ import { formatToolDuration, toolStatusTone as statusTone, toolKindSigils, toolLiveLines, } from "../core/tool-view.js";
10
10
  import { CodeBlock } from "./CodeBlock.js";
11
11
  /** Items beyond a section's preview, unless the reader asked for all. */
12
12
  function visible(items, preview, full) {
@@ -69,7 +69,7 @@ function CallRow({ call, tool, disclosureId, size, disclosure, onToggle, renderT
69
69
  const running = call.status === "running";
70
70
  return (_jsxs("div", { "data-parakeet-call": call.kind, "data-status": call.status, children: [_jsx(Disclosure, { leading: _jsx(Sigil, { name: toolKindSigils[call.kind], size: 16 }), onOpenChange: () => {
71
71
  onToggle(disclosureId, defaultOpen);
72
- }, open: open, size: size, summary: _jsxs(_Fragment, { children: [_jsx("span", { className: "text-base-content", children: running ? call.activeVerb : call.verb }), " ", _jsx("span", { className: "font-mono", children: call.label })] }), tone: call.status === "error" ? "error" : "neutral", trailing: _jsxs(_Fragment, { children: [call.exitCode === undefined ? null : (_jsxs(Badge, { size: "sm", tone: "error", children: ["exit ", String(call.exitCode)] })), call.durationMs === undefined ? null : (_jsx("span", { children: formatToolDuration(call.durationMs) })), _jsx(Status, { motion: running ? "bounce" : "none", title: call.status === "unknown" ? "result unavailable" : call.status, tone: statusTone(call.status) })] }), children: _jsx(CallBody, { call: call, disclosure: disclosure, onToggle: onToggle, mediaClaimed: tool !== undefined && claimsToolMedia?.(tool) === true, tool: tool }) }), call.status === "completed" && tool?.result !== undefined
72
+ }, open: open, size: size, summary: _jsxs(_Fragment, { children: [_jsx("span", { className: "text-base-content", children: running ? call.activeVerb : call.verb }), " ", _jsx("span", { className: "font-mono", children: call.label })] }), tone: call.status === "error" ? "error" : "neutral", trailing: _jsxs(_Fragment, { children: [call.exitCode === undefined ? null : (_jsxs(Badge, { size: "sm", tone: "error", children: ["exit ", String(call.exitCode)] })), call.durationMs === undefined ? null : (_jsx("span", { children: formatToolDuration(call.durationMs) })), _jsx(Status, { motion: running ? "bounce" : "none", title: call.status === "unknown" ? "result unavailable" : call.status, tone: statusTone(call.status) })] }), children: _jsx(CallBody, { call: call, disclosure: disclosure, onToggle: onToggle, mediaClaimed: tool !== undefined && claimsToolMedia?.(tool) === true, tool: tool }) }), call.live === undefined ? null : (_jsx("pre", { className: monoBlock, "aria-label": "Live tool output", children: toolLiveLines(call, open).map((line, index) => (_jsx("span", { className: "block whitespace-pre", children: line || "\u00a0" }, String(index)))) })), call.status === "completed" && tool?.result !== undefined
73
73
  ? renderToolActions?.(tool)
74
74
  : null] }));
75
75
  }
@@ -41,7 +41,7 @@ export declare function createConversationOpeningOwner<Session extends ParakeetS
41
41
  /** Only the primary conversation is the selection the host shell sees. */
42
42
  reportsSelection?: boolean;
43
43
  }>): {
44
- select: (agentId: string, sessionId: string, fromOpenRequest?: boolean, waitForBaseline?: boolean) => void;
44
+ select: (agentId: string, sessionId: string, fromOpenRequest?: boolean) => void;
45
45
  clear: () => void;
46
46
  close: () => void;
47
47
  };
@@ -68,7 +68,7 @@ export function createConversationOpeningOwner(options) {
68
68
  let disposed = false;
69
69
  const same = (left, right) => left?.agentId === right.agentId && left.sessionId === right.sessionId;
70
70
  const isCurrent = (opening) => !disposed && pending === opening;
71
- const select = (agentId, sessionId, fromOpenRequest = false, waitForBaseline = true) => {
71
+ const select = (agentId, sessionId, fromOpenRequest = false) => {
72
72
  if (disposed)
73
73
  return;
74
74
  const selection = { agentId, sessionId };
@@ -114,7 +114,7 @@ export function createConversationOpeningOwner(options) {
114
114
  void Promise.allSettled([client.close(), transport.close()]);
115
115
  };
116
116
  await observation.ready;
117
- if (waitForBaseline && options.reportsSelection !== false) {
117
+ if (options.reportsSelection !== false) {
118
118
  await connection.ui.reportAgentSessionSelection({
119
119
  agentId,
120
120
  sessionId,
@@ -9,6 +9,7 @@ import { atom, createStore, } from "jotai/vanilla";
9
9
  import { atomWithInfiniteQuery, atomWithMutation, atomWithQuery, } from "jotai-tanstack-query";
10
10
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore, } from "react";
11
11
  import { buildDurableTimeline, buildTimelineTail, } from "./core/build-timeline.js";
12
+ import { overlayToolOutput } from "./core/tool-groups.js";
12
13
  import { mergeCompactionNotices, mergeCompactions, mergeMessages, } from "./core/session-projection.js";
13
14
  import { sessionHistoryQueryOptions, sessionQueryOptions, } from "./core/session-query.js";
14
15
  import { sameSessionViewState, sessionActivityStatus, sessionViewState, } from "./core/session-view-state.js";
@@ -100,12 +101,15 @@ function acquireDraftStorage(supplied) {
100
101
  const browser = globalThis;
101
102
  const storage = browser.localStorage;
102
103
  return storage === undefined
103
- ? { status: "unavailable" }
104
+ ? {
105
+ status: "unavailable",
106
+ cause: new Error("Browser draft storage is unavailable"),
107
+ }
104
108
  : { status: "available", storage };
105
109
  }
106
- catch {
110
+ catch (cause) {
107
111
  // An opaque-origin sandbox denies even reading the browser's getter.
108
- return { status: "unavailable" };
112
+ return { status: "unavailable", cause };
109
113
  }
110
114
  }
111
115
  function createConversationAtoms(session, queryClient, authorOf, interactions, draftStorage, draftScope, contributions, shown) {
@@ -535,7 +539,9 @@ function createConversationAtoms(session, queryClient, authorOf, interactions, d
535
539
  hasMoreHistory: get(hasMoreHistoryAtom),
536
540
  hasNewerHistory: get(hasNewerHistoryAtom),
537
541
  loadingOlderHistory: get(loadingOlderHistoryAtom),
538
- settled: durable.items,
542
+ settled: overlayToolOutput(durable.items, get(toolActivityAtom) === undefined
543
+ ? []
544
+ : (get(snapshotAtom)?.toolOutput ?? [])),
539
545
  tail: get(tailAtom),
540
546
  };
541
547
  return durable.model === undefined || durable.usage === undefined
@@ -1125,11 +1131,8 @@ export function ParakeetProvider({ children, session, clients = noClients, inter
1125
1131
  }, [scope, onToast]);
1126
1132
  useEffect(() => {
1127
1133
  if (acquiredDraftStorage.status === "unavailable")
1128
- scope.interactions.report({
1129
- tone: "info",
1130
- message: "Drafts are session-only because browser storage is unavailable. They will be lost when this conversation closes.",
1131
- });
1132
- }, [scope, acquiredDraftStorage]);
1134
+ reportFailure("acquire draft storage", acquiredDraftStorage.cause);
1135
+ }, [acquiredDraftStorage]);
1133
1136
  useEffect(() => {
1134
1137
  const reconcile = () => {
1135
1138
  scope.store.set(scope.atoms.reconcileQueuedCancellationAtom);
@@ -53,11 +53,14 @@ function progressItem(progress) {
53
53
  id: progress.messageId,
54
54
  kind: "assistant_message",
55
55
  pending: true,
56
- text: progress.text,
56
+ text: `${progress.textTruncated ? "…" : ""}${progress.text}`,
57
57
  };
58
58
  return progress.thinking === ""
59
59
  ? base
60
- : { ...base, thinking: progress.thinking };
60
+ : {
61
+ ...base,
62
+ thinking: `${progress.thinkingTruncated ? "…" : ""}${progress.thinking}`,
63
+ };
61
64
  }
62
65
  function collectAssistantBlocks(value) {
63
66
  const text = [];
@@ -0,0 +1,3 @@
1
+ import type { ParakeetModel } from "../conversation.js";
2
+ /** A maintained catalog label, or the real identity rather than a guessed name. */
3
+ export declare function composerModelName(model: ParakeetModel): string;
@@ -0,0 +1,21 @@
1
+ import { ANTHROPIC_MODELS } from "@earendil-works/pi-ai/providers/anthropic.models";
2
+ import { CLOUDFLARE_WORKERS_AI_MODELS } from "@earendil-works/pi-ai/providers/cloudflare-workers-ai.models";
3
+ import { OPENAI_MODELS } from "@earendil-works/pi-ai/providers/openai.models";
4
+ import { OPENAI_CODEX_MODELS } from "@earendil-works/pi-ai/providers/openai-codex.models";
5
+ import { formatModelIdentity } from "./model-identity.js";
6
+ // Read the same maintained metadata as the providers, without loading their
7
+ // execution/auth adapters or maintaining another table of display names.
8
+ const names = new Map([
9
+ ...Object.values(ANTHROPIC_MODELS),
10
+ ...Object.values(CLOUDFLARE_WORKERS_AI_MODELS),
11
+ ...Object.values(OPENAI_MODELS),
12
+ ...Object.values(OPENAI_CODEX_MODELS),
13
+ ].map((model) => [
14
+ formatModelIdentity({ provider: model.provider, model: model.id }),
15
+ model.name,
16
+ ]));
17
+ /** A maintained catalog label, or the real identity rather than a guessed name. */
18
+ export function composerModelName(model) {
19
+ const identity = formatModelIdentity(model);
20
+ return names.get(identity) ?? identity;
21
+ }
@@ -0,0 +1,2 @@
1
+ /** Display text keeps shaping characters, never terminal programs or bidi overrides. */
2
+ export declare function sanitizeDisplayText(value: string): string;
@@ -0,0 +1,7 @@
1
+ import stripAnsi from "strip-ansi";
2
+ /** Display text keeps shaping characters, never terminal programs or bidi overrides. */
3
+ export function sanitizeDisplayText(value) {
4
+ return stripAnsi(value)
5
+ .replace(/\r\n?/gu, "\n")
6
+ .replace(/[\p{Cc}\u200b\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069\u2060\ufeff]/gu, (character) => character === "\n" || character === "\t" ? character : "");
7
+ }
@@ -12,6 +12,8 @@ export type ParakeetDraftStorage = Readonly<{
12
12
  getItem: (key: string) => string | null;
13
13
  /** Atomically replace the journal; throw if it cannot be committed. */
14
14
  setItem: (key: string, value: string) => void;
15
+ /** Remove a journal, doing nothing when the key is absent. */
16
+ removeItem: (key: string) => void;
15
17
  }>;
16
18
  declare const journal: z.ZodObject<{
17
19
  draft: z.ZodString;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { reportFailure } from "../lib/failure.js";
2
3
  const images = z
3
4
  .array(z.object({
4
5
  type: z.literal("image"),
@@ -33,18 +34,30 @@ const journal = z.object({
33
34
  /** One owner-scoped journal commits pending text and composer text together. */
34
35
  export function draftJournal(scope, storage) {
35
36
  const key = `fungi.parakeet.drafts.v2:${scope}`;
37
+ let discardObsolete = true;
36
38
  return {
37
39
  read() {
38
40
  if (storage === undefined)
39
41
  return { draft: "", pending: [] };
42
+ if (discardObsolete) {
43
+ discardObsolete = false;
44
+ try {
45
+ // Delete preview data without interpreting its superseded grammar.
46
+ storage.removeItem(`fungi.parakeet.drafts.v1:${scope}`);
47
+ }
48
+ catch (cause) {
49
+ reportFailure("discard obsolete draft journal", cause);
50
+ }
51
+ }
40
52
  try {
41
53
  const saved = storage.getItem(key);
42
54
  return saved === null
43
55
  ? { draft: "", pending: [] }
44
56
  : journal.parse(JSON.parse(saved));
45
57
  }
46
- catch {
58
+ catch (cause) {
47
59
  // Preview journals are disposable; a broken load must not break chat.
60
+ reportFailure("read saved draft", cause);
48
61
  return { draft: "", pending: [] };
49
62
  }
50
63
  },