@fungi.computer/parakeet 0.3.0 → 0.4.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.
@@ -40,7 +40,7 @@ function requireInteraction(input) {
40
40
  throw new WhistleActionError("interaction_required", "Signing in needs an interactive client.");
41
41
  return input.interaction;
42
42
  }
43
- async function chooseProvider(providers, requested, interaction, message, choices) {
43
+ async function chooseProvider(providers, requested, interaction, prompt, choices) {
44
44
  if (requested !== undefined) {
45
45
  const provider = providers.find((candidate) => candidate.id === requested);
46
46
  if (provider === undefined)
@@ -51,7 +51,8 @@ async function chooseProvider(providers, requested, interaction, message, choice
51
51
  throw new WhistleActionError("no_providers", "No model providers are available here.");
52
52
  const id = await interaction.prompt({
53
53
  type: "choice",
54
- message,
54
+ message: prompt.message,
55
+ summary: prompt.summary,
55
56
  collapsedGroup: "More providers",
56
57
  options: choices,
57
58
  });
@@ -73,6 +74,7 @@ async function chooseMethod(provider, requested, interaction) {
73
74
  const kind = await interaction.prompt({
74
75
  type: "choice",
75
76
  message: `How do you want to sign in to ${provider.name}?`,
77
+ summary: "You can add the other kind later.",
76
78
  options: provider.methods.map((method) => ({
77
79
  value: method.kind,
78
80
  label: method.label,
@@ -102,9 +104,11 @@ export async function signInProvider(owner, provider, interaction, request = {})
102
104
  async function signOutProvider(owner, provider, interaction) {
103
105
  const confirmed = await interaction.prompt({
104
106
  type: "confirmation",
105
- message: `Sign out of ${provider.name}? Local Sessions stop using this account.`,
107
+ message: `Sign out of ${provider.name}?`,
108
+ summary: "Conversations on this device stop using this account.",
106
109
  confirmLabel: "Sign out",
107
110
  cancelLabel: "Keep signed in",
111
+ destructive: true,
108
112
  });
109
113
  if (!confirmed)
110
114
  return;
@@ -162,15 +166,21 @@ export function accountCommandDefinitions(owner, options) {
162
166
  const access = request.providerId === undefined
163
167
  ? await owner.access(interaction.signal)
164
168
  : [];
165
- const provider = await chooseProvider(registry, request.providerId, interaction, "Choose a model provider", providerChoices(registry, access, owner.popularProviderIds, "login"));
169
+ // Several providers can be signed in at once, so none is "current";
170
+ // the Signed in group already comes first and takes the highlight.
171
+ const provider = await chooseProvider(registry, request.providerId, interaction, {
172
+ message: "Sign in to a model provider",
173
+ summary: "Use a subscription or an API key. You can add more later.",
174
+ }, providerChoices(registry, access, owner.popularProviderIds, "login"));
166
175
  if (request.providerId === undefined &&
167
176
  access.some((item) => item.providerId === provider.id)) {
168
177
  const action = await interaction.prompt({
169
178
  type: "choice",
170
- message: provider.name,
179
+ message: `Already signed in to ${provider.name}`,
180
+ summary: "Switch to another account, or sign out of this one.",
171
181
  options: [
172
182
  { value: "switch", label: "Switch account" },
173
- { value: "logout", label: "Sign out" },
183
+ { value: "logout", label: "Sign out…", destructive: true },
174
184
  ],
175
185
  });
176
186
  if (action === "logout") {
@@ -211,7 +221,10 @@ export function accountCommandDefinitions(owner, options) {
211
221
  });
212
222
  return;
213
223
  }
214
- const provider = await chooseProvider(signedIn, request.providerId, interaction, "Sign out of which provider?", signedIn.map((provider) => ({
224
+ const provider = await chooseProvider(signedIn, request.providerId, interaction, {
225
+ message: "Sign out of which provider?",
226
+ summary: "Other providers stay signed in.",
227
+ }, signedIn.map((provider) => ({
215
228
  value: provider.id,
216
229
  label: provider.name,
217
230
  })));
@@ -1,27 +1,41 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Alert } from "@fungi.computer/caps/components/alert";
3
3
  import { Button } from "@fungi.computer/caps/components/button";
4
+ import { DataList, DataRow } from "@fungi.computer/caps/components/data-list";
5
+ import { Disclosure } from "@fungi.computer/caps/components/disclosure";
4
6
  import { Field } from "@fungi.computer/caps/components/field";
5
7
  import { Input } from "@fungi.computer/caps/components/input";
6
8
  import { Listbox } from "@fungi.computer/caps/components/listbox";
7
9
  import { Status } from "@fungi.computer/caps/components/status";
8
10
  import { Display } from "@fungi.computer/caps/components/surface";
9
11
  import { useState, useSyncExternalStore } from "react";
12
+ /** Summary, facts, long text and on-demand details: the shared modal anatomy. */
13
+ function PromptHeader({ request }) {
14
+ const [open, setOpen] = useState(false);
15
+ const entries = (list) => list === undefined ? null : (_jsx(DataList, { density: "compact", children: list.map((entry) => (_jsxs(DataRow, { children: [_jsx("span", { className: "text-sm opacity-70", children: entry.label }), " ", _jsx("span", { className: "break-words", children: entry.value })] }, entry.label))) }));
16
+ return (_jsxs(_Fragment, { children: [_jsx("p", { className: "font-medium", children: request.message }), request.summary === undefined ? null : (_jsx("p", { className: "text-sm opacity-70", children: request.summary })), entries(request.facts), request.body === undefined ? null : (_jsxs("p", { className: "line-clamp-3 whitespace-pre-line break-words", children: [request.bodyLabel === undefined ? null : (_jsxs("span", { className: "text-sm opacity-70", children: [request.bodyLabel, " "] })), request.body] })), request.details === undefined && request.body === undefined ? null : (_jsxs(Disclosure, { open: open, onOpenChange: setOpen, summary: "Details", children: [request.body === undefined ? null : (_jsx("p", { className: "whitespace-pre-line break-words", children: request.body })), entries(request.details)] }))] }));
17
+ }
10
18
  function PromptView({ host, pending, }) {
11
19
  const [text, setText] = useState("");
12
20
  const request = pending.request;
13
21
  if (request.type === "choice")
14
- return (_jsxs("div", { className: "flex flex-col gap-xs", children: [_jsx("p", { className: "font-medium", children: request.message }), _jsx(Listbox, { "aria-label": request.message, items: request.options, getKey: (option) => option.value, getLabel: (option) => option.label, onValueChange: (value) => {
22
+ return (_jsxs("div", { className: "flex flex-col gap-xs", children: [_jsx(PromptHeader, { request: request }), _jsx(Listbox, { "aria-label": request.message, items: request.options, getKey: (option) => option.value, getLabel: (option) => option.label, onValueChange: (value) => {
15
23
  if (value !== null)
16
24
  host.answer(pending.sequence, value);
17
- }, renderItem: (option) => (_jsxs("span", { className: "flex flex-col", children: [_jsx("span", { children: option.label }), option.description === undefined ? null : (_jsx("span", { className: "text-sm opacity-70", children: option.description }))] })) })] }));
25
+ }, renderItem: (option) => (_jsxs("span", { className: `flex flex-col${option.destructive === true ? " text-error" : ""}`, children: [_jsxs("span", { children: [option.value === request.current ? "● " : "", option.label] }), option.description === undefined ? null : (_jsx("span", { className: "text-sm opacity-70", children: option.description }))] })) })] }));
18
26
  if (request.type === "confirmation")
19
- return (_jsxs("div", { className: "flex flex-col gap-sm", children: [_jsx("p", { children: request.message }), _jsxs("div", { className: "flex justify-end gap-xs", children: [_jsx(Button, { variant: "ghost", onClick: () => host.answer(pending.sequence, false), children: request.cancelLabel ?? "Cancel" }), _jsx(Button, { onClick: () => host.answer(pending.sequence, true), children: request.confirmLabel ?? "Confirm" })] })] }));
27
+ return (_jsxs("div", { className: "flex flex-col gap-sm", children: [_jsx(PromptHeader, { request: request }), _jsxs("div", { className: "flex justify-end gap-xs", children: [_jsx(Button
28
+ // A destructive question focuses the safe answer, which comes first.
29
+ , {
30
+ // A destructive question focuses the safe answer, which comes first.
31
+ autoFocus: request.destructive === true, variant: "ghost", onClick: () => host.answer(pending.sequence, false), children: request.cancelLabel ?? "Cancel" }), _jsx(Button, { variant: request.destructive === true ? "destructive" : "primary", onClick: () => host.answer(pending.sequence, true), children: request.confirmLabel ?? "Confirm" })] })] }));
20
32
  const submit = (event) => {
21
33
  event.preventDefault();
22
34
  host.answer(pending.sequence, text);
23
35
  };
24
- return (_jsxs("form", { className: "flex flex-col gap-sm", onSubmit: submit, children: [_jsx(Field, { label: request.message, children: _jsx(Input, { autoComplete: "off", autoFocus: true, placeholder: request.placeholder, type: request.secret === true ? "password" : "text", value: text, onChange: (event) => {
36
+ return (_jsxs("form", { className: "flex flex-col gap-sm", onSubmit: submit, children: [request.summary === undefined &&
37
+ request.facts === undefined &&
38
+ request.body === undefined ? null : (_jsx(PromptHeader, { request: request })), _jsx(Field, { label: request.message, children: _jsx(Input, { autoComplete: "off", autoFocus: true, placeholder: request.placeholder, type: request.secret === true ? "password" : "text", value: text, onChange: (event) => {
25
39
  setText(event.target.value);
26
40
  } }) }), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { type: "submit", children: "Continue" }) })] }));
27
41
  }
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Alert } from "@fungi.computer/caps/components/alert";
3
3
  import { Button } from "@fungi.computer/caps/components/button";
4
+ import { boundedDiagnostic } from "@fungi.computer/bird-dog/effect";
4
5
  const messages = {
5
6
  admission: "Your prompt didn’t make it through. Let’s give it another go.",
6
7
  failed: "The run stopped. Check the message above before trying again.",
@@ -9,5 +10,13 @@ const messages = {
9
10
  };
10
11
  /** Session failures do not carry a billing receipt; never promise a refund. */
11
12
  export function RunFailureNotice({ failure, cause, onRetry, }) {
12
- return (_jsx(Alert, { tone: "warning", title: failure === "cancelled" ? "Taking a breather" : "A little tangle", description: _jsxs(_Fragment, { children: [messages[failure], " If the model didn\u2019t start, there\u2019s no charge. Model work already done can still use credits.", cause ? _jsx("span", { className: "block", children: cause }) : null] }), action: failure === "cancelled" ? undefined : (_jsx(Button, { "aria-label": "Retry prompt", variant: "outline", onClick: onRetry, children: "Retry" })) }));
13
+ const bounded = cause === undefined ? undefined : boundedDiagnostic(cause);
14
+ // Diagnostic metadata is useful to the owner, not a public explanation.
15
+ const publicCause = bounded !== undefined &&
16
+ !bounded.startsWith("reason_unavailable:") &&
17
+ !/\b(?:run|session)=/iu.test(bounded)
18
+ ? bounded
19
+ : undefined;
20
+ const interruptedWithReason = failure === "interrupted" && publicCause !== undefined;
21
+ return (_jsx(Alert, { tone: "warning", title: failure === "cancelled" ? "Taking a breather" : "A little tangle", description: interruptedWithReason ? (_jsxs(_Fragment, { children: ["This turn stopped before it finished: ", publicCause, ". Your prompt is still here."] })) : (_jsxs(_Fragment, { children: [messages[failure], " If the model didn\u2019t start, there\u2019s no charge. Model work already done can still use credits.", publicCause ? _jsx("span", { className: "block", children: publicCause }) : null] })), action: failure === "cancelled" ? undefined : (_jsx(Button, { "aria-label": "Retry prompt", variant: "outline", onClick: onRetry, children: "Retry" })) }));
13
22
  }
@@ -1,4 +1,4 @@
1
- import { WhistleActionError, } from "@fungi.computer/whistle";
1
+ import {} from "@fungi.computer/whistle";
2
2
  import { reportFailure } from "../lib/failure.js";
3
3
  function advertisedActions(entry) {
4
4
  return [
@@ -68,23 +68,6 @@ export function createClientActions(options) {
68
68
  if (intents.get(intent)?.pending)
69
69
  return;
70
70
  publish(intent, { pending: true });
71
- const notificationOnly = {
72
- // Waiting cancellation has no interactive retry surface.
73
- run: (operation) => operation(),
74
- signal: controller.signal,
75
- prompt: () => Promise.reject(new WhistleActionError("client_waiting_prompt", "A waiting cancellation cannot request an answer")),
76
- notify: (notice) => {
77
- // A success only confirms the person's own action: no echo.
78
- if (retired() || notice.type === "success")
79
- return;
80
- options.interactions.toast({
81
- variant: "info",
82
- message: notice.type === "handoff"
83
- ? (notice.message ?? notice.url)
84
- : notice.message,
85
- });
86
- },
87
- };
88
71
  let outcome;
89
72
  try {
90
73
  let input = {
@@ -93,8 +76,6 @@ export function createClientActions(options) {
93
76
  };
94
77
  if (action.input !== undefined)
95
78
  input = { ...input, arguments: action.input };
96
- if (waiting)
97
- input = { ...input, interaction: notificationOnly };
98
79
  outcome = await options.commands.execute(`${entryId}:${action.id}`, input);
99
80
  }
100
81
  catch (cause) {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * When something happened, as a picker column: `14:02` for today, `Yesterday`,
3
+ * or `2026-10-24`. Shared by every picker that lists past work.
4
+ */
5
+ export declare function formatWhen(epochMillis: number, now: number): string;
6
+ /** `text` on one line, whitespace collapsed and cut at a word to `limit`. */
7
+ export declare function oneLine(text: string, limit: number): string;
@@ -0,0 +1,24 @@
1
+ const pad = (value) => String(value).padStart(2, "0");
2
+ const dayStart = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
3
+ /**
4
+ * When something happened, as a picker column: `14:02` for today, `Yesterday`,
5
+ * or `2026-10-24`. Shared by every picker that lists past work.
6
+ */
7
+ export function formatWhen(epochMillis, now) {
8
+ const at = new Date(epochMillis);
9
+ const days = Math.round((dayStart(new Date(now)) - dayStart(at)) / 86_400_000);
10
+ if (days <= 0)
11
+ return `${pad(at.getHours())}:${pad(at.getMinutes())}`;
12
+ if (days === 1)
13
+ return "Yesterday";
14
+ return `${String(at.getFullYear())}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`;
15
+ }
16
+ /** `text` on one line, whitespace collapsed and cut at a word to `limit`. */
17
+ export function oneLine(text, limit) {
18
+ const line = text.replace(/\s+/gu, " ").trim();
19
+ if (line.length <= limit)
20
+ return line;
21
+ const cut = line.slice(0, Math.max(0, limit - 1));
22
+ const space = cut.lastIndexOf(" ");
23
+ return `${(space > limit / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
24
+ }
@@ -46,7 +46,7 @@ export type SessionViewState = Readonly<{
46
46
  }> | Readonly<{
47
47
  /** The latest terminal failed or was interrupted. */
48
48
  kind: "error";
49
- /** Bounded terminal diagnostic suitable for presentation. */
49
+ /** Redacted, bounded terminal diagnostic for presentation. */
50
50
  cause: string;
51
51
  }>;
52
52
  /** The facts a view state is decided from. */
@@ -1,8 +1,15 @@
1
+ import { boundedDiagnostic } from "@fungi.computer/bird-dog/effect";
2
+ import stripAnsi from "strip-ansi";
1
3
  const retryCauseLimit = 160;
2
- const errorCauseLimit = 200;
3
4
  function collapsed(text, limit) {
4
5
  return text.replace(/\s+/gu, " ").trim().slice(0, limit);
5
6
  }
7
+ function displayDiagnostic(text) {
8
+ // Recognize whole terminal programs before dropping individual controls:
9
+ // otherwise OSC payloads become visible text after their ESC is removed.
10
+ const safe = stripAnsi(text).replace(/[\p{Cc}\u200b\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069\u2060\ufeff]/gu, (character) => (/\s/u.test(character) ? " " : ""));
11
+ return boundedDiagnostic(safe);
12
+ }
6
13
  function idleViewState({ activity, terminals, }) {
7
14
  let terminal;
8
15
  for (const candidate of terminals)
@@ -15,14 +22,15 @@ function idleViewState({ activity, terminals, }) {
15
22
  if (activity.lastModelChoiceSequence !== null &&
16
23
  activity.lastModelChoiceSequence > terminal.sequence)
17
24
  return { kind: "waiting_for_you" };
18
- if (terminal.outcome === "failed" || terminal.outcome === "interrupted")
25
+ if (terminal.outcome === "failed" || terminal.outcome === "interrupted") {
26
+ const fallback = terminal.outcome === "interrupted"
27
+ ? "Run was interrupted"
28
+ : "Run failed without a diagnostic";
19
29
  return {
20
30
  kind: "error",
21
- cause: collapsed(terminal.diagnostic ??
22
- (terminal.outcome === "interrupted"
23
- ? "Run was interrupted"
24
- : "Run failed without a diagnostic"), errorCauseLimit),
31
+ cause: displayDiagnostic(terminal.diagnostic ?? fallback) ?? fallback,
25
32
  };
33
+ }
26
34
  return { kind: "waiting_for_you" };
27
35
  }
28
36
  /** Decide a Session's view state from its activity, terminals and progress. */
@@ -2,6 +2,7 @@ export { formatModelIdentity, formatModelWithLevel, } from "./core/model-identit
2
2
  export type { QueuedRecallState, QueuedRecallOutcome, } from "./core/queued-recall.js";
3
3
  export type { ParakeetDraftIdentity, ParakeetDraftStorage, } from "./core/draft-storage.js";
4
4
  export { formatContextUsage, formatTokenCount } from "./core/format-context.js";
5
+ export { formatWhen, oneLine } from "./core/picker-text.js";
5
6
  export { providerChoices } from "./provider-choices.js";
6
7
  export { ParakeetProvider, useComposer, useClientContributions, useParakeetInteractions, useExtensionSlots, useParakeetSession, useStatus, useTimeline, type ComposerController, type ParakeetDraftCheckpoint, type ParakeetModel, type ParakeetObservation, type ParakeetReconnectOutcome, type ParakeetStatus, type ParakeetSessionViewState, type ParakeetUsage, type StatusController, type TimelineController, } from "./conversation.js";
7
8
  export { sessionViewState, queuedSessionLabel, sessionActivityStatus, type SessionActivityStatus, type SessionViewFacts, type SessionViewState as ParakeetOwnerViewState, } from "./core/session-view-state.js";
package/dist/headless.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { formatModelIdentity, formatModelWithLevel, } from "./core/model-identity.js";
2
2
  export { formatContextUsage, formatTokenCount } from "./core/format-context.js";
3
+ export { formatWhen, oneLine } from "./core/picker-text.js";
3
4
  export { providerChoices } from "./provider-choices.js";
4
5
  export { ParakeetProvider, useComposer, useClientContributions, useParakeetInteractions, useExtensionSlots, useParakeetSession, useStatus, useTimeline, } from "./conversation.js";
5
6
  export { sessionViewState, queuedSessionLabel, sessionActivityStatus, } from "./core/session-view-state.js";
@@ -8,9 +8,6 @@ const defaultChoice = "default";
8
8
  const recentModels = new WeakMap();
9
9
  /** Private memory ceiling, independent of how many models a provider offers. */
10
10
  const maximumRememberedProviders = 256;
11
- function modelLabel(model) {
12
- return `${model.providerName} · ${model.name}`;
13
- }
14
11
  /** Each provider's current or last-used model first, then catalog order. */
15
12
  function orderModels(models, current, recent) {
16
13
  return [...models].sort((left, right) => {
@@ -33,19 +30,18 @@ async function chooseReasoning(interaction, model, current) {
33
30
  const answer = await interaction.prompt({
34
31
  type: "choice",
35
32
  message: `Thinking level for ${model.name}`,
33
+ summary: "How long the model thinks before it answers.",
34
+ current: current ?? defaultChoice,
36
35
  options: [
37
36
  { value: defaultChoice, label: "Model default" },
38
- ...model.reasoning.map((level) => ({
39
- value: level,
40
- label: level === current ? `${level} (current)` : level,
41
- })),
37
+ ...model.reasoning.map((level) => ({ value: level, label: level })),
42
38
  ],
43
39
  });
44
40
  return model.reasoning.find((level) => level === answer);
45
41
  }
46
42
  /** Leave one Whistle option for inheriting the Agent model. */
47
43
  const maximumModelOptions = 255;
48
- async function chooseProviderModels(interaction, models, account, refreshModels) {
44
+ async function chooseProviderModels(interaction, models, account, refreshModels, currentProviderId) {
49
45
  const { owner } = account;
50
46
  const [access, registry] = await Promise.all([
51
47
  owner.access(interaction.signal),
@@ -76,7 +72,9 @@ async function chooseProviderModels(interaction, models, account, refreshModels)
76
72
  }
77
73
  const providerId = await interaction.prompt({
78
74
  type: "choice",
79
- message: "Model",
75
+ message: "Choose a provider",
76
+ summary: "Or follow the Agent's own model.",
77
+ current: currentProviderId ?? defaultChoice,
80
78
  collapsedGroup: "More providers",
81
79
  options: [
82
80
  { value: defaultChoice, label: "Use agent model" },
@@ -156,21 +154,22 @@ export function sessionModelCommandDefinitions(session, options) {
156
154
  return session.modelPreference.read();
157
155
  const current = await session.modelPreference.read();
158
156
  remember(current);
159
- const providerModels = await chooseProviderModels(interaction, options.models, options.account, options.refreshModels);
157
+ const providerModels = await chooseProviderModels(interaction, options.models, options.account, options.refreshModels, current?.providerId);
160
158
  if (providerModels === null) {
161
159
  await interaction.run(() => session.modelPreference.set(null));
162
160
  return interaction.run(() => session.modelPreference.read());
163
161
  }
164
162
  const ordered = orderModels(providerModels, current, recent);
163
+ const currentIndex = ordered.findIndex((model) => model.providerId === current?.providerId &&
164
+ model.id === current.modelId);
165
165
  const answer = await interaction.prompt({
166
166
  type: "choice",
167
- message: "Model",
167
+ message: "Choose a model",
168
+ summary: `Models from ${ordered[0]?.providerName ?? "this provider"}.`,
169
+ current: currentIndex < 0 ? undefined : String(currentIndex),
168
170
  options: ordered.map((model, index) => ({
169
171
  value: String(index),
170
- label: model.providerId === current?.providerId &&
171
- model.id === current.modelId
172
- ? `${modelLabel(model)} (current)`
173
- : modelLabel(model),
172
+ label: model.name,
174
173
  })),
175
174
  });
176
175
  const model = ordered[Number(answer)];
@@ -2,6 +2,7 @@ import { nanoid } from "@fungi.computer/cairn";
2
2
  import { ShiitakeError } from "@fungi.computer/shiitake";
3
3
  import { z } from "zod";
4
4
  import { buildDurableTimeline } from "./core/build-timeline.js";
5
+ import { formatWhen, oneLine } from "./core/picker-text.js";
5
6
  import { sessionActivityStatus, sessionViewState, } from "./core/session-view-state.js";
6
7
  /** A choice prompt holds at most 256 options; the default read already bounds this. */
7
8
  const maxForkOptions = 200;
@@ -132,12 +133,15 @@ export function contributeSessionCommands(commands, session, options) {
132
133
  if (interaction === undefined)
133
134
  throw new Error("Forking a Session needs a person.");
134
135
  const ordered = [...candidates].reverse().slice(0, maxForkOptions);
136
+ const now = Date.now();
135
137
  const answer = await interaction.prompt({
136
138
  type: "choice",
137
- message: "Fork from message",
139
+ message: "Fork from which message?",
140
+ summary: "Start a new conversation from your message.",
138
141
  options: ordered.map((item) => ({
139
142
  value: item.id,
140
- label: item.text.slice(0, 100) || "(empty message)",
143
+ label: oneLine(item.text, 44) || "(empty message)",
144
+ description: item.at === undefined ? undefined : formatWhen(item.at, now),
141
145
  })),
142
146
  });
143
147
  const forked = await interaction.run(async () => {
package/dist/styles.css CHANGED
@@ -5932,6 +5932,12 @@
5932
5932
  --mask-chat: url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");
5933
5933
  }
5934
5934
  }
5935
+ .line-clamp-3 {
5936
+ overflow: hidden;
5937
+ display: -webkit-box;
5938
+ -webkit-box-orient: vertical;
5939
+ -webkit-line-clamp: 3;
5940
+ }
5935
5941
  .prose {
5936
5942
  :root & {
5937
5943
  --tw-prose-body: var(--color-base-content);
@@ -6176,6 +6182,9 @@
6176
6182
  .max-w-\[48rem\] {
6177
6183
  max-width: 48rem;
6178
6184
  }
6185
+ .max-w-\[50\%\] {
6186
+ max-width: 50%;
6187
+ }
6179
6188
  .max-w-\[80ch\] {
6180
6189
  max-width: 80ch;
6181
6190
  }
@@ -6784,6 +6793,9 @@
6784
6793
  .whitespace-pre {
6785
6794
  white-space: pre;
6786
6795
  }
6796
+ .whitespace-pre-line {
6797
+ white-space: pre-line;
6798
+ }
6787
6799
  .whitespace-pre-wrap {
6788
6800
  white-space: pre-wrap;
6789
6801
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/parakeet",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "private": false,
6
6
  "description": "Standalone React composer for a typed Shiitake Session",
@@ -47,15 +47,17 @@
47
47
  "react-dom": "^18.2.0 || ^19.2.0"
48
48
  },
49
49
  "dependencies": {
50
- "@fungi.computer/agar": "0.3.1",
50
+ "@fungi.computer/agar": "0.4.0",
51
+ "@fungi.computer/bird-dog": "0.2.1",
51
52
  "@fungi.computer/cairn": "0.1.0",
52
- "@fungi.computer/caps": "0.3.0",
53
- "@fungi.computer/knapsack": "0.3.0",
54
- "@fungi.computer/shiitake": "0.4.0",
53
+ "@fungi.computer/caps": "0.3.1",
54
+ "@fungi.computer/gills": "0.2.0",
55
+ "@fungi.computer/knapsack": "0.3.1",
56
+ "@fungi.computer/shiitake": "0.5.0",
55
57
  "@fungi.computer/sigils": "0.2.0",
56
58
  "@fungi.computer/sprite": "0.2.0",
57
59
  "@fungi.computer/sprite-zdog": "0.2.0",
58
- "@fungi.computer/whistle": "0.1.0",
60
+ "@fungi.computer/whistle": "0.2.0",
59
61
  "@tanstack/markdown": "0.0.12",
60
62
  "@tanstack/react-virtual": "3.14.10",
61
63
  "diff": "8.0.4",
@@ -64,8 +66,7 @@
64
66
  "jotai-tanstack-query": "0.11.0",
65
67
  "lucide-react": "0.460.0",
66
68
  "strip-ansi": "7.2.0",
67
- "zod": "4.4.3",
68
- "@fungi.computer/gills": "0.2.0"
69
+ "zod": "4.4.3"
69
70
  },
70
71
  "dependenciesMeta": {
71
72
  "@fungi.computer/caps": {