@fungi.computer/parakeet 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +186 -2
  3. package/dist/account-commands.d.ts +102 -0
  4. package/dist/account-commands.js +239 -0
  5. package/dist/agent-head.d.ts +4 -0
  6. package/dist/agent-head.js +2 -0
  7. package/dist/client-contributions.d.ts +20 -0
  8. package/dist/client-contributions.js +41 -0
  9. package/dist/commands/advertised.d.ts +23 -0
  10. package/dist/commands/advertised.js +72 -0
  11. package/dist/commands/builtins.d.ts +14 -0
  12. package/dist/commands/builtins.js +91 -0
  13. package/dist/commands/hints.d.ts +36 -0
  14. package/dist/commands/hints.js +64 -0
  15. package/dist/commands/interactions.d.ts +55 -0
  16. package/dist/commands/interactions.js +101 -0
  17. package/dist/commands/run.d.ts +12 -0
  18. package/dist/commands/run.js +64 -0
  19. package/dist/commands/slash.d.ts +126 -0
  20. package/dist/commands/slash.js +190 -0
  21. package/dist/commands/use-slash-commands.d.ts +57 -0
  22. package/dist/commands/use-slash-commands.js +207 -0
  23. package/dist/components/AgentHead.d.ts +22 -0
  24. package/dist/components/AgentHead.js +20 -0
  25. package/dist/components/ChatComposer.d.ts +43 -0
  26. package/dist/components/ChatComposer.js +209 -0
  27. package/dist/components/ChatMessage.d.ts +8 -0
  28. package/dist/components/ChatMessage.js +16 -0
  29. package/dist/components/ClientRows.d.ts +11 -0
  30. package/dist/components/ClientRows.js +46 -0
  31. package/dist/components/CodeBlock.d.ts +11 -0
  32. package/dist/components/CodeBlock.js +38 -0
  33. package/dist/components/InteractionPanel.d.ts +15 -0
  34. package/dist/components/InteractionPanel.js +42 -0
  35. package/dist/components/MarkdownRenderer.d.ts +6 -0
  36. package/dist/components/MarkdownRenderer.js +48 -0
  37. package/dist/components/RunFailureNotice.d.ts +7 -0
  38. package/dist/components/RunFailureNotice.js +13 -0
  39. package/dist/components/ShiitakeAvatar.d.ts +21 -0
  40. package/dist/components/ShiitakeAvatar.js +22 -0
  41. package/dist/components/SlashMenu.d.ts +8 -0
  42. package/dist/components/SlashMenu.js +50 -0
  43. package/dist/components/ThinkingBlock.d.ts +3 -0
  44. package/dist/components/ThinkingBlock.js +18 -0
  45. package/dist/components/TimelineItem.d.ts +15 -0
  46. package/dist/components/TimelineItem.js +35 -0
  47. package/dist/components/ToolGroup.d.ts +12 -0
  48. package/dist/components/ToolGroup.js +90 -0
  49. package/dist/components/WaitingNotice.d.ts +7 -0
  50. package/dist/components/WaitingNotice.js +8 -0
  51. package/dist/components/avatar-presentation.d.ts +16 -0
  52. package/dist/components/avatar-presentation.js +9 -0
  53. package/dist/components/sprite-service.d.ts +2 -0
  54. package/dist/components/sprite-service.js +9 -0
  55. package/dist/conversation.d.ts +165 -0
  56. package/dist/conversation.js +762 -0
  57. package/dist/core/assistant-visibility.d.ts +3 -0
  58. package/dist/core/assistant-visibility.js +4 -0
  59. package/dist/core/author-labels.d.ts +16 -0
  60. package/dist/core/author-labels.js +16 -0
  61. package/dist/core/build-timeline.d.ts +36 -0
  62. package/dist/core/build-timeline.js +328 -0
  63. package/dist/core/client-actions.d.ts +35 -0
  64. package/dist/core/client-actions.js +128 -0
  65. package/dist/core/client-contributions.d.ts +24 -0
  66. package/dist/core/client-contributions.js +115 -0
  67. package/dist/core/client-timeline.d.ts +33 -0
  68. package/dist/core/client-timeline.js +72 -0
  69. package/dist/core/format-context.d.ts +11 -0
  70. package/dist/core/format-context.js +20 -0
  71. package/dist/core/format-tokens.d.ts +2 -0
  72. package/dist/core/format-tokens.js +6 -0
  73. package/dist/core/model-identity.d.ts +3 -0
  74. package/dist/core/model-identity.js +7 -0
  75. package/dist/core/session-projection.d.ts +11 -0
  76. package/dist/core/session-projection.js +191 -0
  77. package/dist/core/session-query.d.ts +118 -0
  78. package/dist/core/session-query.js +187 -0
  79. package/dist/core/session-view-state.d.ts +66 -0
  80. package/dist/core/session-view-state.js +114 -0
  81. package/dist/core/tool-contract.d.ts +15 -0
  82. package/dist/core/tool-contract.js +3 -0
  83. package/dist/core/tool-groups.d.ts +36 -0
  84. package/dist/core/tool-groups.js +151 -0
  85. package/dist/core/tool-view.d.ts +182 -0
  86. package/dist/core/tool-view.js +618 -0
  87. package/dist/headless.d.ts +28 -0
  88. package/dist/headless.js +24 -0
  89. package/dist/index.d.ts +9 -0
  90. package/dist/index.js +4 -0
  91. package/dist/lib/failure.d.ts +2 -0
  92. package/dist/lib/failure.js +9 -0
  93. package/dist/markdown.d.ts +3 -0
  94. package/dist/markdown.js +6 -0
  95. package/dist/model-commands.d.ts +44 -0
  96. package/dist/model-commands.js +232 -0
  97. package/dist/parakeet.d.ts +77 -0
  98. package/dist/parakeet.js +276 -0
  99. package/dist/provider-choices.d.ts +8 -0
  100. package/dist/provider-choices.js +46 -0
  101. package/dist/reload-command.d.ts +43 -0
  102. package/dist/reload-command.js +72 -0
  103. package/dist/session-commands.d.ts +53 -0
  104. package/dist/session-commands.js +259 -0
  105. package/dist/session.d.ts +24 -0
  106. package/dist/session.js +1 -0
  107. package/dist/store/types.d.ts +163 -0
  108. package/dist/store/types.js +1 -0
  109. package/dist/styles.css +19129 -0
  110. package/docs/api/README.md +15 -0
  111. package/docs/api/commands/slash.md +321 -0
  112. package/docs/api/headless.md +2510 -0
  113. package/docs/api/index.md +1249 -0
  114. package/package.json +79 -3
  115. package/src/styles.css +272 -0
