@fungi.computer/parakeet 0.1.0 → 0.2.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 (59) hide show
  1. package/README.md +45 -6
  2. package/dist/account-commands.js +3 -11
  3. package/dist/commands/advertised.d.ts +3 -1
  4. package/dist/commands/advertised.js +89 -4
  5. package/dist/commands/interactions.d.ts +22 -7
  6. package/dist/commands/interactions.js +41 -17
  7. package/dist/commands/slash.js +3 -5
  8. package/dist/commands/use-slash-commands.js +2 -2
  9. package/dist/components/AgentHead.d.ts +2 -0
  10. package/dist/components/AgentHead.js +5 -4
  11. package/dist/components/ChatComposer.d.ts +3 -1
  12. package/dist/components/ChatComposer.js +79 -15
  13. package/dist/components/ChatMessage.d.ts +4 -0
  14. package/dist/components/ChatMessage.js +6 -2
  15. package/dist/components/ClientRows.js +2 -2
  16. package/dist/components/InteractionPanel.d.ts +2 -4
  17. package/dist/components/InteractionPanel.js +2 -2
  18. package/dist/components/TimelineItem.d.ts +2 -0
  19. package/dist/components/TimelineItem.js +31 -7
  20. package/dist/conversation-opening.d.ts +47 -0
  21. package/dist/conversation-opening.js +173 -0
  22. package/dist/conversation.d.ts +47 -3
  23. package/dist/conversation.js +584 -86
  24. package/dist/core/author-labels.d.ts +2 -0
  25. package/dist/core/build-timeline.d.ts +4 -2
  26. package/dist/core/build-timeline.js +46 -7
  27. package/dist/core/client-actions.js +6 -3
  28. package/dist/core/client-timeline.d.ts +9 -2
  29. package/dist/core/client-timeline.js +15 -4
  30. package/dist/core/draft-storage.d.ts +63 -0
  31. package/dist/core/draft-storage.js +50 -0
  32. package/dist/core/model-identity.d.ts +2 -0
  33. package/dist/core/model-identity.js +6 -0
  34. package/dist/core/queued-recall.d.ts +35 -0
  35. package/dist/core/queued-recall.js +138 -0
  36. package/dist/core/session-projection.js +17 -0
  37. package/dist/core/session-query.d.ts +54 -11
  38. package/dist/core/session-query.js +38 -12
  39. package/dist/core/tool-groups.d.ts +1 -1
  40. package/dist/core/tool-groups.js +3 -1
  41. package/dist/core/tool-view.js +2 -1
  42. package/dist/core/use-timeline-item-key.d.ts +8 -0
  43. package/dist/core/use-timeline-item-key.js +29 -0
  44. package/dist/headless.d.ts +6 -4
  45. package/dist/headless.js +1 -1
  46. package/dist/index.d.ts +2 -2
  47. package/dist/index.js +1 -1
  48. package/dist/model-commands.js +64 -39
  49. package/dist/parakeet.js +17 -18
  50. package/dist/session-commands.d.ts +0 -9
  51. package/dist/session-commands.js +39 -68
  52. package/dist/session.d.ts +1 -1
  53. package/dist/store/types.d.ts +10 -4
  54. package/dist/styles.css +27 -12
  55. package/docs/api/commands/slash.md +24 -21
  56. package/docs/api/headless.md +375 -232
  57. package/docs/api/index.md +157 -45
  58. package/package.json +16 -10
  59. package/src/styles.css +0 -9
@@ -55,11 +55,21 @@ const presentations = {
55
55
  tone: "neutral",
56
56
  },
57
57
  };
