@jini-ai/chat 0.3.9 → 0.3.10

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 (33) hide show
  1. package/dist/core/assistant-content.d.ts +29 -0
  2. package/dist/core/assistant-content.js +41 -0
  3. package/dist/core/compact-events.d.ts +23 -0
  4. package/dist/core/compact-events.js +23 -0
  5. package/dist/core/events.d.ts +2 -0
  6. package/dist/core/index.d.ts +4 -0
  7. package/dist/core/index.js +4 -0
  8. package/dist/core/run-activity.d.ts +88 -0
  9. package/dist/core/run-activity.js +153 -0
  10. package/dist/core/tool-output.d.ts +13 -0
  11. package/dist/core/tool-output.js +24 -0
  12. package/dist/react/components/Markdown.js +21 -7
  13. package/dist/react/components/McpUiSurfaceCard.d.ts +9 -1
  14. package/dist/react/components/McpUiSurfaceCard.js +43 -2
  15. package/dist/react/components/MessageRow.js +28 -7
  16. package/dist/react/components/ToolCard.js +6 -6
  17. package/dist/react/ext-event-renderer-registry.d.ts +39 -1
  18. package/dist/react/ext-event-renderer-registry.js +23 -4
  19. package/dist/react/features/chat-pane/components/ChatPane.js +5 -4
  20. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +15 -1
  21. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +113 -14
  22. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  23. package/dist/react/features/chat-pane/styles.js +7 -2
  24. package/dist/react/hooks/useConversation.js +13 -11
  25. package/dist/react/hooks/useExtEventGroups.d.ts +13 -1
  26. package/dist/react/hooks/useExtEventGroups.js +38 -9
  27. package/dist/react/hooks/useRunActivity.d.ts +12 -0
  28. package/dist/react/hooks/useRunActivity.js +44 -0
  29. package/dist/react/index.d.ts +3 -3
  30. package/dist/react/index.js +2 -2
  31. package/dist/react/message-blocks.d.ts +6 -1
  32. package/dist/react/message-blocks.js +14 -13
  33. package/package.json +4 -4
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @module assistant-content
3
+ *
4
+ * The ONE rule for turning a turn's events into its saved text `content`.
5
+ *
6
+ * Why it exists: `content` used to be every `text` event glued together (`out += ev.text`). A model
7
+ * that writes a short note, calls a tool, then answers produced `"Need project id. Find
8
+ * list_projects.`test_table` now exists…"` — the working note fused onto the answer mid-sentence,
9
+ * in the saved row, the copy button, and any transcript rebuilt from it. The live pane already
10
+ * shows those as separate steps (text, tool card, text); the saved string now keeps them apart too,
11
+ * with a blank line (a Markdown paragraph break) wherever a tool call or card sat between them.
12
+ *
13
+ * Every producer of `content` must use this: the browser (`useConversation`) and any host that
14
+ * saves a finished turn server-side. `interleaveMessageBlocks` accepts both this shape and the old
15
+ * glued one, so rows saved before this change still render in order.
16
+ */
17
+ import type { AgentEvent } from './events.js';
18
+ /** What separates two text runs that a tool call or card sat between. */
19
+ export declare const ASSISTANT_STEP_SEPARATOR = "\n\n";
20
+ /**
21
+ * A turn's text, with a paragraph break between runs separated by a tool call or card.
22
+ *
23
+ * @param events - The turn's events, in arrival order.
24
+ * @complexity O(total text length).
25
+ */
26
+ export declare function assistantContentFromEvents(events: readonly AgentEvent[] | undefined): string;
27
+ /** The pre-2026-09-27 rule (plain concatenation), kept only so older saved rows still match. */
28
+ export declare function legacyAssistantContentFromEvents(events: readonly AgentEvent[] | undefined): string;
29
+ //# sourceMappingURL=assistant-content.d.ts.map
@@ -0,0 +1,41 @@
1
+ /** What separates two text runs that a tool call or card sat between. */
2
+ export const ASSISTANT_STEP_SEPARATOR = '\n\n';
3
+ function separatorBefore(out, text) {
4
+ if (out.length === 0 || out.endsWith(ASSISTANT_STEP_SEPARATOR) || text.startsWith('\n'))
5
+ return '';
6
+ return out.endsWith('\n') ? '\n' : ASSISTANT_STEP_SEPARATOR;
7
+ }
8
+ /**
9
+ * A turn's text, with a paragraph break between runs separated by a tool call or card.
10
+ *
11
+ * @param events - The turn's events, in arrival order.
12
+ * @complexity O(total text length).
13
+ */
14
+ export function assistantContentFromEvents(events) {
15
+ let out = '';
16
+ let stepBoundary = false;
17
+ for (const ev of events ?? []) {
18
+ if (ev.kind === 'text') {
19
+ if (ev.text.length === 0)
20
+ continue;
21
+ if (stepBoundary)
22
+ out += separatorBefore(out, ev.text);
23
+ out += ev.text;
24
+ stepBoundary = false;
25
+ }
26
+ else if (ev.kind === 'tool_use' || ev.kind === 'ext') {
27
+ stepBoundary = true;
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+ /** The pre-2026-09-27 rule (plain concatenation), kept only so older saved rows still match. */
33
+ export function legacyAssistantContentFromEvents(events) {
34
+ let out = '';
35
+ for (const ev of events ?? []) {
36
+ if (ev.kind === 'text')
37
+ out += ev.text;
38
+ }
39
+ return out;
40
+ }
41
+ //# sourceMappingURL=assistant-content.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @module compact-events
3
+ *
4
+ * Shrinks a finished turn's events before they are saved.
5
+ *
6
+ * With partial-message streaming on, the CLI reports text a few tokens at a time, so one answer
7
+ * arrives as ~100 `text` events (`"Here"`, `" are your"`, …). Live, that is what makes words appear
8
+ * as they are written. Saved, it only repeats `{"kind":"text","text":…}` per token. Joining each
9
+ * run of back-to-back `text` events (and back-to-back `thinking` events) into one keeps the saved
10
+ * row's meaning intact: nothing is reordered, and any other event between two deltas — a tool call,
11
+ * a card, a status or raw line — still separates them, so `assistantContentFromEvents` and the
12
+ * pane's step layout read the same before and after.
13
+ */
14
+ import type { AgentEvent } from './events.js';
15
+ /**
16
+ * The events with every run of adjacent `text` (or adjacent `thinking`) events joined into one.
17
+ * Returns a new array; the input is not changed.
18
+ *
19
+ * @param events - A turn's events, in arrival order.
20
+ * @complexity O(number of events + total text length).
21
+ */
22
+ export declare function mergeAdjacentTextEvents(events: readonly AgentEvent[] | undefined): AgentEvent[];
23
+ //# sourceMappingURL=compact-events.d.ts.map
@@ -0,0 +1,23 @@
1
+ function isDelta(event) {
2
+ return event.kind === 'text' || event.kind === 'thinking';
3
+ }
4
+ /**
5
+ * The events with every run of adjacent `text` (or adjacent `thinking`) events joined into one.
6
+ * Returns a new array; the input is not changed.
7
+ *
8
+ * @param events - A turn's events, in arrival order.
9
+ * @complexity O(number of events + total text length).
10
+ */
11
+ export function mergeAdjacentTextEvents(events) {
12
+ const out = [];
13
+ for (const event of events ?? []) {
14
+ const last = out[out.length - 1];
15
+ if (last && isDelta(event) && isDelta(last) && last.kind === event.kind) {
16
+ out[out.length - 1] = { kind: last.kind, text: last.text + event.text };
17
+ continue;
18
+ }
19
+ out.push(event);
20
+ }
21
+ return out;
22
+ }
23
+ //# sourceMappingURL=compact-events.js.map
@@ -39,6 +39,8 @@ export type AgentEvent = {
39
39
  label: string;
40
40
  detail?: string;
41
41
  code?: string;
42
+ /** Structured fields for a coded status (e.g. `api_retry`'s attempt numbers). See `run-activity.ts`. */
43
+ data?: Readonly<Record<string, string | number>>;
42
44
  } | {
43
45
  kind: 'text';
44
46
  text: string;
@@ -16,12 +16,16 @@ export * from './events.js';
16
16
  export * from './messages.js';
17
17
  export * from './partial-json.js';
18
18
  export * from './tool-events.js';
19
+ export * from './tool-output.js';
19
20
  export * from './transport.js';
20
21
  export * from './tools.js';
21
22
  export * from './todos.js';
22
23
  export * from './question-form.js';
23
24
  export * from './util/index.js';
24
25
  export * from './transcript.js';
26
+ export * from './run-activity.js';
27
+ export * from './assistant-content.js';
28
+ export * from './compact-events.js';
25
29
  /**
26
30
  * The chat pane's own capability manifest (`CHAT_CAPABILITIES`). The framework-free agent-control
27
31
  * vocabulary this used to sit alongside — `CapabilityDef`, `PAGE_CAPABILITIES`, the
@@ -16,12 +16,16 @@ export * from './events.js';
16
16
  export * from './messages.js';
17
17
  export * from './partial-json.js';
18
18
  export * from './tool-events.js';
19
+ export * from './tool-output.js';
19
20
  export * from './transport.js';
20
21
  export * from './tools.js';
21
22
  export * from './todos.js';
22
23
  export * from './question-form.js';
23
24
  export * from './util/index.js';
24
25
  export * from './transcript.js';
26
+ export * from './run-activity.js';
27
+ export * from './assistant-content.js';
28
+ export * from './compact-events.js';
25
29
  /**
26
30
  * The chat pane's own capability manifest (`CHAT_CAPABILITIES`). The framework-free agent-control
27
31
  * vocabulary this used to sit alongside — `CapabilityDef`, `PAGE_CAPABILITIES`, the
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @module run-activity
3
+ *
4
+ * What a running turn is doing right now, as one short line ("Running Page Fill… 1 m 20 s").
5
+ *
6
+ * Why: once a turn's first tool row appeared, nothing on screen showed the run was still alive —
7
+ * the old "Thinking…" placeholder only showed while the message was completely empty. Model think
8
+ * time, long tool calls, held cards and API overload retries all looked identical: dead.
9
+ *
10
+ * Shape: {@link deriveRunActivity} reads the events the pane already has and returns the latest
11
+ * signal; {@link RUN_ACTIVITY_LABELS} is the ONE table that turns a signal into words. The clock
12
+ * (seconds since the signal began) is the caller's: the React hook ticks it once a second. Nothing
13
+ * here special-cases a particular tool.
14
+ *
15
+ * Status events this reads (a host translates its runtime's heartbeats into them):
16
+ * - `code: 'api_retry'`, `data: { attempt, maxAttempts, service? }` — the model API is overloaded.
17
+ * - `code: 'tool_progress'`, `data: { elapsedSeconds }` — a tool call is still running.
18
+ * - `code: 'thinking'` — the model is reasoning (keeps the idle timer honest).
19
+ */
20
+ import type { AgentEvent } from './events.js';
21
+ /** The latest thing a running turn is doing. */
22
+ export type RunActivity = {
23
+ readonly kind: 'thinking';
24
+ } | {
25
+ readonly kind: 'writing';
26
+ } | {
27
+ readonly kind: 'finding-tool';
28
+ } | {
29
+ readonly kind: 'running-tool';
30
+ readonly tool: string;
31
+ readonly reportedSeconds?: number;
32
+ } | {
33
+ readonly kind: 'awaiting-answer';
34
+ } | {
35
+ readonly kind: 'retrying';
36
+ readonly attempt?: number;
37
+ readonly maxAttempts?: number;
38
+ readonly service?: string;
39
+ };
40
+ /** {@link deriveRunActivity}'s result: the signal, plus a key that changes whenever the signal restarts. */
41
+ export interface RunActivityState {
42
+ readonly activity: RunActivity;
43
+ /** Changes when a new signal begins, so the caller restarts its clock (e.g. `tool:<id>`). */
44
+ readonly key: string;
45
+ /** Count of events the person can see (text, tool rows, cards). A change means "something new". */
46
+ readonly visibleCount: number;
47
+ }
48
+ /** After this long with nothing new on screen, a thinking/writing line says "Still working…". */
49
+ export declare const RUN_ACTIVITY_IDLE_MS = 20000;
50
+ /** `"page.fill"` → `"Page Fill"`, `"mcp__supabase__list_projects"` → `"List Projects"`. Display only. */
51
+ export declare function humanizeToolName(name: string, input?: unknown): string;
52
+ /**
53
+ * The latest signal in a running turn's events.
54
+ *
55
+ * Precedence, newest wins: an API retry with nothing visible after it; a card that arrived while a
56
+ * tool call is still open (the tool is waiting on the person); the newest open tool call; else
57
+ * writing (last visible event is text) or thinking.
58
+ *
59
+ * @complexity O(n) in events, O(open tool calls) extra space.
60
+ */
61
+ export declare function deriveRunActivity(events: readonly AgentEvent[] | undefined): RunActivityState;
62
+ /** `4` → `"4 s"`, `80` → `"1 m 20 s"`, `120` → `"2 m 0 s"`. */
63
+ export declare function formatActivityClock(totalSeconds: number): string;
64
+ /** A translator: the chat package's `t(key, vars)`. */
65
+ export type RunActivityTranslate = (key: string, vars?: Record<string, string | number>) => string;
66
+ /** The clock values a label may use. */
67
+ export interface RunActivityClock {
68
+ /** Seconds since this signal began (already corrected by any `tool_progress` report). */
69
+ readonly seconds: number;
70
+ /** Milliseconds since anything new appeared on screen. */
71
+ readonly idleMs: number;
72
+ }
73
+ /**
74
+ * THE event → words table. One entry per {@link RunActivity} kind; nothing else in the package
75
+ * decides what the activity line says. Keys are English source strings for the host's `t()`.
76
+ */
77
+ export declare const RUN_ACTIVITY_LABELS: {
78
+ readonly [K in RunActivity['kind']]: (activity: Extract<RunActivity, {
79
+ kind: K;
80
+ }>, clock: RunActivityClock, t: RunActivityTranslate) => string;
81
+ };
82
+ /**
83
+ * The activity line's text for a signal and clock.
84
+ *
85
+ * @complexity O(1).
86
+ */
87
+ export declare function describeRunActivity(activity: RunActivity, clock: RunActivityClock, t: RunActivityTranslate): string;
88
+ //# sourceMappingURL=run-activity.d.ts.map
@@ -0,0 +1,153 @@
1
+ /** Tool names whose only job is finding the right tool (MCP tool search / schema lookup). */
2
+ const DISCOVERY_TOOL_NAMES = new Set([
3
+ 'ToolSearch',
4
+ 'search_tools',
5
+ 'describe_tool',
6
+ 'mcp__jini__search_tools',
7
+ 'mcp__jini__describe_tool',
8
+ ]);
9
+ /** Transport wrappers whose real tool id is in `input.toolId`. */
10
+ const WRAPPER_TOOL_NAMES = new Set(['execute_delegated_tool', 'mcp__jini__execute_delegated_tool']);
11
+ /** Ext events that are a card for the person (a form, a confirm, a sign-in). */
12
+ const CARD_EXT_NAMES = new Set(['mcp-ui', 'a2ui']);
13
+ /** After this long with nothing new on screen, a thinking/writing line says "Still working…". */
14
+ export const RUN_ACTIVITY_IDLE_MS = 20_000;
15
+ function isRecord(value) {
16
+ return typeof value === 'object' && value !== null;
17
+ }
18
+ function numberField(data, key) {
19
+ const value = isRecord(data) ? data[key] : undefined;
20
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
21
+ }
22
+ function stringField(data, key) {
23
+ const value = isRecord(data) ? data[key] : undefined;
24
+ return typeof value === 'string' && value ? value : undefined;
25
+ }
26
+ /** `"page.fill"` → `"Page Fill"`, `"mcp__supabase__list_projects"` → `"List Projects"`. Display only. */
27
+ export function humanizeToolName(name, input) {
28
+ const real = WRAPPER_TOOL_NAMES.has(name) ? (stringField(input, 'toolId') ?? name) : name;
29
+ const bare = real.startsWith('mcp__') ? real.split('__').slice(2).join('__') || real : real;
30
+ return bare
31
+ .split(/[._\s]+/)
32
+ .filter(Boolean)
33
+ .map((word) => word[0].toUpperCase() + word.slice(1))
34
+ .join(' ');
35
+ }
36
+ function isVisible(event) {
37
+ return event.kind === 'text' ? event.text.length > 0 : event.kind === 'tool_use' || event.kind === 'tool_result' || event.kind === 'ext';
38
+ }
39
+ function scanEvent(scan, event, index) {
40
+ if (isVisible(event)) {
41
+ scan.lastVisible = { index, kind: event.kind };
42
+ scan.visibleCount += 1;
43
+ }
44
+ if (event.kind === 'tool_use') {
45
+ scan.open = scan.open.filter((tool) => tool.id !== event.id);
46
+ scan.open.push({ id: event.id, name: event.name, input: event.input, index });
47
+ }
48
+ else if (event.kind === 'tool_result') {
49
+ scan.open = scan.open.filter((tool) => tool.id !== event.toolUseId);
50
+ }
51
+ else if (event.kind === 'ext' && CARD_EXT_NAMES.has(event.name)) {
52
+ scan.lastCardIndex = index;
53
+ }
54
+ else if (event.kind === 'status' && event.code === 'api_retry') {
55
+ scan.retry = { index, data: event.data };
56
+ }
57
+ else if (event.kind === 'status' && event.code === 'tool_progress') {
58
+ const seconds = numberField(event.data, 'elapsedSeconds');
59
+ if (seconds !== undefined)
60
+ scan.progress = { index, seconds };
61
+ }
62
+ }
63
+ /**
64
+ * The latest signal in a running turn's events.
65
+ *
66
+ * Precedence, newest wins: an API retry with nothing visible after it; a card that arrived while a
67
+ * tool call is still open (the tool is waiting on the person); the newest open tool call; else
68
+ * writing (last visible event is text) or thinking.
69
+ *
70
+ * @complexity O(n) in events, O(open tool calls) extra space.
71
+ */
72
+ export function deriveRunActivity(events) {
73
+ const scan = { open: [], lastVisible: null, lastCardIndex: -1, retry: null, progress: null, visibleCount: 0 };
74
+ (events ?? []).forEach((event, index) => scanEvent(scan, event, index));
75
+ const visibleCount = scan.visibleCount;
76
+ const lastVisibleIndex = scan.lastVisible?.index ?? -1;
77
+ if (scan.retry && scan.retry.index > lastVisibleIndex) {
78
+ const data = scan.retry.data;
79
+ const attempt = numberField(data, 'attempt');
80
+ const maxAttempts = numberField(data, 'maxAttempts');
81
+ const service = stringField(data, 'service');
82
+ return {
83
+ activity: {
84
+ kind: 'retrying',
85
+ ...(attempt !== undefined ? { attempt } : {}),
86
+ ...(maxAttempts !== undefined ? { maxAttempts } : {}),
87
+ ...(service !== undefined ? { service } : {}),
88
+ },
89
+ key: `retry:${lastVisibleIndex}`,
90
+ visibleCount,
91
+ };
92
+ }
93
+ const newestOpen = scan.open[scan.open.length - 1];
94
+ if (newestOpen) {
95
+ if (scan.lastCardIndex > newestOpen.index) {
96
+ return { activity: { kind: 'awaiting-answer' }, key: `card:${scan.lastCardIndex}`, visibleCount };
97
+ }
98
+ if (DISCOVERY_TOOL_NAMES.has(newestOpen.name)) {
99
+ return { activity: { kind: 'finding-tool' }, key: `tool:${newestOpen.id}`, visibleCount };
100
+ }
101
+ const reported = scan.progress && scan.progress.index > newestOpen.index ? scan.progress.seconds : undefined;
102
+ return {
103
+ activity: {
104
+ kind: 'running-tool',
105
+ tool: humanizeToolName(newestOpen.name, newestOpen.input),
106
+ ...(reported !== undefined ? { reportedSeconds: reported } : {}),
107
+ },
108
+ key: `tool:${newestOpen.id}`,
109
+ visibleCount,
110
+ };
111
+ }
112
+ if (scan.lastVisible?.kind === 'text') {
113
+ return { activity: { kind: 'writing' }, key: `writing:${lastVisibleIndex}`, visibleCount };
114
+ }
115
+ return { activity: { kind: 'thinking' }, key: `thinking:${lastVisibleIndex}`, visibleCount };
116
+ }
117
+ /** `4` → `"4 s"`, `80` → `"1 m 20 s"`, `120` → `"2 m 0 s"`. */
118
+ export function formatActivityClock(totalSeconds) {
119
+ const seconds = Math.max(0, Math.floor(totalSeconds));
120
+ const minutes = Math.floor(seconds / 60);
121
+ return minutes > 0 ? `${minutes} m ${seconds % 60} s` : `${seconds} s`;
122
+ }
123
+ /**
124
+ * THE event → words table. One entry per {@link RunActivity} kind; nothing else in the package
125
+ * decides what the activity line says. Keys are English source strings for the host's `t()`.
126
+ */
127
+ export const RUN_ACTIVITY_LABELS = {
128
+ thinking: (_a, clock, t) => clock.idleMs >= RUN_ACTIVITY_IDLE_MS
129
+ ? t('Still working… {time}', { time: formatActivityClock(clock.seconds) })
130
+ : t('Thinking… {time}', { time: formatActivityClock(clock.seconds) }),
131
+ writing: (_a, clock, t) => clock.idleMs >= RUN_ACTIVITY_IDLE_MS
132
+ ? t('Still working… {time}', { time: formatActivityClock(clock.seconds) })
133
+ : t('Writing…'),
134
+ 'finding-tool': (_a, clock, t) => t('Finding the right tool… {time}', { time: formatActivityClock(clock.seconds) }),
135
+ 'running-tool': (a, clock, t) => t('Running {tool}… {time}', { tool: a.tool, time: formatActivityClock(clock.seconds) }),
136
+ 'awaiting-answer': (_a, clock, t) => t('Waiting for your answer above · {time}', { time: formatActivityClock(clock.seconds) }),
137
+ retrying: (a, _clock, t) => {
138
+ const who = a.service ? t("{service}'s servers are busy", { service: a.service }) : t('The AI service is busy');
139
+ return a.attempt !== undefined && a.maxAttempts !== undefined
140
+ ? t('{who} — retrying ({attempt} of {max})…', { who, attempt: a.attempt, max: a.maxAttempts })
141
+ : t('{who} — retrying…', { who });
142
+ },
143
+ };
144
+ /**
145
+ * The activity line's text for a signal and clock.
146
+ *
147
+ * @complexity O(1).
148
+ */
149
+ export function describeRunActivity(activity, clock, t) {
150
+ const label = RUN_ACTIVITY_LABELS[activity.kind];
151
+ return label(activity, clock, t);
152
+ }
153
+ //# sourceMappingURL=run-activity.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Formats a tool result's text for a person to read. Tool results travel to the model as compact
3
+ * JSON (`@jini-ai/mcp`'s `okResult` — indentation made nested results ~2.5× larger), so a view that
4
+ * shows one re-indents it here, at render time. Only a JSON object or array is re-indented; any other
5
+ * text (shell output, an error message, a bare JSON scalar, something that merely starts with `{`)
6
+ * comes back unchanged.
7
+ *
8
+ * @param text - A `tool_result` event's flattened `content`.
9
+ * @returns The value re-serialized with two-space indentation, or `text` as given.
10
+ * @complexity O(n) in `text.length`.
11
+ */
12
+ export declare function formatToolOutputForDisplay(text: string): string;
13
+ //# sourceMappingURL=tool-output.d.ts.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Formats a tool result's text for a person to read. Tool results travel to the model as compact
3
+ * JSON (`@jini-ai/mcp`'s `okResult` — indentation made nested results ~2.5× larger), so a view that
4
+ * shows one re-indents it here, at render time. Only a JSON object or array is re-indented; any other
5
+ * text (shell output, an error message, a bare JSON scalar, something that merely starts with `{`)
6
+ * comes back unchanged.
7
+ *
8
+ * @param text - A `tool_result` event's flattened `content`.
9
+ * @returns The value re-serialized with two-space indentation, or `text` as given.
10
+ * @complexity O(n) in `text.length`.
11
+ */
12
+ export function formatToolOutputForDisplay(text) {
13
+ const trimmed = text.trim();
14
+ const first = trimmed[0];
15
+ if (first !== '{' && first !== '[')
16
+ return text;
17
+ try {
18
+ return JSON.stringify(JSON.parse(trimmed), null, 2);
19
+ }
20
+ catch {
21
+ return text;
22
+ }
23
+ }
24
+ //# sourceMappingURL=tool-output.js.map
@@ -131,6 +131,7 @@ function collectTableRows(lines, start) {
131
131
  }
132
132
  return { rows, next: i };
133
133
  }
134
+ const ORDERED_ITEM = /^\s*\d+[.)]\s+/;
134
135
  function parseBlocks(input) {
135
136
  const lines = input.split(/\r?\n/);
136
137
  const blocks = [];
@@ -204,13 +205,14 @@ function parseBlocks(input) {
204
205
  i = next;
205
206
  continue;
206
207
  }
207
- if (/^\s*\d+[.)]\s+/.test(line)) {
208
+ if (ORDERED_ITEM.test(line)) {
209
+ const start = Number(/\d+/.exec(line)[0]);
208
210
  const items = [];
209
- while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) {
210
- items.push(lines[i].replace(/^\s*\d+[.)]\s+/, ''));
211
- i += 1;
211
+ while (i < lines.length && ORDERED_ITEM.test(lines[i])) {
212
+ items.push(lines[i].replace(ORDERED_ITEM, ''));
213
+ i = nextLooseListLine(lines, i + 1);
212
214
  }
213
- blocks.push({ kind: 'ol', items });
215
+ blocks.push({ kind: 'ol', start, items });
214
216
  continue;
215
217
  }
216
218
  const paragraph = [];
@@ -222,6 +224,18 @@ function parseBlocks(input) {
222
224
  }
223
225
  return blocks;
224
226
  }
227
+ /**
228
+ * A "loose" numbered list puts a blank line between items ("1. a\n\n2. b").
229
+ * Skip those blank lines when the next non-blank line is another numbered
230
+ * item, so the items stay in ONE `<ol>` (numbered 1..n) instead of n lists
231
+ * that each show "1.". Otherwise return `i` unchanged and the list ends.
232
+ */
233
+ function nextLooseListLine(lines, i) {
234
+ let j = i;
235
+ while (j < lines.length && lines[j].trim() === '')
236
+ j += 1;
237
+ return j > i && j < lines.length && ORDERED_ITEM.test(lines[j]) ? j : i;
238
+ }
225
239
  function isBlockStart(lines, i) {
226
240
  const line = lines[i];
227
241
  return (/^```/.test(line)
@@ -229,7 +243,7 @@ function isBlockStart(lines, i) {
229
243
  || /^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)
230
244
  || /^>\s?/.test(line)
231
245
  || /^\s*[-*]\s+/.test(line)
232
- || /^\s*\d+[.)]\s+/.test(line)
246
+ || ORDERED_ITEM.test(line)
233
247
  || isTableStartAt(lines, i));
234
248
  }
235
249
  function renderBlock(block) {
@@ -243,7 +257,7 @@ function renderBlock(block) {
243
257
  case 'ul':
244
258
  return (_jsx("ul", { children: block.items.map((item, i) => (_jsx("li", { children: renderInline(item) }, i))) }));
245
259
  case 'ol':
246
- return (_jsx("ol", { children: block.items.map((item, i) => (_jsx("li", { children: renderInline(item) }, i))) }));
260
+ return (_jsx("ol", { start: block.start === 1 ? undefined : block.start, children: block.items.map((item, i) => (_jsx("li", { children: renderInline(item) }, i))) }));
247
261
  case 'bq':
248
262
  return _jsx("blockquote", { children: renderInline(block.text) });
249
263
  case 'code':
@@ -24,7 +24,15 @@ export interface McpUiSurfaceCardProps extends ExtEventRenderProps {
24
24
  maxHeight?: number;
25
25
  }
26
26
  /** Registered against `ext-event-renderer-registry.ts`'s `'mcp-ui'` name — see module doc. */
27
- export declare function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight }: McpUiSurfaceCardProps): import("react").JSX.Element;
27
+ export declare function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming }: McpUiSurfaceCardProps): import("react").JSX.Element;
28
+ /**
29
+ * The ext-event slot key for MCP-UI: one slot per `ui://` URI. Each resource is its own card, so a
30
+ * run's second card renders where it arrived instead of inside the first card's slot, while a
31
+ * re-emitted resource with the same URI still updates its card in place. Pass it as
32
+ * `registerExtEventRenderer(MCP_UI_EXT_EVENT_NAME, renderer, { slotKey: mcpUiSurfaceSlotKey })`
33
+ * when registering a custom MCP-UI renderer; {@link registerMcpUiSurfaceRenderer} already does.
34
+ */
35
+ export declare function mcpUiSurfaceSlotKey(data: unknown): string | undefined;
28
36
  /**
29
37
  * Registers {@link McpUiSurfaceCard} against the ext-event registry.
30
38
  *
@@ -112,10 +112,41 @@ function pendingMirrorLabel(t, plan) {
112
112
  function PendingSurfaceMirror({ uri, plan, t }) {
113
113
  return (_jsx("div", { "aria-hidden": "true", style: { display: 'none' }, "data-agent-element": pendingMirrorHandle(uri), "data-agent-role": "status", "data-agent-label": pendingMirrorLabel(t, plan) }));
114
114
  }
115
+ /** Words in a finished call's result that mean nobody answered in time. */
116
+ const UNANSWERED_RESULT = /\b(expired|abandoned|did not respond|run ended)\b/i;
117
+ /**
118
+ * Why a card can no longer be answered, or `undefined` while it still can.
119
+ *
120
+ * A held-open question (the tool shows the card and waits) is over once its tool call returns, and
121
+ * dead once its run ends without that return. Its first document stays on screen either way, so
122
+ * without this an expired, answered or orphaned question still looked answerable (Tovu stuck-chat
123
+ * investigation, 2026-09-27). Only a card still showing that FIRST document closes: a tool that sent
124
+ * a follow-up document for the same `ui://` URI (an outcome after the answer) keeps showing it.
125
+ *
126
+ * The labels are read from the call's result text, so they are a best guess for a producer this
127
+ * package has not seen; the card is closed either way, which is the property that matters.
128
+ */
129
+ function closedLabel(t, call, runStreaming, documentsForUri) {
130
+ if (call === undefined || documentsForUri > 1)
131
+ return undefined;
132
+ if (call.result === undefined)
133
+ return runStreaming ? undefined : t('This question expired');
134
+ return UNANSWERED_RESULT.test(call.result.content) ? t('This question expired') : t('Answered');
135
+ }
136
+ /** A closed card: the question's title, when the call gave one, and why it is closed. */
137
+ function ClosedSurface({ call, label }) {
138
+ const title = call?.input?.title;
139
+ return (_jsx("div", { className: "mcpui-surface-card mcpui-surface-card-closed", role: "status", children: typeof title === 'string' && title.trim() !== '' ? `${title}: ${label}` : label }));
140
+ }
115
141
  /** Registered against `ext-event-renderer-registry.ts`'s `'mcp-ui'` name — see module doc. */
116
- export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight }) {
142
+ export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming }) {
117
143
  const t = useT();
118
144
  const resources = useMemo(() => latestResourcesByUri(events), [events]);
145
+ // One card per slot is the registered shape (`mcpUiSurfaceSlotKey`), so `call` belongs to exactly
146
+ // that card. A group holding several URIs has no single call to judge them by, and stays live.
147
+ const closed = resources.length === 1 ? closedLabel(t, call, runStreaming, events.length) : undefined;
148
+ if (closed !== undefined)
149
+ return _jsx(ClosedSurface, { call: call, label: closed });
119
150
  if (resources.length === 0) {
120
151
  // Visible rather than silent: an `ext` event named `mcp-ui` that carries no parseable resource
121
152
  // is a producer bug, and a transcript that renders nothing for it hides the bug behind what
@@ -137,6 +168,16 @@ export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLi
137
168
  sessionKey: `${resource.resource.uri}:${resource.resource.text}`, ...(onToolCall === undefined ? {} : { onToolCall }), ...(onOpenLink === undefined ? {} : { onOpenLink }), ...(height === undefined || Number.isNaN(height) ? {} : { initialHeight: height }), ...(maxHeight === undefined ? {} : { maxHeight }) }), plan !== undefined && _jsx(PendingSurfaceMirror, { uri: resource.resource.uri, plan: plan, t: t })] }, resource.resource.uri));