@@ -0,0 +1,187 @@
1
+ import { experimental_streamedQuery, infiniteQueryOptions, queryOptions, QueryObserver, } from "@tanstack/react-query";
2
+ import { applySessionFrame } from "./session-projection.js";
3
+ import { reportFailure } from "../lib/failure.js";
4
+ const emptyProjection = Object.freeze({
5
+ observation: "connecting",
6
+ snapshot: undefined,
7
+ latestPrompt: undefined,
8
+ });
9
+ // A watch starts with a bounded page, but its update frames keep arriving for
10
+ // the lifetime of the client. Refresh from the owner before that page becomes
11
+ // an ever-growing in-memory transcript. The smaller read leaves room for a
12
+ // burst of frames without reading on every tool result.
13
+ const LIVE_HISTORY_LIMIT = 200;
14
+ const LIVE_HISTORY_REFRESH_AT = 400;
15
+ function latestPrompt(messages, previous) {
16
+ for (let index = messages.length - 1; index >= 0; index--) {
17
+ const message = messages[index];
18
+ if (message?.value.role !== "user" || message.commandId === null)
19
+ continue;
20
+ const content = message.value.content;
21
+ const text = Array.isArray(content)
22
+ ? content
23
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
24
+ .join("\n")
25
+ : content;
26
+ return [message.commandId, text];
27
+ }
28
+ return previous;
29
+ }
30
+ async function* observeSession(session, signal) {
31
+ yield { kind: "opening" };
32
+ const watch = await session.watch({ limit: LIVE_HISTORY_LIMIT });
33
+ let closing;
34
+ const close = () => {
35
+ closing ??= watch.close();
36
+ return closing;
37
+ };
38
+ const onAbort = () => {
39
+ // The generator finalizer collects this same promise; observe it immediately.
40
+ void close().catch((cause) => {
41
+ reportFailure("close aborted Session watch", cause);
42
+ });
43
+ };
44
+ signal.addEventListener("abort", onAbort, { once: true });
45
+ try {
46
+ if (signal.aborted)
47
+ return;
48
+ let snapshot = watch.snapshot;
49
+ yield { kind: "snapshot", value: snapshot };
50
+ for await (const frame of watch.changes) {
51
+ snapshot = applySessionFrame(snapshot, frame);
52
+ if (snapshot.messages.length > LIVE_HISTORY_REFRESH_AT) {
53
+ snapshot = await session.read({ limit: LIVE_HISTORY_LIMIT });
54
+ yield { kind: "snapshot", value: snapshot };
55
+ }
56
+ else {
57
+ yield { kind: "frame", value: frame };
58
+ }
59
+ }
60
+ throw new Error("Session watch ended; current activity is unavailable");
61
+ }
62
+ catch (error) {
63
+ if (!signal.aborted)
64
+ throw error;
65
+ }
66
+ finally {
67
+ signal.removeEventListener("abort", onAbort);
68
+ await close();
69
+ }
70
+ }
71
+ function reduceObservation(projection, observation) {
72
+ if (observation.kind === "opening")
73
+ return { ...projection, observation: "connecting" };
74
+ if (observation.kind === "snapshot")
75
+ return {
76
+ observation: "live",
77
+ snapshot: observation.value,
78
+ latestPrompt: latestPrompt(observation.value.messages, projection.latestPrompt),
79
+ };
80
+ if (projection.snapshot === undefined)
81
+ return observation.value.kind === "reset"
82
+ ? {
83
+ observation: "live",
84
+ snapshot: observation.value.baseline,
85
+ latestPrompt: latestPrompt(observation.value.baseline.messages),
86
+ }
87
+ : projection;
88
+ const snapshot = applySessionFrame(projection.snapshot, observation.value);
89
+ return {
90
+ observation: "live",
91
+ snapshot,
92
+ latestPrompt: latestPrompt(snapshot.messages, projection.latestPrompt),
93
+ };
94
+ }
95
+ /** One shared TanStack observation keyed by Shiitake's logical Session address. */
96
+ export function sessionQueryOptions(session) {
97
+ const queryKey = ["shiitake-session", session.address];
98
+ return queryOptions({
99
+ queryKey,
100
+ queryFn: experimental_streamedQuery({
101
+ streamFn: ({ signal }) => observeSession(session, signal),
102
+ reducer: reduceObservation,
103
+ initialValue: emptyProjection,
104
+ refetchMode: "append",
105
+ }),
106
+ refetchOnMount: "always",
107
+ retry: false,
108
+ staleTime: Number.POSITIVE_INFINITY,
109
+ });
110
+ }
111
+ /** Seed the shared observation with a bounded read before its watch mounts. */
112
+ export function seedSessionQuery(queryClient, session, snapshot) {
113
+ queryClient.setQueryData(sessionQueryOptions(session).queryKey, {
114
+ observation: "connecting",
115
+ snapshot,
116
+ latestPrompt: latestPrompt(snapshot.messages),
117
+ });
118
+ }
119
+ /** Borrow activity from the existing cache without starting another watch. */
120
+ export function subscribeSessionActivity(queryClient, session, listener) {
121
+ const observer = new QueryObserver(queryClient, {
122
+ ...sessionQueryOptions(session),
123
+ enabled: false,
124
+ });
125
+ const report = (result) => {
126
+ const snapshot = result.data?.snapshot;
127
+ listener(result.isError ||
128
+ result.data?.observation !== "live" ||
129
+ snapshot === undefined
130
+ ? "unknown"
131
+ : snapshot.activity.queue.state === "idle"
132
+ ? "idle"
133
+ : "working");
134
+ };
135
+ const unsubscribe = observer.subscribe(report);
136
+ report(observer.getCurrentResult());
137
+ return unsubscribe;
138
+ }
139
+ /** Shared, demand-loaded history; the live watch remains a separate small tail. */
140
+ export function sessionHistoryQueryOptions(session, before = null) {
141
+ const initialPageParam = {
142
+ before,
143
+ newerBefore: [],
144
+ newerOverflow: false,
145
+ };
146
+ return infiniteQueryOptions({
147
+ queryKey: ["shiitake-session-history", session.address],
148
+ initialPageParam,
149
+ queryFn: async ({ pageParam, signal }) => {
150
+ if (pageParam.before === null)
151
+ throw new Error("Session has no older history cursor");
152
+ const page = await session.read({ before: pageParam.before, limit: 100 });
153
+ signal.throwIfAborted();
154
+ return page;
155
+ },
156
+ getNextPageParam: (last, _pages, cursor) => {
157
+ if (last.oldestCursor === null || last.oldestCursor === undefined)
158
+ return undefined;
159
+ const newerBefore = cursor.before === null
160
+ ? cursor.newerBefore
161
+ : [...cursor.newerBefore, cursor.before];
162
+ return {
163
+ before: last.oldestCursor,
164
+ newerBefore: newerBefore.slice(-2048),
165
+ newerOverflow: cursor.newerOverflow || newerBefore.length > 2048,
166
+ };
167
+ },
168
+ getPreviousPageParam: (_first, _pages, cursor) => {
169
+ const before = cursor.newerBefore.at(-1);
170
+ return before === undefined
171
+ ? undefined
172
+ : {
173
+ before,
174
+ newerBefore: cursor.newerBefore.slice(0, -1),
175
+ newerOverflow: cursor.newerOverflow,
176
+ };
177
+ },
178
+ select: (data) => ({
179
+ pages: data.pages,
180
+ newerOverflow: data.pageParams[0]?.newerOverflow ?? false,
181
+ }),
182
+ maxPages: 4,
183
+ enabled: false,
184
+ retry: false,
185
+ staleTime: Number.POSITIVE_INFINITY,
186
+ });
187
+ }
@@ -0,0 +1,66 @@
1
+ import type { AgentRunningWork, SessionCompactionTrigger, SessionSnapshot } from "@fungi.computer/shiitake/client";
2
+ /**
3
+ * What a person's readout of one Session says, decided from Shiitake's raw
4
+ * activity facts. Shiitake publishes facts; this module owns their meaning.
5
+ */
6
+ export type SessionViewState = Readonly<{
7
+ /** Current activity presented to the person. */
8
+ kind: "idle" | "waiting_for_you" | "thinking" | "responding";
9
+ }> | Readonly<{
10
+ /** Cancellation is waiting for current work to settle. */
11
+ kind: "cancelling";
12
+ }> | Readonly<{
13
+ /** A compaction is running now, started by this trigger. */
14
+ kind: "compacting";
15
+ /** What started it: the user, the context threshold, or an overflow. */
16
+ trigger: SessionCompactionTrigger;
17
+ }>
18
+ /**
19
+ * The Session's accepted work has not started in the Agent. Cancelling
20
+ * `queuedCommandId` with `onlyIfPending` withdraws the wait, or reports
21
+ * `too_late` once the work has started.
22
+ */
23
+ | Readonly<{
24
+ /** Accepted work is waiting in the Agent queue. */
25
+ kind: "queued";
26
+ /** Exact command that can be withdrawn before it starts. */
27
+ queuedCommandId: string;
28
+ /** Live work occupying the executor, or null while awaiting the host. */
29
+ runningWork: AgentRunningWork | null;
30
+ }> | Readonly<{
31
+ /** Provider retry is scheduled. */
32
+ kind: "retrying";
33
+ /** Current retry attempt. */
34
+ attempt: number;
35
+ /** Maximum attempts allowed by the run policy. */
36
+ maxAttempts: number;
37
+ /** Bounded reason suitable for presentation. */
38
+ cause: string;
39
+ /** Scheduled retry time, in milliseconds since the epoch. */
40
+ nextRetryAt: number;
41
+ }> | Readonly<{
42
+ /** One tool is running. */
43
+ kind: "running_tool";
44
+ /** Name of the active tool. */
45
+ tool: string;
46
+ }> | Readonly<{
47
+ /** The latest terminal failed or was interrupted. */
48
+ kind: "error";
49
+ /** Bounded terminal diagnostic suitable for presentation. */
50
+ cause: string;
51
+ }>;
52
+ /** The facts a view state is decided from. */
53
+ export type SessionViewFacts = Pick<SessionSnapshot, "activity" | "terminals" | "progress">;
54
+ /** Coarse activity of a Session, for composer gating. */
55
+ export type SessionActivityStatus = "cancelling" | "idle" | "queued" | "retrying" | "streaming" | "thinking";
56
+ /** Decide a Session's view state from its activity, terminals and progress. */
57
+ export declare function sessionViewState(facts: SessionViewFacts): SessionViewState;
58
+ export declare function sameRunningWork(left: AgentRunningWork | null, right: AgentRunningWork | null): boolean;
59
+ /** Explain a queued Session using its owner's executor fact. */
60
+ export declare function queuedSessionLabel(state: Extract<SessionViewState, {
61
+ kind: "queued";
62
+ }>): string;
63
+ /** Whether two view states say the same thing, so a readout can stay stable. */
64
+ export declare function sameSessionViewState(left: SessionViewState, right: SessionViewState): boolean;
65
+ /** Coarse activity for a view state: what the composer gates on. */
66
+ export declare function sessionActivityStatus(view: SessionViewState): SessionActivityStatus;
@@ -0,0 +1,114 @@
1
+ const retryCauseLimit = 160;
2
+ const errorCauseLimit = 200;
3
+ function collapsed(text, limit) {
4
+ return text.replace(/\s+/gu, " ").trim().slice(0, limit);
5
+ }
6
+ function idleViewState({ activity, terminals, }) {
7
+ let terminal;
8
+ for (const candidate of terminals)
9
+ if (terminal === undefined || candidate.sequence > terminal.sequence)
10
+ terminal = candidate;
11
+ if (terminal === undefined)
12
+ return { kind: "idle" };
13
+ // A later model choice acknowledges the failed run while leaving its
14
+ // diagnostic and terminal in history for inspection.
15
+ if (activity.lastModelChoiceSequence !== null &&
16
+ activity.lastModelChoiceSequence > terminal.sequence)
17
+ return { kind: "waiting_for_you" };
18
+ if (terminal.outcome === "failed" || terminal.outcome === "interrupted")
19
+ return {
20
+ kind: "error",
21
+ cause: collapsed(terminal.diagnostic ??
22
+ (terminal.outcome === "interrupted"
23
+ ? "Run was interrupted"
24
+ : "Run failed without a diagnostic"), errorCauseLimit),
25
+ };
26
+ return { kind: "waiting_for_you" };
27
+ }
28
+ /** Decide a Session's view state from its activity, terminals and progress. */
29
+ export function sessionViewState(facts) {
30
+ const { activity, progress } = facts;
31
+ const { queue } = activity;
32
+ if (queue.state === "idle")
33
+ return idleViewState(facts);
34
+ if (activity.cancellationRequested)
35
+ return { kind: "cancelling" };
36
+ if (queue.state === "queued")
37
+ return {
38
+ kind: "queued",
39
+ queuedCommandId: queue.commandId,
40
+ runningWork: queue.runningWork,
41
+ };
42
+ if (activity.compaction !== null)
43
+ return { kind: "compacting", trigger: activity.compaction.trigger };
44
+ if (activity.retry !== null)
45
+ return {
46
+ kind: "retrying",
47
+ attempt: activity.retry.attempt,
48
+ maxAttempts: activity.retry.maxAttempts,
49
+ cause: collapsed(activity.retry.errorMessage, retryCauseLimit),
50
+ nextRetryAt: activity.retry.scheduledAt + activity.retry.delayMs,
51
+ };
52
+ if (activity.activeTool !== null)
53
+ return { kind: "running_tool", tool: activity.activeTool };
54
+ return {
55
+ kind: progress === null || progress.text === "" ? "thinking" : "responding",
56
+ };
57
+ }
58
+ export function sameRunningWork(left, right) {
59
+ if (left === null || right === null)
60
+ return left === right;
61
+ if (left.kind === "session" && right.kind === "session")
62
+ return (left.sessionId === right.sessionId && left.commandId === right.commandId);
63
+ if (left.kind === "host" && right.kind === "host")
64
+ return left.lane === right.lane && left.jobId === right.jobId;
65
+ return false;
66
+ }
67
+ /** Explain a queued Session using its owner's executor fact. */
68
+ export function queuedSessionLabel(state) {
69
+ const work = state.runningWork;
70
+ if (work === null)
71
+ return "Queued for host";
72
+ return work.kind === "session"
73
+ ? `Waiting for Session ${collapsed(work.sessionId, 256)}`
74
+ : `Waiting for host job ${collapsed(work.lane, 256)} · ${collapsed(work.jobId, 256)}`;
75
+ }
76
+ /** Whether two view states say the same thing, so a readout can stay stable. */
77
+ export function sameSessionViewState(left, right) {
78
+ if (left.kind !== right.kind)
79
+ return false;
80
+ if (left.kind === "queued" && right.kind === "queued")
81
+ return (left.queuedCommandId === right.queuedCommandId &&
82
+ sameRunningWork(left.runningWork, right.runningWork));
83
+ if (left.kind === "running_tool" && right.kind === "running_tool")
84
+ return left.tool === right.tool;
85
+ if (left.kind === "compacting" && right.kind === "compacting")
86
+ return left.trigger === right.trigger;
87
+ if (left.kind === "retrying" && right.kind === "retrying")
88
+ return (left.attempt === right.attempt &&
89
+ left.maxAttempts === right.maxAttempts &&
90
+ left.cause === right.cause &&
91
+ left.nextRetryAt === right.nextRetryAt);
92
+ if (left.kind === "error" && right.kind === "error")
93
+ return left.cause === right.cause;
94
+ return true;
95
+ }
96
+ /** Coarse activity for a view state: what the composer gates on. */
97
+ export function sessionActivityStatus(view) {
98
+ switch (view.kind) {
99
+ case "cancelling":
100
+ case "queued":
101
+ case "retrying":
102
+ return view.kind;
103
+ case "responding":
104
+ return "streaming";
105
+ case "thinking":
106
+ case "running_tool":
107
+ case "compacting":
108
+ return "thinking";
109
+ case "idle":
110
+ case "waiting_for_you":
111
+ case "error":
112
+ return "idle";
113
+ }
114
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ import type { SessionMessage } from "@fungi.computer/shiitake/client";
3
+ /** Exact JSON argument object carried by a Pi tool call into Parakeet. */
4
+ export declare const ToolArgumentsSchema: z.ZodRecord<z.ZodString, z.ZodJSONSchema>;
5
+ /** Pi content used to derive tool arguments. */
6
+ export type ParakeetAssistantPart = Extract<SessionMessage["value"], {
7
+ role: "assistant";
8
+ }>["content"][number];
9
+ /**
10
+ * Pi's JSON argument object after the maintained Zod boundary parser.
11
+ * @useDeclaredType
12
+ */
13
+ export type ToolArguments = Extract<ParakeetAssistantPart, {
14
+ type: "toolCall";
15
+ }>["arguments"];
@@ -0,0 +1,3 @@
1
+ import { z } from "zod";
2
+ /** Exact JSON argument object carried by a Pi tool call into Parakeet. */
3
+ export const ToolArgumentsSchema = z.record(z.string(), z.json());
@@ -0,0 +1,36 @@
1
+ import type { ProjectedItem, TimelineItem, ToolGroupItem, ToolItem } from "../store/types.js";
2
+ import { type ToolCallView } from "./tool-view.js";
3
+ /** Build one group from a non-empty run of consecutive calls. */
4
+ export declare function toolGroup(tools: readonly [ToolItem, ...ToolItem[]]): ToolGroupItem;
5
+ /**
6
+ * Fold a stretch of tool work into one group. User rows and rendered assistant
7
+ * content end the stretch; invisible assistant rows can span it.
8
+ */
9
+ export declare function groupToolCalls(items: readonly ProjectedItem[]): TimelineItem[];
10
+ /** View-only open/closed state shared by the browser and terminal renderers. */
11
+ export type ToolDisclosureState = Readonly<{
12
+ /**
13
+ * `default` follows each row's default; `expanded` and `collapsed` are the
14
+ * terminal's global details toggle and override defaults.
15
+ */
16
+ mode: "collapsed" | "default" | "expanded";
17
+ /** Explicit reader choices by group or call id; they win over the mode. */
18
+ overrides: ReadonlyMap<string, boolean>;
19
+ }>;
20
+ /** Defaults only, with no reader choices. */
21
+ export declare const initialToolDisclosure: ToolDisclosureState;
22
+ /** Active and failed groups show every call; clean history retains a visible tail. */
23
+ export declare function toolGroupDefaultOpen(group: ToolGroupItem): boolean;
24
+ /** Even a folded group shows recent calls, rather than hiding all tool work. */
25
+ export declare function visibleToolCalls(group: ToolGroupItem, open: boolean): readonly ToolCallView[];
26
+ /** A failed call opens by default so its error is visible. */
27
+ export declare function toolCallDefaultOpen(call: ToolCallView): boolean;
28
+ /** Whether a group or call is currently open. */
29
+ export declare function disclosureOpen(state: ToolDisclosureState, id: string, defaultOpen: boolean): boolean;
30
+ /** Flip one group or call, recording the reader's explicit choice. */
31
+ export declare function toggleDisclosure(state: ToolDisclosureState, id: string, defaultOpen: boolean): ToolDisclosureState;
32
+ /**
33
+ * Expand everything, or collapse everything when the reader has expanded
34
+ * anything. Either way individual choices are cleared.
35
+ */
36
+ export declare function toggleAllDisclosure(state: ToolDisclosureState): ToolDisclosureState;
@@ -0,0 +1,151 @@
1
+ import { assistantMessageVisible } from "./assistant-visibility.js";
2
+ import { toolCallView } from "./tool-view.js";
3
+ const nouns = {
4
+ code: ["ran", "script", "scripts"],
5
+ command: ["ran", "command", "commands"],
6
+ edit: ["edited", "file", "files"],
7
+ other: ["called", "tool", "tools"],
8
+ read: ["read", "file", "files"],
9
+ search: ["searched", "pattern", "patterns"],
10
+ };
11
+ /**
12
+ * One-line summary from typed kinds, in first-seen order:
13
+ * `Read 3 files, ran 2 commands, edited 1 file`. Files count distinct paths.
14
+ */
15
+ function summarizeToolCalls(calls) {
16
+ const counts = new Map();
17
+ for (const call of calls) {
18
+ const entry = counts.get(call.kind) ?? { calls: 0, paths: new Set() };
19
+ entry.calls += 1;
20
+ for (const path of call.paths)
21
+ entry.paths.add(path);
22
+ counts.set(call.kind, entry);
23
+ }
24
+ // Map iteration keeps first-seen order.
25
+ const phrases = [...counts].map(([kind, entry]) => {
26
+ const [verb, singular, plural] = nouns[kind];
27
+ const countsFiles = kind === "read" || kind === "edit";
28
+ const count = countsFiles && entry.paths.size > 0 ? entry.paths.size : entry.calls;
29
+ return `${verb} ${String(count)} ${count === 1 ? singular : plural}`;
30
+ });
31
+ const text = phrases.join(", ");
32
+ return text.charAt(0).toUpperCase() + text.slice(1);
33
+ }
34
+ function groupStatus(calls) {
35
+ if (calls.some((call) => call.status === "running"))
36
+ return "running";
37
+ if (calls.some((call) => call.status === "error"))
38
+ return "error";
39
+ if (calls.some((call) => call.status === "unknown"))
40
+ return "unknown";
41
+ return "completed";
42
+ }
43
+ function groupDuration(tools) {
44
+ let start;
45
+ let end;
46
+ for (const tool of tools) {
47
+ if (tool.startedAt !== undefined)
48
+ start = Math.min(start ?? tool.startedAt, tool.startedAt);
49
+ if (tool.result !== undefined)
50
+ end = Math.max(end ?? tool.result.timestamp, tool.result.timestamp);
51
+ }
52
+ return start === undefined || end === undefined
53
+ ? undefined
54
+ : Math.max(0, end - start);
55
+ }
56
+ /** Build one group from a non-empty run of consecutive calls. */
57
+ export function toolGroup(tools) {
58
+ const calls = tools.map(toolCallView);
59
+ const status = groupStatus(calls);
60
+ const active = [...calls].reverse().find((call) => call.status === "running");
61
+ return {
62
+ active,
63
+ calls,
64
+ durationMs: status === "running" ? undefined : groupDuration(tools),
65
+ failedCount: calls.filter((call) => call.status === "error").length,
66
+ id: `tools:${tools[0].id}`,
67
+ kind: "tool_group",
68
+ status,
69
+ summary: calls.length === 1 && calls[0] !== undefined
70
+ ? `${calls[0].verb} ${calls[0].label}`
71
+ : summarizeToolCalls(calls),
72
+ tools,
73
+ };
74
+ }
75
+ /**
76
+ * Fold a stretch of tool work into one group. User rows and rendered assistant
77
+ * content end the stretch; invisible assistant rows can span it.
78
+ */
79
+ export function groupToolCalls(items) {
80
+ const rows = [];
81
+ let run = [];
82
+ let nontextRows = [];
83
+ const flush = () => {
84
+ const [first, ...rest] = run;
85
+ if (first !== undefined)
86
+ rows.push(toolGroup([first, ...rest]));
87
+ rows.push(...nontextRows);
88
+ run = [];
89
+ nontextRows = [];
90
+ };
91
+ for (const item of items) {
92
+ if (item.kind === "tool") {
93
+ run.push(item);
94
+ continue;
95
+ }
96
+ if (item.kind === "assistant_message" &&
97
+ !assistantMessageVisible(item) &&
98
+ run.length > 0) {
99
+ nontextRows.push(item);
100
+ continue;
101
+ }
102
+ flush();
103
+ rows.push(item);
104
+ }
105
+ flush();
106
+ return rows;
107
+ }
108
+ /** Defaults only, with no reader choices. */
109
+ export const initialToolDisclosure = Object.freeze({
110
+ mode: "default",
111
+ overrides: new Map(),
112
+ });
113
+ /** Active and failed groups show every call; clean history retains a visible tail. */
114
+ export function toolGroupDefaultOpen(group) {
115
+ return group.status !== "completed";
116
+ }
117
+ /** Even a folded group shows recent calls, rather than hiding all tool work. */
118
+ export function visibleToolCalls(group, open) {
119
+ return open ? group.calls : group.calls.slice(-3);
120
+ }
121
+ /** A failed call opens by default so its error is visible. */
122
+ export function toolCallDefaultOpen(call) {
123
+ return call.status === "error";
124
+ }
125
+ /** Whether a group or call is currently open. */
126
+ export function disclosureOpen(state, id, defaultOpen) {
127
+ const override = state.overrides.get(id);
128
+ if (override !== undefined)
129
+ return override;
130
+ if (state.mode === "default")
131
+ return defaultOpen;
132
+ return state.mode === "expanded";
133
+ }
134
+ /** Flip one group or call, recording the reader's explicit choice. */
135
+ export function toggleDisclosure(state, id, defaultOpen) {
136
+ const overrides = new Map(state.overrides);
137
+ overrides.set(id, !disclosureOpen(state, id, defaultOpen));
138
+ return { ...state, overrides };
139
+ }
140
+ /**
141
+ * Expand everything, or collapse everything when the reader has expanded
142
+ * anything. Either way individual choices are cleared.
143
+ */
144
+ export function toggleAllDisclosure(state) {
145
+ const anyOpen = state.mode === "expanded" ||
146
+ [...state.overrides.values()].some((open) => open);
147
+ return {
148
+ mode: anyOpen ? "collapsed" : "expanded",
149
+ overrides: new Map(),
150
+ };
151
+ }