58
- function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, slashListId, textareaId, textareaRef, value, }) {
58
+ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, slashListId, textareaId, textareaRef, value, queuedRecall, }) {
59
59
  const latestValue = useRef(value);
60
60
  const [history, setHistory] = useState([]);
61
61
  const [historyIndex, setHistoryIndex] = useState(-1);
62
62
  latestValue.current = value;
63
+ const recallState = queuedRecall?.recallState;
64
+ const recalling = recallState?.kind === "recalling";
65
+ useLayoutEffect(() => {
66
+ if (recallState?.kind !== "restored")
67
+ return;
68
+ setHistoryIndex(-1);
69
+ const textarea = textareaRef.current;
70
+ if (textarea !== null)
71
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
72
+ }, [recallState, textareaRef]);
63
73
  useLayoutEffect(() => {
64
74
  const textarea = textareaRef.current;
65
75
  if (!textarea)
@@ -69,15 +79,18 @@ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, s
69
79
  }, [textareaRef, value]);
70
80
  const saveAndSend = useCallback(() => {
71
81
  const draft = latestValue.current.trim();
72
- if (!draft)
82
+ if (!draft && (queuedRecall?.images?.length ?? 0) === 0)
73
83
  return;
74
84
  onSend();
75
- setHistory((previous) => previous.at(-1) === draft ? previous : [...previous, draft]);
85
+ if (draft)
86
+ setHistory((previous) => previous.at(-1) === draft ? previous : [...previous, draft]);
76
87
  setHistoryIndex(-1);
77
- }, [onSend]);
88
+ }, [onSend, queuedRecall?.images?.length]);
78
89
  const onKeyDown = useCallback((event) => {
79
90
  if (isImeComposing(event))
80
91
  return;
92
+ if (recalling)
93
+ return;
81
94
  if (slash?.open === true) {
82
95
  const handled = event.key === "ArrowDown"
83
96
  ? slash.move(1)
@@ -98,6 +111,35 @@ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, s
98
111
  onAbort();
99
112
  return;
100
113
  }
114
+ if (queuedRecall !== undefined &&
115
+ event.key === "ArrowUp" &&
116
+ !event.repeat &&
117
+ !event.altKey &&
118
+ !event.ctrlKey &&
119
+ !event.metaKey &&
120
+ !event.shiftKey &&
121
+ slash?.open !== true &&
122
+ latestValue.current === "" &&
123
+ (queuedRecall.images?.length ?? 0) === 0 &&
124
+ document.activeElement === event.currentTarget) {
125
+ event.preventDefault();
126
+ const input = event.currentTarget;
127
+ void queuedRecall.recall().then((outcome) => {
128
+ if (outcome !== "empty" ||
129
+ !input.isConnected ||
130
+ input.value !== "" ||
131
+ textareaRef.current !== input ||
132
+ history.length === 0)
133
+ return;
134
+ const next = historyIndex === -1 ? history.length - 1 : historyIndex - 1;
135
+ const entry = history[next];
136
+ if (entry !== undefined) {
137
+ setHistoryIndex(next);
138
+ onChange(entry);
139
+ }
140
+ });
141
+ return;
142
+ }
101
143
  if (event.key === "Enter" && !event.shiftKey) {
102
144
  event.preventDefault();
103
145
  saveAndSend();
@@ -129,19 +171,36 @@ function HostComposerTextarea({ placeholder, onChange, onSend, onAbort, slash, s
129
171
  onChange(entry);
130
172
  }
131
173
  }
132
- }, [history, historyIndex, onAbort, onChange, saveAndSend, slash, textareaRef]);
174
+ }, [
175
+ history,
176
+ historyIndex,
177
+ onAbort,
178
+ onChange,
179
+ saveAndSend,
180
+ slash,
181
+ textareaRef,
182
+ queuedRecall,
183
+ recalling,
184
+ ]);
133
185
  const listOpen = slash?.open === true && slash.completion.kind !== "none"
134
186
  ? slash.completion.items.length > 0
135
187
  : false;