138
169
  }) }));
139
170
  }
171
+ /**
172
+ * The ext-event slot key for MCP-UI: one slot per `ui://` URI. Each resource is its own card, so a
173
+ * run's second card renders where it arrived instead of inside the first card's slot, while a
174
+ * re-emitted resource with the same URI still updates its card in place. Pass it as
175
+ * `registerExtEventRenderer(MCP_UI_EXT_EVENT_NAME, renderer, { slotKey: mcpUiSurfaceSlotKey })`
176
+ * when registering a custom MCP-UI renderer; {@link registerMcpUiSurfaceRenderer} already does.
177
+ */
178
+ export function mcpUiSurfaceSlotKey(data) {
179
+ return parseUIResource(data)?.resource.uri;
180
+ }
140
181
  /**
141
182
  * Registers {@link McpUiSurfaceCard} against the ext-event registry.
142
183
  *
@@ -154,6 +195,6 @@ export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLi
154
195
  * @returns An unregister handle, so a test or a hot reload can dispose cleanly.
155
196
  */
156
197
  export function registerMcpUiSurfaceRenderer(options) {
157
- return registerExtEventRenderer(options.name ?? MCP_UI_EXT_EVENT_NAME, (props) => (_jsx(McpUiSurfaceCard, { ...props, sandboxProxyUrl: options.sandboxProxyUrl, ...(options.onToolCall === undefined ? {} : { onToolCall: options.onToolCall }), ...(options.onOpenLink === undefined ? {} : { onOpenLink: options.onOpenLink }), ...(options.maxHeight === undefined ? {} : { maxHeight: options.maxHeight }) })));
198
+ return registerExtEventRenderer(options.name ?? MCP_UI_EXT_EVENT_NAME, (props) => (_jsx(McpUiSurfaceCard, { ...props, sandboxProxyUrl: options.sandboxProxyUrl, ...(options.onToolCall === undefined ? {} : { onToolCall: options.onToolCall }), ...(options.onOpenLink === undefined ? {} : { onOpenLink: options.onOpenLink }), ...(options.maxHeight === undefined ? {} : { maxHeight: options.maxHeight }) })), { slotKey: mcpUiSurfaceSlotKey });
158
199
  }
159
200
  //# sourceMappingURL=McpUiSurfaceCard.js.map