136
- return (_jsxs("div", { className: "relative min-w-0 flex-1", children: [_jsx("label", { className: "sr-only", htmlFor: textareaId, children: "Message" }), _jsx(Textarea, { "aria-autocomplete": "list", "aria-controls": listOpen ? slashListId : undefined, "aria-expanded": listOpen, "aria-activedescendant": listOpen && slash !== undefined
188
+ return (_jsxs("div", { className: "relative min-w-0 flex-1", children: [_jsx("label", { className: "sr-only", htmlFor: textareaId, children: "Message" }), _jsx(Textarea, { "aria-describedby": [
189
+ slash === undefined ? undefined : `${textareaId}-guidance`,
190
+ queuedRecall === undefined
191
+ ? undefined
192
+ : `${textareaId}-recall-guidance`,
193
+ ]
194
+ .filter((id) => id !== undefined)
195
+ .join(" ") || undefined, "aria-busy": recalling, readOnly: recalling, "aria-autocomplete": "list", "aria-controls": listOpen ? slashListId : undefined, "aria-expanded": listOpen, "aria-activedescendant": listOpen && slash !== undefined
137
196
  ? slashOptionId(slashListId, slash.selected)
138
197
  : 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) => {
139
198
  onChange(event.target.value);
140
199
  setHistoryIndex(-1);
141
200
  }, onKeyDown: onKeyDown, placeholder: placeholder ??
142
- (slash === undefined ? "Ask agent..." : "Ask agent… (/ for commands)"), role: slash === undefined ? undefined : "combobox", ref: textareaRef, rows: 1, value: value })] }));
201
+ (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)"] }))] }));
143
202
  }
144
- /** Shared visible and screen-reader readout of the observed Session state. */
203
+ /** Full Session readout for announcements; the footer only draws attention states. */
145
204
  export function sessionReadout(status, view) {
146
205
  if (view.kind === "connecting")
147
206
  return "connecting";
@@ -179,19 +238,24 @@ export function sessionReadout(status, view) {
179
238
  }
180
239
  function ComposerFooter({ statusChips, model, status, viewState, usage, context, }) {
181
240
  const tone = viewState.kind === "error" ? "error" : presentations[status].tone;
182
- const label = sessionReadout(status, viewState);
183
- return (_jsxs("div", { className: "composer-footer", children: [_jsx(Status, { label: label, title: label, tone: tone }), 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] }));
241
+ // Admission must not replace an outstanding owner attention readout.
242
+ const label = sessionReadout("idle", viewState);
243
+ const needsAttention = viewState.kind === "waiting_for_you" || viewState.kind === "error";
244
+ 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] }));
184
245
  }
185
- export const ChatComposer = memo(function ChatComposer({ placeholder, showFooter = true, statusChips, actionVariant = "primary", draft, failure, failureCause, admissionError, model, onAbort, onChange, onRetry, onSend, status, viewState, textareaRef, usage, context, slash, }) {
246
+ 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, }) {
186
247
  const textareaId = useId();
187
248
  const slashListId = `${textareaId}-commands`;
188
249
  const { busy } = presentations[status];
189
- 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: 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: draft.trim().length === 0, onClick: () => {
250
+ const recallBusy = queuedRecall?.recallState.kind === "recalling";
251
+ const cannotSend = recallBusy ||
252
+ (draft.trim().length === 0 && (queuedRecall?.images?.length ?? 0) === 0);
253
+ 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: () => {
190
254
  onSend("followUp");
191
- }, size: "lg", variant: actionVariant, children: "Queue" })] })) : (_jsx(Button, { "aria-label": "Send message", className: "join-item h-auto min-h-0 self-stretch", disabled: draft.trim().length === 0, onClick: () => {
255
+ }, 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: () => {
192
256
  onSend("steer");
193
257
  }, title: "Send", size: "lg", variant: actionVariant, children: "Send" }));
194
- const composerInput = (_jsx(HostComposerTextarea, { placeholder: placeholder, onChange: onChange, onAbort: onAbort, onSend: () => {
258
+ const composerInput = (_jsx(HostComposerTextarea, { queuedRecall: queuedRecall, placeholder: placeholder, onChange: onChange, onAbort: onAbort, onSend: () => {
195
259
  onSend(busy ? "followUp" : "steer");
196
260
  }, slash: slash, slashListId: slashListId, textareaId: textareaId, textareaRef: textareaRef, value: draft }));
197
261
  const composerProps = {
@@ -205,5 +269,5 @@ export const ChatComposer = memo(function ChatComposer({ placeholder, showFooter
205
269
  footerProps.usage = usage;
206
270
  if (context)
207
271
  footerProps.context = context;
208
- 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: "parakeet-key-hints", "aria-live": "polite", children: formatHints(slash.hints) }))] }));
272
+ 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) }))] }));
209
273
  });
@@ -1,3 +1,7 @@
1
+ /** User-message admission state, shared by full and compact transcript rows. */
2
+ export declare function PendingMessageStatus({ pending, }: Readonly<{
3
+ pending: boolean | undefined;
4
+ }>): import("react/jsx-runtime").JSX.Element | null;
1
5
  export declare function ChatMessage({ children, isError, pending, speaker, thinking, markdownPolicy, }: Readonly<{
2
6
  children: string;
3
7
  isError?: boolean;
@@ -1,13 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { MarkdownRenderer } from "./MarkdownRenderer.js";
3
3
  import { ThinkingBlock } from "./ThinkingBlock.js";
4
+ /** User-message admission state, shared by full and compact transcript rows. */
5
+ export function PendingMessageStatus({ pending, }) {
6
+ return pending ? (_jsx("span", { className: "sr-only", role: "status", children: "Sending" })) : null;
7
+ }
4
8
  export function ChatMessage({ children, isError, pending, speaker, thinking, markdownPolicy, }) {
5
9
  const content = (_jsx(MarkdownRenderer, { policy: markdownPolicy, children: children }));
6
10
  if (speaker === "user") {
7
- return (_jsx("div", { className: `px-3 py-2 transition-opacity motion-reduce:transition-none ${pending ? "opacity-50" : ""}`, "data-pending": pending ? "true" : undefined, "data-speaker": "user", style: {
11
+ return (_jsxs("div", { className: `px-3 py-2 transition-opacity motion-reduce:transition-none ${pending ? "opacity-50" : ""}`, "data-pending": pending ? "true" : undefined, "data-speaker": "user", style: {
8
12
  background: "var(--stipe-surface)",
9
13
  borderLeft: "3px solid var(--color-primary)",
10
- }, children: _jsx("div", { className: "message-body text-base-content", children: content }) }));
14
+ }, children: [_jsx(PendingMessageStatus, { pending: pending }), _jsx("div", { className: "message-body text-base-content", children: content })] }));
11
15
  }
12
16
  const hasText = children.trim().length > 0;
13
17
  if (!hasText && !thinking)
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Button } from "@fungi.computer/caps/components/button";
3
3
  import { DataList, DataRow, DataListHeading, } from "@fungi.computer/caps/components/data-list";
4
4
  import { Status } from "@fungi.computer/caps/components/status";
@@ -17,7 +17,7 @@ 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: [_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
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
21
  ? null
22
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
23
  if (row.open !== undefined)
@@ -1,15 +1,13 @@
1
1
  import type { WhistleInteractionHost } from "@fungi.computer/whistle/interaction";
2
- /** The interaction to draw and the page's close and retry callbacks. */
2
+ /** The interaction to draw and the page's close callback. */
3
3
  export type InteractionPanelProps = Readonly<{
4
4
  /** The renderer-neutral interaction store. */
5
5
  host: WhistleInteractionHost;
6
6
  /** Close a settled interaction. */
7
7
  onClose?: () => void;
8
- /** Run the same command again after a failure. */
9
- onRetry?: () => void;
10
8
  }>;
11
9
  /**
12
10
  * Browser renderer for one Whistle interaction. It draws the same host
13
11
  * snapshot the terminal draws; the command, not the page, defines the steps.
14
12
  */
15
- export declare function InteractionPanel({ host, onClose, onRetry, }: InteractionPanelProps): import("react/jsx-runtime").JSX.Element;
13
+ export declare function InteractionPanel({ host, onClose }: InteractionPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -29,7 +29,7 @@ function PromptView({ host, pending, }) {
29
29
  * Browser renderer for one Whistle interaction. It draws the same host
30
30
  * snapshot the terminal draws; the command, not the page, defines the steps.
31
31
  */
32
- export function InteractionPanel({ host, onClose, onRetry, }) {
32
+ export function InteractionPanel({ host, onClose }) {
33
33
  const state = useSyncExternalStore(host.subscribe, host.snapshot);
34
34
  const active = state.status === "working" || state.status === "prompting";
35
35
  return (_jsxs("section", { "aria-label": state.title, className: "flex flex-col gap-sm", "data-status": state.status, children: [_jsx("h2", { className: "text-lg font-semibold", children: state.title }), state.handoff === undefined ? null : (_jsx(Display, { density: "compact", children: _jsxs("div", { className: "flex flex-col gap-xs p-sm", children: [_jsx("span", { className: "text-sm", children: state.handoff.message ??
@@ -38,5 +38,5 @@ export function InteractionPanel({ host, onClose, onRetry, }) {
38
38
  : "Open this page and enter the code") }), _jsx("a", { className: "link break-all font-mono", href: state.handoff.url, rel: "noreferrer", target: "_blank", children: state.handoff.url }), state.handoff.code === undefined ? null : (_jsx("span", { className: "font-mono text-2xl tracking-widest", children: state.handoff.code }))] }) })), state.message === undefined || state.status === "failed" ? null : (_jsxs("p", { className: "flex items-center gap-xs", children: [_jsx(Status, { tone: state.message.type === "success" ? "success" : "info", motion: state.message.type === "progress" && active ? "pulse" : "none" }), state.message.message] })), state.links.map((link) => (_jsx("a", { className: "link break-all", href: link.url, rel: "noreferrer", target: "_blank", children: link.label ?? link.url }, link.url))), state.prompt === undefined ? null : (_jsx(PromptView, { host: host, pending: state.prompt }, state.prompt.sequence)), state.status === "failed" ? (_jsx(Alert, { tone: "error", title: "That did not work", description: state.failure })) : null, _jsxs("div", { className: "flex justify-end gap-xs", children: [active ? (_jsx(Button, { variant: "ghost", onClick: host.cancel, children: "Cancel" })) : null, state.status === "failed" ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", onClick: () => {
39
39
  host.cancel();
40
40
  onClose?.();
41
- }, children: "Close" }), onRetry === undefined ? null : (_jsx(Button, { onClick: onRetry, children: "Try again" }))] })) : null, state.status === "done" || state.status === "cancelled" ? (_jsx(Button, { onClick: onClose, children: "Done" })) : null] })] }));
41
+ }, children: "Close" }), !state.retryable ? null : (_jsx(Button, { onClick: host.retry, children: "Try again" }))] })) : null, state.status === "done" || state.status === "cancelled" ? (_jsx(Button, { onClick: onClose, children: "Done" })) : null] })] }));
42
42
  }
@@ -5,6 +5,8 @@ import { type ToggleToolDisclosure } from "./ToolGroup.js";
5
5
  export declare const TimelineItem: import("react").NamedExoticComponent<Readonly<{
6
6
  item: TimelineRow;
7
7
  onDismissFailed?: ((requestId: string) => void) | undefined;
8
+ onCancelQueued?: ((commandId: string) => void) | undefined;
9
+ cancellingQueued?: boolean | undefined;
8
10
  renderToolActions?: ((item: ToolItem) => ReactNode) | undefined;
9
11
  renderAssistantAttachment?: ((text: string) => ReactNode) | undefined;
10
12
  formatAssistantText?: ((text: string) => string) | undefined;
@@ -1,19 +1,45 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Sigil } from "@fungi.computer/sigils";
3
+ import { Button } from "@fungi.computer/caps/components/button";
2
4
  import { memo } from "react";
3
- import { initialToolDisclosure, } from "../core/tool-groups.js";
5
+ import { disclosureOpen, initialToolDisclosure, } from "../core/tool-groups.js";
4
6
  import { assistantMessageVisible } from "../core/assistant-visibility.js";
5
7
  import { formatTokenCount } from "../core/format-context.js";
6
- import { ChatMessage } from "./ChatMessage.js";
8
+ import { ChatMessage, PendingMessageStatus } from "./ChatMessage.js";
7
9
  import { ToolGroup } from "./ToolGroup.js";
8
10
  const ignoreToggle = () => undefined;
9
- export const TimelineItem = memo(function TimelineItem({ item, onDismissFailed, renderToolActions, renderAssistantAttachment, formatAssistantText, toolDisclosure = initialToolDisclosure, onToggleTool = ignoreToggle, }) {
11
+ const compactionNoticeLabel = {
12
+ nothing_to_compact: "nothing to compact",
13
+ failed: "compaction failed",
14
+ stale: "compaction skipped: the context moved on",
15
+ insufficient: "compaction skipped: the summary would not fit",
16
+ };
17
+ export const TimelineItem = memo(function TimelineItem({ item, onDismissFailed, onCancelQueued, cancellingQueued = false, renderToolActions, renderAssistantAttachment, formatAssistantText, toolDisclosure = initialToolDisclosure, onToggleTool = ignoreToggle, }) {
10
18
  switch (item.kind) {
11
19
  case "user_message": {
20
+ const pending = item.status === "pending" || item.status === "queued";
12
21
  const requestId = item.requestId;
22
+ const commandId = item.commandId;
23
+ const cancelAction = item.status === "queued" &&
24
+ commandId !== undefined &&
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
+ onCancelQueued(commandId);
27
+ }, children: "Cancel" }) })) : null;
13
28
  const dismissAction = requestId === undefined || onDismissFailed === undefined ? null : (_jsx("button", { "aria-label": "Dismiss failed prompt", className: "btn btn-ghost btn-xs", onClick: () => {
14
29
  onDismissFailed(requestId);
15
30
  }, type: "button", children: "Dismiss" }));
16
- return (_jsxs(_Fragment, { children: [item.author ? (_jsx("div", { className: "px-3 text-xs text-dim", "data-author": item.author.id, children: item.author.name })) : null, _jsx(ChatMessage, { pending: item.status !== "confirmed", speaker: "user", children: item.text }), item.status === "failed" ? (_jsxs("div", { className: "flex items-center gap-2 px-3 text-xs text-error", role: "alert", children: [_jsx("span", { children: "Your prompt didn\u2019t make it through." }), dismissAction] })) : null] }));
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"
32
+ ? "That Agent message didn’t make it through."
33
+ : "Your prompt didn’t make it through." }), dismissAction] })) : null;
34
+ if (item.author?.kind === "agent") {
35
+ const open = disclosureOpen(toolDisclosure, item.id, false);
36
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: item.status === "confirmed"
37
+ ? "px-3 text-xs"
38
+ : "px-3 text-xs opacity-60", "data-agent-message": item.author.id, "data-author": item.author.id, children: [_jsx(PendingMessageStatus, { pending: pending }), _jsxs("button", { "aria-expanded": open, className: "text-dim flex w-full min-w-0 items-center gap-sm text-left", onClick: () => {
39
+ onToggleTool(item.id, false);
40
+ }, type: "button", children: [_jsx("span", { className: "flex shrink-0", children: _jsx(Sigil, { name: "agent", size: 16 }) }), _jsx("span", { className: "shrink-0", children: item.author.name }), open ? null : (_jsxs("span", { className: "min-w-0 truncate", children: ["\u00B7 ", item.text] }))] }), open ? (_jsx("div", { className: "whitespace-pre-wrap break-words pl-3", children: item.text })) : null] }), failed, cancelAction] }));
41
+ }
42
+ return (_jsxs(_Fragment, { children: [item.author ? (_jsx("div", { className: "px-3 text-xs text-dim", "data-author": item.author.id, children: item.author.name })) : null, _jsx(ChatMessage, { pending: pending, speaker: "user", children: item.text }), failed, cancelAction] }));
17
43
  }
18
44
  case "assistant_message":
19
45
  if (!assistantMessageVisible(item))
@@ -28,8 +54,6 @@ export const TimelineItem = memo(function TimelineItem({ item, onDismissFailed,
28
54
  ? "compacted · the agent’s memory starts below"
29
55
  : `compacted ${formatTokenCount(item.tokensBefore)} → ${formatTokenCount(item.tokensAfter)}` }));
30
56
  case "compaction_notice":
31
- return (_jsxs("div", { className: `px-3 text-xs ${item.result === "failed" ? "text-error" : "text-dim"}`, role: "status", children: ["\u00B7", " ", item.result === "failed"
32
- ? "compaction failed"
33
- : "nothing to compact", item.diagnostic === null ? "" : `: ${item.diagnostic}`] }));
57
+ return (_jsxs("div", { className: `px-3 text-xs ${item.result === "failed" ? "text-error" : "text-dim"}`, role: "status", children: ["\u00B7 ", compactionNoticeLabel[item.result], item.diagnostic === null ? "" : `: ${item.diagnostic}`] }));
34
58
  }
35
59
  });
@@ -0,0 +1,47 @@
1
+ import type { AppRoot } from "@fungi.computer/agar";
2
+ import { openAppAgent } from "@fungi.computer/agar/agents";
3
+ import { type ParakeetSession } from "./headless.js";
4
+ import type { QueryClient } from "@tanstack/react-query";
5
+ import { createShiitakeClient, type ShiitakeAgent, type ShiitakeEventSourceConstructor, type ShiitakeSession } from "@fungi.computer/shiitake/client";
6
+ export type OpenConversation<Session extends ParakeetSession = ShiitakeSession> = Readonly<{
7
+ agentId: string;
8
+ sessionId: string;
9
+ session: Session;
10
+ }>;
11
+ export type PendingSelection = Readonly<{
12
+ agentId: string;
13
+ sessionId: string;
14
+ }>;
15
+ export type InboxClient = Pick<ShiitakeAgent, "createSession" | "deleteSession" | "session" | "close">;
16
+ /** What the opening owner needs of a client: one addressable Session, closable. */
17
+ export type OpeningClient<Session extends ParakeetSession = ShiitakeSession> = Readonly<{
18
+ session: (sessionId: string) => Session;
19
+ close: () => Promise<void>;
20
+ }>;
21
+ /** The owner's own view of its services; a full {@link Services} satisfies it. */
22
+ export type OpeningServices<Session extends ParakeetSession = ShiitakeSession> = Readonly<{
23
+ openAgent: typeof openAppAgent;
24
+ createClient: (options: Parameters<typeof createShiitakeClient>[0]) => OpeningClient<Session>;
25
+ }>;
26
+ export type Services = Readonly<{
27
+ openAgent: typeof openAppAgent;
28
+ createClient: (options: Parameters<typeof createShiitakeClient>[0]) => InboxClient;
29
+ }>;
30
+ export declare function eventSourceFor(fetcher: typeof globalThis.fetch): ShiitakeEventSourceConstructor;
31
+ /** Owns the selected conversation's Agent, client and Parakeet query lifetimes. */
32
+ export declare function createConversationOpeningOwner<Session extends ParakeetSession = ShiitakeSession>(options: Readonly<{
33
+ connection: Pick<AppRoot, "capabilities"> & {
34
+ ui: Pick<AppRoot["ui"], "reportAgentSessionSelection">;
35
+ };
36
+ queryClient: QueryClient;
37
+ services: OpeningServices<Session>;
38
+ onPending: (selection: PendingSelection | null) => void;
39
+ onConversation: (conversation: OpenConversation<Session> | null) => void;
40
+ onError: (message: string | null) => void;
41
+ /** Only the primary conversation is the selection the host shell sees. */
42
+ reportsSelection?: boolean;
43
+ }>): {
44
+ select: (agentId: string, sessionId: string, fromOpenRequest?: boolean, waitForBaseline?: boolean) => void;
45
+ clear: () => void;
46
+ close: () => void;
47
+ };
@@ -0,0 +1,173 @@
1
+ import { openAppAgent } from "@fungi.computer/agar/agents";
2
+ import { seedSessionQuery } from "./headless.js";
3
+ import { createShiitakeClient, } from "@fungi.computer/shiitake/client";
4
+ import { ShiitakeError } from "@fungi.computer/shiitake";
5
+ import { EventSource as EventSourceImplementation, } from "eventsource";
6
+ /** Seed Parakeet's shared query with the bounded read before mounting its watch. */
7
+ function prepareConversation(queryClient, session) {
8
+ let cancelled = false;
9
+ const readBaseline = async () => {
10
+ try {
11
+ return await session.read();
12
+ }
13
+ catch (cause) {
14
+ if (!(cause instanceof ShiitakeError) ||
15
+ cause.boundary !== "runtime" ||
16
+ cause.cause !== "unavailable")
17
+ throw cause;
18
+ await new Promise((resolve) => setTimeout(resolve, 150));
19
+ if (cancelled)
20
+ throw new Error("Conversation opening cancelled", { cause });
21
+ return session.read();
22
+ }
23
+ };
24
+ let cancel;
25
+ const cancelledRead = new Promise((_, reject) => {
26
+ cancel = () => {
27
+ reject(new Error("Conversation opening cancelled"));
28
+ };
29
+ });
30
+ const ready = Promise.race([readBaseline(), cancelledRead]).then((snapshot) => {
31
+ if (cancelled)
32
+ throw new Error("Conversation opening cancelled");
33
+ seedSessionQuery(queryClient, session, snapshot);
34
+ });
35
+ return {
36
+ ready,
37
+ close: () => {
38
+ cancelled = true;
39
+ cancel();
40
+ },
41
+ };
42
+ }
43
+ export function eventSourceFor(fetcher) {
44
+ return class HostEventSource {
45
+ #source;
46
+ constructor(url) {
47
+ this.#source = new EventSourceImplementation(url, {
48
+ fetch: (input, init) => fetcher(input, init),
49
+ });
50
+ }
51
+ get readyState() {
52
+ return this.#source.readyState;
53
+ }
54
+ addEventListener(type, listener, options) {
55
+ this.#source.addEventListener(type, listener, options);
56
+ }
57
+ close() {
58
+ this.#source.close();
59
+ }
60
+ };
61
+ }
62
+ /** Owns the selected conversation's Agent, client and Parakeet query lifetimes. */
63
+ export function createConversationOpeningOwner(options) {
64
+ const { connection, queryClient, services } = options;
65
+ let pending = null;
66
+ let current = null;
67
+ let currentClose = null;
68
+ let disposed = false;
69
+ const same = (left, right) => left?.agentId === right.agentId && left.sessionId === right.sessionId;
70
+ const isCurrent = (opening) => !disposed && pending === opening;
71
+ const select = (agentId, sessionId, fromOpenRequest = false, waitForBaseline = true) => {
72
+ if (disposed)
73
+ return;
74
+ const selection = { agentId, sessionId };
75
+ if (same(pending, selection))
76
+ return;
77
+ pending?.close();
78
+ pending = null;
79
+ options.onPending(null);
80
+ options.onError(null);
81
+ if (same(current, selection))
82
+ return;
83
+ let openedClose = null;
84
+ let closed = false;
85
+ const opening = {
86
+ ...selection,
87
+ close: () => {
88
+ if (closed)
89
+ return;
90
+ closed = true;
91
+ openedClose?.();
92
+ },
93
+ };
94
+ pending = opening;
95
+ options.onPending(selection);
96
+ void (async () => {
97
+ try {
98
+ const transport = await services.openAgent(connection.capabilities, {
99
+ agentId,
100
+ });
101
+ if (!isCurrent(opening)) {
102
+ await transport.close();
103
+ return;
104
+ }
105
+ const client = services.createClient({
106
+ baseUrl: transport.baseUrl,
107
+ eventSource: eventSourceFor(transport.fetch),
108
+ fetch: transport.fetch,
109
+ });
110
+ const session = client.session(sessionId);
111
+ const observation = prepareConversation(queryClient, session);
112
+ openedClose = () => {
113
+ observation.close();
114
+ void Promise.allSettled([client.close(), transport.close()]);
115
+ };
116
+ await observation.ready;
117
+ if (waitForBaseline && options.reportsSelection !== false) {
118
+ await connection.ui.reportAgentSessionSelection({
119
+ agentId,
120
+ sessionId,
121
+ });
122
+ }
123
+ if (!isCurrent(opening))
124
+ return;
125
+ const conversation = { agentId, sessionId, session };
126
+ const previousClose = currentClose;
127
+ pending = null;
128
+ current = conversation;
129
+ currentClose = opening.close;
130
+ options.onConversation(conversation);
131
+ options.onPending(null);
132
+ previousClose?.();
133
+ }
134
+ catch (cause) {
135
+ console.error("Chat Session opening failed", cause);
136
+ opening.close();
137
+ if (isCurrent(opening)) {
138
+ pending = null;
139
+ options.onPending(null);
140
+ options.onError(fromOpenRequest
141
+ ? `That Session could not be opened. ${current ? "The current conversation remains open." : "Choose another Session from the list."}`
142
+ : `Could not open this Session${cause instanceof Error ? `: ${cause.message}` : ""}. ${current ? "The previous conversation remains open." : "Try opening it again."}`);
143
+ }
144
+ }
145
+ })();
146
+ };
147
+ const clear = () => {
148
+ if (disposed)
149
+ return;
150
+ const hadSelection = pending !== null || current !== null;
151
+ pending?.close();
152
+ pending = null;
153
+ currentClose?.();
154
+ currentClose = null;
155
+ current = null;
156
+ options.onPending(null);
157
+ options.onConversation(null);
158
+ options.onError(null);
159
+ if (!hadSelection || options.reportsSelection === false)
160
+ return;
161
+ void connection.ui
162
+ .reportAgentSessionSelection(null)
163
+ .catch((cause) => {
164
+ console.error("Chat Session selection could not be reported", cause);
165
+ return undefined;
166
+ });
167
+ };
168
+ const close = () => {
169
+ clear();
170
+ disposed = true;
171
+ };
172
+ return { select, clear, close };
173
+ }