@aparte/core 0.22.0 → 0.24.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 (66) hide show
  1. package/dist/client/aparte-client.d.ts +10 -22
  2. package/dist/client/aparte-client.d.ts.map +1 -1
  3. package/dist/client/stream-adapter.d.ts +16 -0
  4. package/dist/client/stream-adapter.d.ts.map +1 -1
  5. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  6. package/dist/components/conversation-list/aparte-conversation-list.d.ts +38 -0
  7. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  8. package/dist/components/conversation-list/index.d.ts +1 -1
  9. package/dist/components/conversation-list/index.d.ts.map +1 -1
  10. package/dist/components/index.d.ts +1 -1
  11. package/dist/components/index.d.ts.map +1 -1
  12. package/dist/components/viewport/aparte-chat-viewport.d.ts +10 -1
  13. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  14. package/dist/config/aparte-config.d.ts +76 -19
  15. package/dist/config/aparte-config.d.ts.map +1 -1
  16. package/dist/config/error-renderer.d.ts +3 -1
  17. package/dist/config/error-renderer.d.ts.map +1 -1
  18. package/dist/config/locale.d.ts +49 -4
  19. package/dist/config/locale.d.ts.map +1 -1
  20. package/dist/config/tool-table.d.ts +30 -0
  21. package/dist/config/tool-table.d.ts.map +1 -0
  22. package/dist/conversations/conversation-controller.d.ts +19 -0
  23. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  24. package/dist/conversations/conversation-status.d.ts +27 -0
  25. package/dist/conversations/conversation-status.d.ts.map +1 -0
  26. package/dist/custom-elements.json +12058 -11106
  27. package/dist/{dialog-triggers-3Rk7PuZa.js → dialog-triggers-BqXRdHWZ.js} +746 -205
  28. package/dist/dialog-triggers-BqXRdHWZ.js.map +1 -0
  29. package/dist/index.css +1 -1
  30. package/dist/index.d.ts +4 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +91 -18
  33. package/dist/index.js.map +1 -1
  34. package/dist/index.node.d.ts +3 -2
  35. package/dist/index.node.d.ts.map +1 -1
  36. package/dist/index.node.js +4 -3
  37. package/dist/index.node.js.map +1 -1
  38. package/dist/interop/element-props.d.ts +1 -1
  39. package/dist/interop/element-props.d.ts.map +1 -1
  40. package/dist/renderers/copy-button.d.ts +12 -0
  41. package/dist/renderers/copy-button.d.ts.map +1 -0
  42. package/dist/renderers/segment-renderers.d.ts +6 -3
  43. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  44. package/dist/renderers/segments/code.d.ts.map +1 -1
  45. package/dist/renderers/segments/error.d.ts.map +1 -1
  46. package/dist/renderers/segments/thinking.d.ts.map +1 -1
  47. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  48. package/dist/transport/agent-handler.d.ts.map +1 -1
  49. package/dist/types/errors.d.ts +2 -0
  50. package/dist/types/errors.d.ts.map +1 -1
  51. package/dist/types/event-map.d.ts +4 -2
  52. package/dist/types/event-map.d.ts.map +1 -1
  53. package/dist/types/events.d.ts +15 -0
  54. package/dist/types/events.d.ts.map +1 -1
  55. package/dist/types/index.d.ts +2 -2
  56. package/dist/types/index.d.ts.map +1 -1
  57. package/dist/types/segments.d.ts +42 -2
  58. package/dist/types/segments.d.ts.map +1 -1
  59. package/dist/types/tools.d.ts +77 -0
  60. package/dist/types/tools.d.ts.map +1 -1
  61. package/dist/utils/format-duration.d.ts +9 -0
  62. package/dist/utils/format-duration.d.ts.map +1 -0
  63. package/dist/utils/tool-line.d.ts +23 -0
  64. package/dist/utils/tool-line.d.ts.map +1 -0
  65. package/package.json +2 -2
  66. package/dist/dialog-triggers-3Rk7PuZa.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { unansweredTail, runStreamAgent } from "@aparte/engine";
1
+ import { toolRefusal as toolRefusal$1, unansweredTail, runStreamAgent } from "@aparte/engine";
2
2
  import { A as APARTE_ICON_GLYPHS } from "./glyphs-C8z2oJUg.js";
3
3
  function contentToText(content) {
4
4
  if (typeof content === "string") return content;
@@ -18,6 +18,7 @@ var AparteErrorCode = /* @__PURE__ */ ((AparteErrorCode2) => {
18
18
  AparteErrorCode2["PROVIDER_ERROR"] = "PROVIDER_ERROR";
19
19
  AparteErrorCode2["PROVIDER_UNAVAILABLE"] = "PROVIDER_UNAVAILABLE";
20
20
  AparteErrorCode2["PROVIDER_POLICY"] = "PROVIDER_POLICY";
21
+ AparteErrorCode2["MAX_TURNS_EXCEEDED"] = "MAX_TURNS_EXCEEDED";
21
22
  AparteErrorCode2["UNKNOWN_ERROR"] = "UNKNOWN_ERROR";
22
23
  return AparteErrorCode2;
23
24
  })(AparteErrorCode || {});
@@ -98,7 +99,7 @@ const APARTE_DEFAULT_LOCALE = {
98
99
  elicitationNo: "No",
99
100
  elicitationAnswerLabel: "Your answer",
100
101
  approvalInstructionPlaceholder: "Or tell it what to do instead…",
101
- approvalAsk: "Run {tool}?",
102
+ approvalAsk: "Allow {tool}?",
102
103
  approvalWaiting: "Waiting",
103
104
  scrollToBottom: "Scroll to bottom",
104
105
  suggestionsLabel: "Suggested prompts",
@@ -111,6 +112,18 @@ const APARTE_DEFAULT_LOCALE = {
111
112
  toolFailed: "Failed",
112
113
  toolInput: "Input",
113
114
  toolOutput: "Output",
115
+ toolRejectedSaid: "You said: “{instruction}”",
116
+ toolRefusedByPolicy: "Refused by policy",
117
+ toolNotRunAfterRefusal: "Not run: an earlier call was refused",
118
+ toolTimedOut: "Timed out",
119
+ toolUnavailable: "Not available here",
120
+ toolNoApprover: "No one could approve it",
121
+ toolTurnLimit: "Turn limit reached",
122
+ maxTurnsReached: "Stopped after {n} turns to avoid an endless loop.",
123
+ questionDeclined: "You declined to answer.",
124
+ artifactNotCreated: "Not created",
125
+ thoughtFor: "Thought for {duration}",
126
+ thought: "Thought",
114
127
  approvalOptionsLabel: "Your decision",
115
128
  approvalArgsLabel: "Arguments",
116
129
  actionUpload: "Attach file",
@@ -160,6 +173,8 @@ const APARTE_DEFAULT_LOCALE = {
160
173
  archiveConversation: "Archive",
161
174
  unarchiveConversation: "Unarchive",
162
175
  conversationActions: "Conversation actions",
176
+ conversationRunning: "Answering",
177
+ conversationWaiting: "Waiting for you",
163
178
  renameConversation: "Rename",
164
179
  conversationTitle: "Conversation title",
165
180
  pinConversation: "Pin",
@@ -1076,25 +1091,33 @@ function dispatchLifecycleEvent(target, name, detail, targetId) {
1076
1091
  const stamped = detail && typeof detail === "object" ? { targetId: id, ...detail } : detail;
1077
1092
  target.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail: stamped }));
1078
1093
  }
1094
+ const toolRefusal = toolRefusal$1;
1079
1095
  function createStreamAdapter(opts) {
1080
1096
  const { target, config, messageId, targetId } = opts;
1081
1097
  let parser = new AparteStreamParser({ blocks: config.getStreamBlocks() });
1082
1098
  let streaming = /* @__PURE__ */ new Set();
1083
1099
  let thinkingId = null;
1084
1100
  const toolSegments = /* @__PURE__ */ new Map();
1085
- const addedTools = /* @__PURE__ */ new Set();
1086
1101
  const settle = (toolCallId, update) => {
1087
1102
  const id = `tool-${toolCallId}`;
1088
1103
  target.updateSegment?.(id, toolSegments.has(id) ? update : { ...update, deferred: void 0 });
1089
1104
  };
1090
1105
  let thinkingContent = "";
1091
1106
  let thinkingCollapsed = false;
1107
+ const settleThinking = (fold) => {
1108
+ if (!thinkingId || thinkingCollapsed) return;
1109
+ const chatConfig = typeof Element !== "undefined" && target instanceof Element ? resolveConfig(target) : config;
1110
+ const keepOpen = !fold || chatConfig.getSegmentDefaults("thinking")?.collapsed === false;
1111
+ target.updateSegment?.(thinkingId, keepOpen ? { isStreaming: false } : { collapsed: true, isStreaming: false });
1112
+ thinkingCollapsed = true;
1113
+ };
1092
1114
  return (e) => {
1093
1115
  switch (e.type) {
1094
1116
  case "run-start":
1095
1117
  target.updateMessage?.(messageId, { status: "streaming" });
1096
1118
  break;
1097
1119
  case "turn-start":
1120
+ settleThinking(false);
1098
1121
  parser = new AparteStreamParser({ blocks: config.getStreamBlocks() });
1099
1122
  streaming = /* @__PURE__ */ new Set();
1100
1123
  thinkingId = null;
@@ -1107,7 +1130,8 @@ function createStreamAdapter(opts) {
1107
1130
  const seg = {
1108
1131
  id: `think-${uuid()}`,
1109
1132
  type: "thinking",
1110
- content: thinkingContent
1133
+ content: thinkingContent,
1134
+ isStreaming: true
1111
1135
  };
1112
1136
  thinkingId = seg.id;
1113
1137
  streaming.add(seg.id);
@@ -1118,12 +1142,7 @@ function createStreamAdapter(opts) {
1118
1142
  break;
1119
1143
  }
1120
1144
  case "text-delta": {
1121
- if (thinkingId && !thinkingCollapsed) {
1122
- const chatConfig = typeof Element !== "undefined" && target instanceof Element ? resolveConfig(target) : config;
1123
- const keepOpen = chatConfig.getSegmentDefaults("thinking")?.collapsed === false;
1124
- target.updateSegment?.(thinkingId, keepOpen ? { isStreaming: false } : { collapsed: true, isStreaming: false });
1125
- thinkingCollapsed = true;
1126
- }
1145
+ settleThinking(true);
1127
1146
  const result = parser.parse(e.delta);
1128
1147
  for (const segment of result.segments) {
1129
1148
  if (!streaming.has(segment.id)) {
@@ -1153,6 +1172,7 @@ function createStreamAdapter(opts) {
1153
1172
  break;
1154
1173
  }
1155
1174
  case "tool-start": {
1175
+ settleThinking(false);
1156
1176
  const toolSeg = {
1157
1177
  id: `tool-${e.toolCallId}`,
1158
1178
  type: "tool_call",
@@ -1161,17 +1181,8 @@ function createStreamAdapter(opts) {
1161
1181
  };
1162
1182
  toolSegments.set(toolSeg.id, toolSeg);
1163
1183
  const renderer = config.getToolRenderer(e.name);
1164
- if (renderer) {
1165
- injectToolRendererStyles(e.name, renderer);
1166
- const html = renderer.render(toolSeg);
1167
- if (html) {
1168
- target.addSegment?.(toolSeg);
1169
- addedTools.add(toolSeg.id);
1170
- }
1171
- } else {
1172
- target.addSegment?.(toolSeg);
1173
- addedTools.add(toolSeg.id);
1174
- }
1184
+ if (renderer) injectToolRendererStyles(e.name, renderer);
1185
+ target.addSegment?.(toolSeg);
1175
1186
  break;
1176
1187
  }
1177
1188
  case "tool-awaiting-approval":
@@ -1182,7 +1193,11 @@ function createStreamAdapter(opts) {
1182
1193
  settle(e.toolCallId, { status: "pending" });
1183
1194
  break;
1184
1195
  case "tool-rejected":
1185
- settle(e.toolCallId, { status: "rejected", result: e.reason });
1196
+ settle(e.toolCallId, {
1197
+ status: "rejected",
1198
+ result: e.reason,
1199
+ ...e.by ? { refusal: e.instruction ? { by: e.by, instruction: e.instruction } : { by: e.by } } : {}
1200
+ });
1186
1201
  break;
1187
1202
  case "tool-resolved":
1188
1203
  settle(e.toolCallId, {
@@ -1192,7 +1207,7 @@ function createStreamAdapter(opts) {
1192
1207
  });
1193
1208
  break;
1194
1209
  case "tool-aborted":
1195
- settle(e.toolCallId, { status: "aborted" });
1210
+ settle(e.toolCallId, e.cause ? { status: "aborted", abortCause: e.cause } : { status: "aborted" });
1196
1211
  break;
1197
1212
  case "tool-failed":
1198
1213
  settle(e.toolCallId, { status: "failed", result: e.error });
@@ -1202,11 +1217,14 @@ function createStreamAdapter(opts) {
1202
1217
  target.addSegment?.({
1203
1218
  id: `max-turns-${uuid()}`,
1204
1219
  type: "error",
1205
- content: `Stopped after ${e.limit} tool calls to prevent an infinite loop.`,
1206
- details: "MAX_TURNS_EXCEEDED"
1220
+ // The CHAT's text, in its language — resolved from the target, as the
1221
+ // reasoning default is: a client on the global config also answers a
1222
+ // chat with its own.
1223
+ content: (typeof Element !== "undefined" && target instanceof Element ? resolveConfig(target) : config).t("maxTurnsReached").replace("{n}", () => String(e.limit)),
1224
+ code: AparteErrorCode.MAX_TURNS_EXCEEDED
1207
1225
  });
1208
1226
  } else if (e.toolCallId) {
1209
- settle(e.toolCallId, { status: "aborted" });
1227
+ settle(e.toolCallId, { status: "aborted", abortCause: "turn-limit" });
1210
1228
  }
1211
1229
  break;
1212
1230
  case "run-aborted":
@@ -1217,15 +1235,7 @@ function createStreamAdapter(opts) {
1217
1235
  for (const p of e.pending) {
1218
1236
  const id = `tool-${p.toolCallId}`;
1219
1237
  const deferred = { reason: p.reason, ...typeof ticket === "string" ? { ticket } : {} };
1220
- if (addedTools.has(id) || !toolSegments.has(id)) {
1221
- target.updateSegment?.(id, { deferred });
1222
- } else {
1223
- const seg = toolSegments.get(id);
1224
- if (seg) {
1225
- target.addSegment?.({ ...seg, deferred });
1226
- addedTools.add(id);
1227
- }
1228
- }
1238
+ target.updateSegment?.(id, { deferred });
1229
1239
  }
1230
1240
  target.updateMessage?.(messageId, { status: "completed" });
1231
1241
  if (e.usage) {
@@ -1494,7 +1504,8 @@ function createAparteAgentHandler(options) {
1494
1504
  // Reached only for a server `deny`: an `ask` is deferred to the page.
1495
1505
  approvalResolver: async (call) => ({
1496
1506
  approved: false,
1497
- reason: rule(call)?.reason?.trim() || "Tool execution was refused by the server policy."
1507
+ reason: rule(call)?.reason?.trim() || "Tool execution was refused by the server policy.",
1508
+ by: "policy"
1498
1509
  }),
1499
1510
  deferral: { outcomes },
1500
1511
  emitter,
@@ -1848,6 +1859,56 @@ function chatBoundaryOf(el2) {
1848
1859
  }
1849
1860
  return null;
1850
1861
  }
1862
+ class ToolTable {
1863
+ exact = /* @__PURE__ */ new Map();
1864
+ // Keyed by the pattern's spelling, so two RegExp instances of one pattern are one
1865
+ // entry; a Map keeps first-insertion order, which is the order patterns are tried in.
1866
+ patterns = /* @__PURE__ */ new Map();
1867
+ any;
1868
+ /** The value registered under exactly this matcher — not a resolution. */
1869
+ get(match) {
1870
+ if (match instanceof RegExp) return this.patterns.get(String(match))?.value;
1871
+ return match === "*" ? this.any : this.exact.get(match);
1872
+ }
1873
+ set(match, value) {
1874
+ if (match instanceof RegExp) {
1875
+ const key = String(match);
1876
+ const entry = this.patterns.get(key);
1877
+ if (entry) entry.value = value;
1878
+ else this.patterns.set(key, { re: match, value });
1879
+ } else if (match === "*") {
1880
+ this.any = value;
1881
+ } else {
1882
+ this.exact.set(match, value);
1883
+ }
1884
+ }
1885
+ delete(match) {
1886
+ if (match instanceof RegExp) this.patterns.delete(String(match));
1887
+ else if (match === "*") this.any = void 0;
1888
+ else this.exact.delete(match);
1889
+ }
1890
+ /** The value that applies to a tool named `name`, and how specifically it names it. */
1891
+ resolve(name) {
1892
+ if (typeof name !== "string") return void 0;
1893
+ const own = this.exact.get(name);
1894
+ if (own !== void 0) return { value: own, level: "exact" };
1895
+ for (const { re, value } of this.patterns.values()) {
1896
+ if (name.search(re) !== -1) return { value, level: "pattern" };
1897
+ }
1898
+ return this.any !== void 0 ? { value: this.any, level: "any" } : void 0;
1899
+ }
1900
+ /** Every value registered, whatever its matcher. */
1901
+ values() {
1902
+ const out = [...this.exact.values(), ...[...this.patterns.values()].map((p) => p.value)];
1903
+ if (this.any !== void 0) out.push(this.any);
1904
+ return out;
1905
+ }
1906
+ clear() {
1907
+ this.exact.clear();
1908
+ this.patterns.clear();
1909
+ this.any = void 0;
1910
+ }
1911
+ }
1851
1912
  const APARTE_DEFAULT_BUBBLE_ACTIONS = {
1852
1913
  copy: true,
1853
1914
  retry: false,
@@ -1867,6 +1928,7 @@ function renderingVersionOf(cfg) {
1867
1928
  function bumpRenderingVersion(cfg) {
1868
1929
  renderingVersions.set(cfg, renderingVersionOf(cfg) + 1);
1869
1930
  }
1931
+ const ELEMENT_SHAPING = ["hidden", "open", "details", "quiet"];
1870
1932
  function drawingOf(provider, name) {
1871
1933
  if (!provider || !(name in provider) || name in Object.prototype) return void 0;
1872
1934
  const fn = provider[name];
@@ -1975,9 +2037,9 @@ class AparteConfig {
1975
2037
  _elicitationQueue = /* @__PURE__ */ new Map();
1976
2038
  // Tool Registry
1977
2039
  _tools = /* @__PURE__ */ new Map();
1978
- _toolRenderers = /* @__PURE__ */ new Map();
1979
- _toolSummaries = /* @__PURE__ */ new Map();
1980
- _toolInputFormatters = /* @__PURE__ */ new Map();
2040
+ // By exact name, pattern or '*' — see `tool-table.ts` for the one resolution rule.
2041
+ _toolRenderers = new ToolTable();
2042
+ _toolDisplays = new ToolTable();
1981
2043
  /** Tagged blocks the stream parser recognises in the prose — see {@link registerStreamBlock}. */
1982
2044
  _streamBlocks = /* @__PURE__ */ new Map();
1983
2045
  _segmentDefaults = /* @__PURE__ */ new Map();
@@ -2810,16 +2872,20 @@ class AparteConfig {
2810
2872
  * instead — see [Custom tool
2811
2873
  * renderer](https://apartejs.dev/guides/tools/#custom-tool-renderer).
2812
2874
  *
2813
- * @example
2814
- * // Hide the segment entirely (UI-only tool like ask_user)
2815
- * aparteGlobalConfig.registerToolRenderer('ask_user', { render: () => '' });
2875
+ * The matcher is an exact name, a RegExp, or `'*'` — the same rule as
2876
+ * {@link registerToolDisplay}: the exact name wins, then the first pattern registered,
2877
+ * then `'*'`, which is one renderer for every tool without its own.
2816
2878
  *
2817
2879
  * @example
2818
2880
  * // Custom pill for a web-search tool
2819
2881
  * aparteGlobalConfig.registerToolRenderer('web_search', { render: (seg) => `<div class="aparte-tool-label">Searching...</div>` });
2882
+ *
2883
+ * @example
2884
+ * // One look for every tool an MCP server brings
2885
+ * aparteGlobalConfig.registerToolRenderer(/^mcp__/, { render: (seg) => myMcpCard(seg) });
2820
2886
  */
2821
- registerToolRenderer(toolName, renderer) {
2822
- this._toolRenderers.set(toolName, renderer);
2887
+ registerToolRenderer(match, renderer) {
2888
+ this._toolRenderers.set(match, renderer);
2823
2889
  }
2824
2890
  /**
2825
2891
  * Field defaults for a segment TYPE, filled in when a segment enters a message.
@@ -2859,13 +2925,93 @@ class AparteConfig {
2859
2925
  clearSegmentDefaults(type) {
2860
2926
  this._segmentDefaults.delete(type);
2861
2927
  }
2862
- /** Unregister a per-tool renderer */
2863
- unregisterToolRenderer(toolName) {
2864
- this._toolRenderers.delete(toolName);
2928
+ /** Unregister the renderer registered under this matcher. */
2929
+ unregisterToolRenderer(match) {
2930
+ this._toolRenderers.delete(match);
2865
2931
  }
2866
- /** Get the renderer for a specific tool name. Returns undefined if none registered. */
2932
+ /** The renderer that draws a tool named `toolName` — exact, pattern or `'*'` — or undefined. */
2867
2933
  getToolRenderer(toolName) {
2868
- return this._toolRenderers.get(toolName);
2934
+ return this._toolRenderers.resolve(toolName)?.value;
2935
+ }
2936
+ /**
2937
+ * Say how a tool's calls are shown: a label in place of its identifier, an icon, what
2938
+ * the call targets, how its arguments read, whether its row is drawn at all, and
2939
+ * whether it opens.
2940
+ *
2941
+ * The matcher is an exact name, a RegExp, or `'*'` — "every tool nothing more
2942
+ * specific describes". One entry per matcher, and calls MERGE into it; the entry that
2943
+ * applies to a tool is the exact name's, else the first pattern registered that
2944
+ * matches, else `'*'`'s — whole, never a mix of two. Registering tells the chats on
2945
+ * the page, so a label reaches rows already drawn.
2946
+ *
2947
+ * Core never makes a label up: without one the row shows the identifier, which is the
2948
+ * app's wire format.
2949
+ *
2950
+ * @example
2951
+ * aparteGlobalConfig.registerToolDisplay('web_search', {
2952
+ * label: 'Web search',
2953
+ * icon: 'search',
2954
+ * summary: (segment) => String(segment.toolCall.input['query'] ?? ''),
2955
+ * });
2956
+ *
2957
+ * @example
2958
+ * // A consumer chat: the reply is what matters; the calls stay in the history.
2959
+ * aparteGlobalConfig.registerToolDisplay('*', { hidden: true });
2960
+ *
2961
+ * @example
2962
+ * // The line, and no raw JSON behind it: nothing, or a detail of the app's own.
2963
+ * aparteGlobalConfig.registerToolDisplay('read_file', { label: 'Reading a file', details: false });
2964
+ * aparteGlobalConfig.registerToolDisplay('web_search', {
2965
+ * label: 'Searched the web',
2966
+ * details: (segment) => sourcesList(segment.result),
2967
+ * });
2968
+ */
2969
+ registerToolDisplay(match, display) {
2970
+ const before = this._toolDisplays.get(match);
2971
+ const next = { ...before, ...display };
2972
+ this._toolDisplays.set(match, next);
2973
+ const shapes = ELEMENT_SHAPING.some((field) => before?.[field] !== next[field]) || before === void 0 && this._anotherShapes(next);
2974
+ if (shapes) bumpRenderingVersion(this);
2975
+ this._notify();
2976
+ }
2977
+ /** Forget the display registered under this matcher. */
2978
+ unregisterToolDisplay(match) {
2979
+ const before = this._toolDisplays.get(match);
2980
+ this._toolDisplays.delete(match);
2981
+ const shapes = ELEMENT_SHAPING.some((field) => before?.[field] !== void 0) || before !== void 0 && this._anotherShapes(before);
2982
+ if (shapes) bumpRenderingVersion(this);
2983
+ this._notify();
2984
+ }
2985
+ /** Whether an entry other than `own` sets a field that shapes a row. */
2986
+ _anotherShapes(own) {
2987
+ return this._toolDisplays.values().some((entry) => entry !== own && ELEMENT_SHAPING.some((field) => entry[field] !== void 0));
2988
+ }
2989
+ /** How a tool named `toolName` is shown — the entry that applies to it — or undefined. */
2990
+ getToolDisplay(toolName) {
2991
+ return this._toolDisplays.resolve(toolName)?.value;
2992
+ }
2993
+ /**
2994
+ * Whether a tool's calls draw nothing: its display says `hidden`, and no renderer is
2995
+ * registered for it MORE specifically. A `'*'` hide therefore leaves a tool with its
2996
+ * own renderer drawn (a plugin's receipt), while a hide as specific as the renderer
2997
+ * wins — the app said so for that tool. A hidden call stays in the message, in storage
2998
+ * and in the model's history; only its row is empty.
2999
+ */
3000
+ isToolHidden(toolName) {
3001
+ const display = this._toolDisplays.resolve(toolName);
3002
+ if (!display?.value.hidden) return false;
3003
+ const renderer = this._toolRenderers.resolve(toolName);
3004
+ const rank = { exact: 2, pattern: 1, any: 0 };
3005
+ return !renderer || rank[renderer.level] <= rank[display.level];
3006
+ }
3007
+ /** Drop one field of the entry under `match`, and the entry when nothing is left. */
3008
+ _dropToolDisplayField(match, field) {
3009
+ const entry = this._toolDisplays.get(match);
3010
+ if (!entry || !(field in entry)) return;
3011
+ const { [field]: _dropped, ...rest } = entry;
3012
+ if (Object.keys(rest).length) this._toolDisplays.set(match, rest);
3013
+ else this._toolDisplays.delete(match);
3014
+ this._notify();
2869
3015
  }
2870
3016
  /**
2871
3017
  * Say what a call targets, in the default row, after the tool's name.
@@ -2882,42 +3028,46 @@ class AparteConfig {
2882
3028
  * on every config change — so registering one after a row is on screen shows up on
2883
3029
  * that row's next update.
2884
3030
  *
3031
+ * The `summary` field of {@link registerToolDisplay}, on its own: same matcher, same
3032
+ * entry.
3033
+ *
2885
3034
  * @example
2886
3035
  * // read_file skills.json
2887
3036
  * aparteGlobalConfig.registerToolSummary('read_file', (segment) =>
2888
3037
  * String(segment.toolCall.input['path'] ?? ''));
2889
3038
  */
2890
- registerToolSummary(toolName, summary) {
2891
- this._toolSummaries.set(toolName, summary);
3039
+ registerToolSummary(match, summary) {
3040
+ this.registerToolDisplay(match, { summary });
2892
3041
  }
2893
- /** Unregister a per-tool summary. */
2894
- unregisterToolSummary(toolName) {
2895
- this._toolSummaries.delete(toolName);
3042
+ /** Drop the summary registered under this matcher; the rest of its display stays. */
3043
+ unregisterToolSummary(match) {
3044
+ this._dropToolDisplayField(match, "summary");
2896
3045
  }
2897
- /** Get the summary for a specific tool name. Returns undefined if none registered. */
3046
+ /** The summary that applies to a tool named `toolName`, or undefined. */
2898
3047
  getToolSummary(toolName) {
2899
- return this._toolSummaries.get(toolName);
3048
+ return this.getToolDisplay(toolName)?.summary;
2900
3049
  }
2901
3050
  /**
2902
3051
  * Show a tool's arguments as a person reads them, in the approval panel and in the
2903
3052
  * default tool row's disclosure — one formatter for both. Without one, both show the
2904
- * JSON the model sent.
3053
+ * JSON the model sent. The `formatInput` field of {@link registerToolDisplay}, on its
3054
+ * own: same matcher, same entry.
2905
3055
  *
2906
3056
  * @example
2907
3057
  * // Approve "write_file notes.md (2 KB)" rather than a JSON object.
2908
3058
  * aparteGlobalConfig.registerToolInputFormatter('write_file', (input) =>
2909
3059
  * `${String(input['path'] ?? '')} (${Math.ceil(String(input['content'] ?? '').length / 1024)} KB)`);
2910
3060
  */
2911
- registerToolInputFormatter(toolName, formatter) {
2912
- this._toolInputFormatters.set(toolName, formatter);
3061
+ registerToolInputFormatter(match, formatter) {
3062
+ this.registerToolDisplay(match, { formatInput: formatter });
2913
3063
  }
2914
- /** Unregister a tool's input formatter. */
2915
- unregisterToolInputFormatter(toolName) {
2916
- this._toolInputFormatters.delete(toolName);
3064
+ /** Drop the input formatter registered under this matcher; the rest of its display stays. */
3065
+ unregisterToolInputFormatter(match) {
3066
+ this._dropToolDisplayField(match, "formatInput");
2917
3067
  }
2918
- /** Get the input formatter for a tool name. Returns undefined if none registered. */
3068
+ /** The input formatter that applies to a tool named `toolName`, or undefined. */
2919
3069
  getToolInputFormatter(toolName) {
2920
- return this._toolInputFormatters.get(toolName);
3070
+ return this.getToolDisplay(toolName)?.formatInput;
2921
3071
  }
2922
3072
  /**
2923
3073
  * Teach the stream parser a tagged block: `<tag attr="…">…</tag>` in the model's
@@ -3128,8 +3278,7 @@ class AparteConfig {
3128
3278
  this._fetchedModels.clear();
3129
3279
  this._tools.clear();
3130
3280
  this._toolRenderers.clear();
3131
- this._toolSummaries.clear();
3132
- this._toolInputFormatters.clear();
3281
+ this._toolDisplays.clear();
3133
3282
  this._streamBlocks.clear();
3134
3283
  this._modelConfig = {};
3135
3284
  this._requireModelSelection = false;
@@ -3262,9 +3411,36 @@ const textRenderer = {
3262
3411
  },
3263
3412
  getStyles: () => ``
3264
3413
  };
3414
+ function formatDuration(ms, tag) {
3415
+ const total = Math.max(1, Math.round(ms / 1e3));
3416
+ try {
3417
+ const unit = (n, u) => new Intl.NumberFormat(tag, { style: "unit", unit: u, unitDisplay: "narrow" }).format(n);
3418
+ if (total < 60) return unit(total, "second");
3419
+ const minutes = Math.floor(total / 60);
3420
+ const seconds = total % 60;
3421
+ if (!seconds) return unit(minutes, "minute");
3422
+ return new Intl.ListFormat(tag, { style: "narrow", type: "unit" }).format([unit(minutes, "minute"), unit(seconds, "second")]);
3423
+ } catch {
3424
+ return `${total}s`;
3425
+ }
3426
+ }
3265
3427
  const THINKING_ANCHOR_SLACK = 24;
3266
3428
  function labelOf(segment) {
3267
- return segment.label && segment.label !== "Thinking" ? segment.label : contextConfig().t("thinking");
3429
+ if (segment.label && segment.label !== "Thinking") return segment.label;
3430
+ const cfg = contextConfig();
3431
+ if (!isSegmentSettled(segment)) return cfg.t("thinking");
3432
+ const show = segment.showDuration ?? cfg.getSegmentDefaults("thinking")?.["showDuration"] === true;
3433
+ const ms = show ? segmentDuration(segment) : void 0;
3434
+ if (ms === void 0) return cfg.t("thought");
3435
+ return cfg.t("thoughtFor").replace("{duration}", () => formatDuration(ms, cfg.getLocale().tag || void 0));
3436
+ }
3437
+ const arriving = (segment) => segment.isStreaming === true;
3438
+ function setLabel(el2, segment) {
3439
+ const text = labelOf(segment);
3440
+ const label = el2.querySelector(".aparte-thinking-label");
3441
+ if (label && label.textContent !== text) label.textContent = text;
3442
+ label?.classList.toggle("aparte-shimmer", arriving(segment));
3443
+ el2.querySelector(".aparte-thinking-content")?.setAttribute("aria-label", text);
3268
3444
  }
3269
3445
  const thinkingRenderer = {
3270
3446
  type: "thinking",
@@ -3296,18 +3472,14 @@ const thinkingRenderer = {
3296
3472
  * only part that is genuinely about reasoning rather than about disclosure.
3297
3473
  * `.aparte-thinking-label` stays too: `relabel` below queries it.
3298
3474
  */
3299
- render: (segment) => `<details class="aparte-segment aparte-segment-thinking aparte-accordion__item" data-segment-id="${escapeHtml(segment.id)}" ${segment.collapsed === false ? "open" : ""}><summary class="aparte-accordion__header aparte-thinking-header"><span class="aparte-thinking-label">${escapeHtml(labelOf(segment))}</span><span class="aparte-accordion__icon">${contextConfig().getIcon("expand")}</span></summary><div class="aparte-accordion__panel aparte-thinking-content" role="region" tabindex="0" aria-label="${escapeAttr(labelOf(segment))}">${contextConfig().renderMarkdown(segment.content)}</div></details>`,
3475
+ render: (segment) => `<details class="aparte-segment aparte-segment-thinking aparte-accordion__item" data-segment-id="${escapeHtml(segment.id)}" ${segment.collapsed === false ? "open" : ""}><summary class="aparte-accordion__header aparte-thinking-header"><span class="aparte-thinking-label${escapeAttr(arriving(segment) ? " aparte-shimmer" : "")}">${escapeHtml(labelOf(segment))}</span><span class="aparte-accordion__icon">${contextConfig().getIcon("expand")}</span></summary><div class="aparte-accordion__panel aparte-thinking-content" role="region" tabindex="0" aria-label="${escapeAttr(labelOf(segment))}">${contextConfig().renderMarkdown(segment.content)}</div></details>`,
3300
3476
  /**
3301
3477
  * The default label is the only config-derived text here — and `segment.label`
3302
3478
  * still wins, exactly as in `render`, because that string is the app's.
3303
3479
  */
3304
- relabel: (el2, segment) => {
3305
- const text = labelOf(segment);
3306
- const label = el2.querySelector(".aparte-thinking-label");
3307
- if (label) label.textContent = text;
3308
- el2.querySelector(".aparte-thinking-content")?.setAttribute("aria-label", text);
3309
- },
3480
+ relabel: (el2, segment) => setLabel(el2, segment),
3310
3481
  update: (el2, segment) => {
3482
+ setLabel(el2, segment);
3311
3483
  const contentEl = el2.querySelector(".aparte-thinking-content");
3312
3484
  if (!contentEl) return;
3313
3485
  const wasAtBottom = contentEl.scrollHeight - contentEl.scrollTop - contentEl.clientHeight <= THINKING_ANCHOR_SLACK;
@@ -3352,6 +3524,35 @@ function copyText(text) {
3352
3524
  else reject(new Error("[aparte] Clipboard write failed"));
3353
3525
  });
3354
3526
  }
3527
+ const CONFIRM_MS = 1500;
3528
+ function nameButton(button, label) {
3529
+ button.setAttribute("aria-label", label);
3530
+ button.setAttribute("title", label);
3531
+ }
3532
+ function copyButtonMarkup(className) {
3533
+ const label = escapeAttr(contextConfig().t("copy"));
3534
+ const icon = contextConfig().getIcon("copy");
3535
+ return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm ${escapeAttr(className)}" data-action="copy" aria-label="${label}" title="${label}">${icon}</button>`;
3536
+ }
3537
+ function wireCopyButton(button, read) {
3538
+ button.addEventListener("click", () => {
3539
+ void copyText(read()).catch(() => {
3540
+ });
3541
+ button.innerHTML = contextConfig(button).getIcon("check");
3542
+ nameButton(button, contextConfig(button).t("copied"));
3543
+ button.dataset["copied"] = "1";
3544
+ setTimeout(() => {
3545
+ button.innerHTML = contextConfig(button).getIcon("copy");
3546
+ nameButton(button, contextConfig(button).t("copy"));
3547
+ delete button.dataset["copied"];
3548
+ }, CONFIRM_MS);
3549
+ });
3550
+ }
3551
+ function relabelCopyButton(button) {
3552
+ if (button.dataset["copied"]) return;
3553
+ nameButton(button, contextConfig().t("copy"));
3554
+ button.innerHTML = contextConfig().getIcon("copy");
3555
+ }
3355
3556
  const HIGHLIGHT_DEBOUNCE_MS = 400;
3356
3557
  const MAX_THROTTLE_ENTRIES = 256;
3357
3558
  const _lastHighlightAt = /* @__PURE__ */ new Map();
@@ -3399,32 +3600,27 @@ function streamHighlight(element, paneSelector, content, lang, segId) {
3399
3600
  }).catch(() => {
3400
3601
  });
3401
3602
  }
3402
- function nameButton(button, label) {
3403
- button.setAttribute("aria-label", label);
3404
- button.setAttribute("title", label);
3405
- }
3406
3603
  const codeRenderer = {
3407
3604
  type: "code",
3408
- render: (segment) => `
3605
+ render: (segment) => {
3606
+ const copy = copyButtonMarkup("aparte-code-copy");
3607
+ return `
3409
3608
  <div class="aparte-segment aparte-segment-code" data-segment-id="${escapeHtml(segment.id)}">
3410
3609
  <div class="aparte-code-header">
3411
3610
  ${segment.filename ? `<span class="aparte-code-filename">${escapeHtml(segment.filename)}</span>` : `<span class="aparte-code-header-filler"></span>`}
3412
3611
  <span class="aparte-code-language">${escapeHtml(segment.language || "")}</span>
3413
- <button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-code-copy" data-action="copy" aria-label="${escapeAttr(contextConfig().t("copy"))}" title="${escapeAttr(contextConfig().t("copy"))}">
3414
- ${contextConfig().getIcon("copy")}
3415
- </button>
3612
+ ${copy}
3416
3613
  </div>
3417
3614
  <div class="aparte-code-content-wrapper">
3418
3615
  <pre><code class="language-${escapeHtml(segment.language || "text")}">${escapeHtml(segment.content)}</code></pre>
3419
3616
  </div>
3420
3617
  </div>
3421
- `,
3618
+ `;
3619
+ },
3422
3620
  /** The copy button's icon and tooltip — nothing else here comes from config. */
3423
3621
  relabel: (element) => {
3424
3622
  const copyBtn = element.querySelector(".aparte-code-copy");
3425
- if (!copyBtn || copyBtn.dataset.copied) return;
3426
- nameButton(copyBtn, contextConfig().t("copy"));
3427
- copyBtn.innerHTML = contextConfig().getIcon("copy");
3623
+ if (copyBtn) relabelCopyButton(copyBtn);
3428
3624
  },
3429
3625
  setup: (element, segment) => {
3430
3626
  const wrapper = element.querySelector(".aparte-code-content-wrapper");
@@ -3435,21 +3631,7 @@ const codeRenderer = {
3435
3631
  });
3436
3632
  }
3437
3633
  const copyBtn = element.querySelector(".aparte-code-copy");
3438
- if (copyBtn) {
3439
- copyBtn.addEventListener("click", () => {
3440
- const source = element.querySelector(".aparte-code-content-wrapper")?.textContent ?? "";
3441
- void copyText(source).catch(() => {
3442
- });
3443
- copyBtn.innerHTML = contextConfig(copyBtn).getIcon("check");
3444
- nameButton(copyBtn, contextConfig(copyBtn).t("copied"));
3445
- copyBtn.dataset.copied = "1";
3446
- setTimeout(() => {
3447
- copyBtn.innerHTML = contextConfig(copyBtn).getIcon("copy");
3448
- nameButton(copyBtn, contextConfig(copyBtn).t("copy"));
3449
- delete copyBtn.dataset.copied;
3450
- }, 1500);
3451
- });
3452
- }
3634
+ if (copyBtn) wireCopyButton(copyBtn, () => element.querySelector(".aparte-code-content-wrapper")?.textContent ?? "");
3453
3635
  },
3454
3636
  update: (element, segment) => {
3455
3637
  if (segment.isStreaming) {
@@ -3471,7 +3653,11 @@ const errorRenderer = {
3471
3653
  render: (segment) => {
3472
3654
  const custom = contextConfig().getErrorRenderer?.();
3473
3655
  if (custom) {
3474
- const out = custom({ message: segment.content, details: segment.details });
3656
+ const out = custom({
3657
+ message: segment.content,
3658
+ ...segment.details !== void 0 ? { details: segment.details } : {},
3659
+ ...segment.code !== void 0 ? { code: segment.code } : {}
3660
+ });
3475
3661
  if (out instanceof HTMLElement) {
3476
3662
  out.setAttribute("data-segment-id", segment.id);
3477
3663
  return out;
@@ -3479,7 +3665,7 @@ const errorRenderer = {
3479
3665
  return out;
3480
3666
  }
3481
3667
  return `
3482
- <div class="aparte-segment aparte-alert aparte-alert--danger aparte-segment-error" data-segment-id="${escapeHtml(segment.id)}">
3668
+ <div class="aparte-segment aparte-alert aparte-alert--danger aparte-segment-error" data-segment-id="${escapeHtml(segment.id)}"${segment.code ? ` data-error-code="${escapeAttr(segment.code)}"` : ""}>
3483
3669
  <span class="aparte-alert__icon">${contextConfig().getIcon("error") || "⚠"}</span>
3484
3670
  <div class="aparte-alert__body">
3485
3671
  <div class="aparte-alert__title">${escapeHtml(contextConfig().t("error"))}</div>
@@ -3518,22 +3704,150 @@ function describeToolInput(input, format) {
3518
3704
  return "";
3519
3705
  }
3520
3706
  }
3707
+ function toolNameOf(segment, config) {
3708
+ const identifier = segment.toolCall?.name ?? "tool";
3709
+ const label = config.getToolDisplay(identifier)?.label;
3710
+ if (label !== void 0) {
3711
+ try {
3712
+ const text = typeof label === "function" ? label(segment) : label;
3713
+ if (typeof text === "string" && text.trim()) return { text: text.trim(), isLabel: true };
3714
+ } catch {
3715
+ }
3716
+ }
3717
+ return { text: identifier, isLabel: false };
3718
+ }
3719
+ function toolTargetOf(segment, config) {
3720
+ const summary = config.getToolSummary(segment.toolCall?.name ?? "");
3721
+ if (!summary) return "";
3722
+ try {
3723
+ const text = summary(segment);
3724
+ return typeof text === "string" ? text.trim() : "";
3725
+ } catch {
3726
+ return "";
3727
+ }
3728
+ }
3521
3729
  function describeInput(segment) {
3522
3730
  return describeToolInput(segment.toolCall?.input, contextConfig().getToolInputFormatter(segment.toolCall?.name ?? ""));
3523
3731
  }
3524
3732
  function stateBadge(segment) {
3525
3733
  const cfg = contextConfig();
3526
3734
  const status = segment.status ?? "pending";
3527
- const word = (key) => escapeHtml(cfg.t(key));
3528
- if (status === "awaiting-approval") return `${cfg.getIcon("pause")}${word("approvalWaiting")}`;
3529
- if (status === "pending") return word("toolRunning");
3530
- if (status === "resolved") return `${cfg.getIcon("check")}${word("toolCompleted")}`;
3531
- if (status === "rejected") return `${cfg.getIcon("close")}${word("toolRejected")}`;
3532
- if (status === "aborted") return `${cfg.getIcon("stop")}${word("toolStopped")}`;
3533
- if (status === "failed") return `${cfg.getIcon("close")}${word("toolFailed")}`;
3735
+ const word = (text) => escapeHtml(text);
3736
+ if (status === "awaiting-approval") return `${cfg.getIcon("pause")}${word(cfg.t("approvalWaiting"))}`;
3737
+ if (status === "pending") return word(cfg.t("toolRunning"));
3738
+ if (status === "resolved") return `${cfg.getIcon("check")}${word(cfg.t("toolCompleted"))}`;
3739
+ if (status === "rejected") return `${cfg.getIcon("close")}${word(cfg.t("toolRejected"))}`;
3740
+ if (status === "aborted") return `${cfg.getIcon("stop")}${word(cfg.t("toolStopped"))}`;
3741
+ if (status === "failed") return `${cfg.getIcon("close")}${word(cfg.t("toolFailed"))}`;
3534
3742
  return "";
3535
3743
  }
3536
- function setPart(element, part, text) {
3744
+ function noteOf(segment) {
3745
+ const cfg = contextConfig();
3746
+ if (segment.status === "rejected") {
3747
+ const by = segment.refusal?.by;
3748
+ if (by === "policy") return cfg.t("toolRefusedByPolicy");
3749
+ if (by === "earlier-refusal") return cfg.t("toolNotRunAfterRefusal");
3750
+ const said = segment.refusal?.instruction?.trim();
3751
+ return said ? cfg.t("toolRejectedSaid").replace("{instruction}", () => said) : "";
3752
+ }
3753
+ if (segment.status === "aborted") {
3754
+ switch (segment.abortCause) {
3755
+ case "timeout":
3756
+ return cfg.t("toolTimedOut");
3757
+ case "no-handler":
3758
+ return cfg.t("toolUnavailable");
3759
+ case "no-resolver":
3760
+ return cfg.t("toolNoApprover");
3761
+ case "turn-limit":
3762
+ return cfg.t("toolTurnLimit");
3763
+ default:
3764
+ return "";
3765
+ }
3766
+ }
3767
+ return "";
3768
+ }
3769
+ function outcomeOf(segment) {
3770
+ const text = displayText(segment.result ?? "");
3771
+ if (!text) return null;
3772
+ if (segment.status === "rejected" && segment.refusal) return null;
3773
+ return { key: "toolOutput", text };
3774
+ }
3775
+ function behindOf(segment) {
3776
+ const details = contextConfig().getToolDisplay(segment.toolCall?.name ?? "")?.details;
3777
+ if (details === false) return { kind: "none" };
3778
+ if (typeof details !== "function") return { kind: "parts" };
3779
+ try {
3780
+ const out = details(segment);
3781
+ if (!out) return { kind: "none" };
3782
+ return { kind: "body", body: typeof out === "string" ? contextConfig().sanitizeHtml(out) : out };
3783
+ } catch {
3784
+ return { kind: "none" };
3785
+ }
3786
+ }
3787
+ const appDetailOf = (element) => element.querySelector(':scope > .aparte-tool-detail[data-details="app"]');
3788
+ function setBody(element, body) {
3789
+ const detail = element.querySelector(".aparte-tool-detail");
3790
+ if (!detail) return;
3791
+ if (typeof body === "string") detail.innerHTML = body;
3792
+ else detail.replaceChildren(body);
3793
+ drawnBodies.set(detail, serialized(body));
3794
+ }
3795
+ const drawnBodies = /* @__PURE__ */ new WeakMap();
3796
+ const serialized = (body) => typeof body === "string" ? body : body.outerHTML;
3797
+ function relabelBody(detail, body) {
3798
+ if (typeof body !== "string" && detail.contains(body)) {
3799
+ drawnBodies.set(detail, body.outerHTML);
3800
+ return;
3801
+ }
3802
+ const before = drawnBodies.get(detail);
3803
+ const now = serialized(body);
3804
+ if (before === now) return;
3805
+ const next = document.createElement("div");
3806
+ if (typeof body === "string") next.innerHTML = body;
3807
+ else next.append(body);
3808
+ const prev = document.createElement("div");
3809
+ if (before !== void 0) prev.innerHTML = before;
3810
+ if (before !== void 0 && sameShape(prev, next) && sameShape(prev, detail)) patchChanges(detail, prev, next);
3811
+ else detail.replaceChildren(...Array.from(next.childNodes));
3812
+ drawnBodies.set(detail, now);
3813
+ }
3814
+ function sameShape(a, b) {
3815
+ if (a.childNodes.length !== b.childNodes.length) return false;
3816
+ for (let i = 0; i < a.childNodes.length; i++) {
3817
+ const x = a.childNodes[i];
3818
+ const y = b.childNodes[i];
3819
+ if (x.nodeType !== y.nodeType || x.nodeName !== y.nodeName || !sameShape(x, y)) return false;
3820
+ }
3821
+ return true;
3822
+ }
3823
+ function patchChanges(live, prev, next) {
3824
+ for (let i = 0; i < live.childNodes.length; i++) {
3825
+ const l = live.childNodes[i];
3826
+ const p = prev.childNodes[i];
3827
+ const n = next.childNodes[i];
3828
+ if (l instanceof Element && p instanceof Element && n instanceof Element) {
3829
+ for (const { name, value } of Array.from(n.attributes)) {
3830
+ if (p.getAttribute(name) !== value) l.setAttribute(name, value);
3831
+ }
3832
+ for (const { name } of Array.from(p.attributes)) {
3833
+ if (!n.hasAttribute(name)) l.removeAttribute(name);
3834
+ }
3835
+ patchChanges(l, p, n);
3836
+ } else if (p.nodeValue !== n.nodeValue) {
3837
+ l.nodeValue = n.nodeValue;
3838
+ }
3839
+ }
3840
+ }
3841
+ function setNote(element, segment) {
3842
+ const el2 = element.querySelector(".aparte-tool-note");
3843
+ if (!el2) return;
3844
+ const text = noteOf(segment);
3845
+ if (el2.textContent !== text) el2.textContent = text;
3846
+ el2.toggleAttribute("hidden", !text);
3847
+ if (text) el2.setAttribute("title", text);
3848
+ else el2.removeAttribute("title");
3849
+ }
3850
+ function setPart(element, part, text, labelKey) {
3537
3851
  const existing = element.querySelector(`[data-part="${part}"]`);
3538
3852
  if (!text) {
3539
3853
  existing?.remove();
@@ -3542,6 +3856,10 @@ function setPart(element, part, text) {
3542
3856
  if (existing) {
3543
3857
  const code2 = existing.querySelector("code");
3544
3858
  if (code2 && code2.textContent !== text) code2.textContent = text;
3859
+ const label2 = existing.querySelector(".aparte-tool-part-label");
3860
+ const word = contextConfig().t(labelKey);
3861
+ if (label2 && label2.textContent !== word) label2.textContent = word;
3862
+ existing.querySelector(".aparte-tool-part-body")?.setAttribute("aria-label", word);
3545
3863
  return;
3546
3864
  }
3547
3865
  const detail = element.querySelector(".aparte-tool-detail");
@@ -3550,25 +3868,57 @@ function setPart(element, part, text) {
3550
3868
  const wrap = document.createElement("div");
3551
3869
  wrap.className = "aparte-tool-part";
3552
3870
  wrap.dataset["part"] = part;
3871
+ const head = document.createElement("div");
3872
+ head.className = "aparte-tool-part-head";
3553
3873
  const label = document.createElement("span");
3554
3874
  label.className = "aparte-tool-part-label";
3555
- label.textContent = cfg.t(part === "input" ? "toolInput" : "toolOutput");
3875
+ label.textContent = cfg.t(labelKey);
3876
+ const tpl = document.createElement("template");
3877
+ tpl.innerHTML = copyButtonMarkup("aparte-tool-copy");
3878
+ head.append(label, tpl.content);
3556
3879
  const body = document.createElement("div");
3557
3880
  body.className = "aparte-tool-part-body";
3881
+ body.setAttribute("role", "region");
3882
+ body.tabIndex = 0;
3883
+ body.setAttribute("aria-label", label.textContent);
3558
3884
  const pre = document.createElement("pre");
3559
3885
  const code = document.createElement("code");
3560
3886
  code.textContent = text;
3561
3887
  pre.appendChild(code);
3562
3888
  body.appendChild(pre);
3563
- wrap.append(label, body);
3889
+ wrap.append(head, body);
3564
3890
  if (part === "input") detail.prepend(wrap);
3565
3891
  else detail.appendChild(wrap);
3892
+ wireParts(wrap);
3893
+ }
3894
+ function wireParts(root) {
3895
+ for (const button of root.querySelectorAll(".aparte-tool-copy")) {
3896
+ if (button.dataset["wired"]) continue;
3897
+ button.dataset["wired"] = "1";
3898
+ const part = button.closest(".aparte-tool-part");
3899
+ wireCopyButton(button, () => part?.querySelector(".aparte-tool-part-body")?.textContent ?? "");
3900
+ }
3901
+ }
3902
+ const JSON_PART_LIMIT = 2e5;
3903
+ function jsonStructureOf(text) {
3904
+ const trimmed = text.trim();
3905
+ if (trimmed.length > JSON_PART_LIMIT || !(trimmed.startsWith("{") || trimmed.startsWith("["))) return void 0;
3906
+ try {
3907
+ const value = JSON.parse(trimmed);
3908
+ return value !== null && typeof value === "object" ? value : void 0;
3909
+ } catch {
3910
+ return void 0;
3911
+ }
3912
+ }
3913
+ function displayText(raw) {
3914
+ const value = jsonStructureOf(raw);
3915
+ return value === void 0 ? raw : JSON.stringify(value, null, 2);
3566
3916
  }
3567
3917
  function highlightParts(element) {
3568
3918
  const cfg = contextConfig();
3569
3919
  for (const body of element.querySelectorAll(".aparte-tool-part-body")) {
3570
3920
  const text = body.querySelector("code")?.textContent ?? "";
3571
- if (!text) continue;
3921
+ if (!text || jsonStructureOf(text) === void 0) continue;
3572
3922
  void cfg.highlightCode(text, "json").then((html) => {
3573
3923
  if ((body.querySelector("code")?.textContent ?? "") !== text) return;
3574
3924
  body.innerHTML = html;
@@ -3577,14 +3927,7 @@ function highlightParts(element) {
3577
3927
  }
3578
3928
  }
3579
3929
  function toolTarget(segment) {
3580
- const summary = contextConfig().getToolSummary(segment.toolCall?.name ?? "");
3581
- if (!summary) return "";
3582
- try {
3583
- const text = summary(segment);
3584
- return typeof text === "string" ? text.trim() : "";
3585
- } catch {
3586
- return "";
3587
- }
3930
+ return toolTargetOf(segment, contextConfig());
3588
3931
  }
3589
3932
  function setTarget(element, segment) {
3590
3933
  const el2 = element.querySelector(".aparte-tool-target");
@@ -3593,58 +3936,117 @@ function setTarget(element, segment) {
3593
3936
  if (el2.textContent !== text) el2.textContent = text;
3594
3937
  el2.toggleAttribute("hidden", !text);
3595
3938
  }
3596
- function pillMarkup(segment, name, status) {
3939
+ function nameOf(segment) {
3940
+ return toolNameOf(segment, contextConfig());
3941
+ }
3942
+ function iconOf(segment) {
3943
+ const cfg = contextConfig();
3944
+ const key = cfg.getToolDisplay(segment.toolCall?.name ?? "")?.icon;
3945
+ return (key ? cfg.getIcon(key) : void 0) || cfg.getIcon("tool");
3946
+ }
3947
+ const isQuiet = (segment) => contextConfig().getToolDisplay(segment.toolCall?.name ?? "")?.quiet === true;
3948
+ function quietOf(segment) {
3949
+ const status = segment.status ?? "pending";
3950
+ const quiet = isQuiet(segment);
3951
+ return {
3952
+ shimmer: quiet && status === "pending",
3953
+ spinner: !quiet && status === "pending",
3954
+ stateHidden: quiet && (status === "pending" || status === "resolved")
3955
+ };
3956
+ }
3957
+ function setQuiet(element, segment) {
3958
+ const quiet = quietOf(segment);
3959
+ element.toggleAttribute("data-quiet", isQuiet(segment));
3960
+ element.querySelector(".aparte-tool-spinner")?.toggleAttribute("hidden", !quiet.spinner);
3961
+ element.querySelector(".aparte-tool-name")?.classList.toggle("aparte-shimmer", quiet.shimmer);
3962
+ element.querySelector(".aparte-tool-state")?.classList.toggle("aparte-sr-only", quiet.stateHidden);
3963
+ }
3964
+ function setName(element, segment) {
3965
+ const el2 = element.querySelector(".aparte-tool-name");
3966
+ if (!el2) return;
3967
+ const { text, isLabel } = nameOf(segment);
3968
+ if (el2.textContent !== text) el2.textContent = text;
3969
+ el2.classList.toggle("aparte-tool-name--label", isLabel);
3970
+ }
3971
+ function pillMarkup(segment) {
3597
3972
  const badge = stateBadge(segment);
3973
+ const icon = iconOf(segment);
3598
3974
  const target = toolTarget(segment);
3975
+ const note = noteOf(segment);
3976
+ const name = nameOf(segment);
3977
+ const quiet = quietOf(segment);
3599
3978
  return `
3600
3979
  <span class="aparte-tool-label">
3601
- <span class="aparte-tool-icon">${contextConfig().getIcon("tool")}</span>
3602
- <span class="aparte-tool-name">${escapeHtml(name)}</span>
3980
+ <span class="aparte-tool-icon">${icon}</span>
3981
+ <span class="aparte-tool-name${name.isLabel ? " aparte-tool-name--label" : ""}${quiet.shimmer ? " aparte-shimmer" : ""}">${escapeHtml(name.text)}</span>
3603
3982
  <span class="aparte-tool-target"${target ? "" : " hidden"}>${escapeHtml(target)}</span>
3604
3983
  </span>
3605
- <span class="aparte-spinner aparte-tool-spinner" aria-hidden="true"${status === "pending" ? "" : " hidden"}></span>
3606
- <span class="aparte-tool-state">${badge}</span>`;
3984
+ <span class="aparte-spinner aparte-tool-spinner" aria-hidden="true"${quiet.spinner ? "" : " hidden"}></span>
3985
+ <span class="aparte-tool-state${quiet.stateHidden ? " aparte-sr-only" : ""}">${badge}</span>
3986
+ <span class="aparte-tool-note"${note ? ` title="${escapeAttr(note)}"` : " hidden"}>${escapeHtml(note)}</span>`;
3987
+ }
3988
+ function placeholderMarkup(segment) {
3989
+ return `<div class="aparte-segment aparte-segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${escapeAttr(segment.status ?? "pending")}" data-tool-call-id="${escapeAttr(segment.toolCall?.id ?? "")}" data-tool-name="${escapeAttr(segment.toolCall?.name ?? "tool")}" hidden></div>`;
3990
+ }
3991
+ const isPlaceholder = (element) => element.hidden && element.classList.contains("aparte-segment-tool-call") && element.childElementCount === 0;
3992
+ function drawDefault(segment, behind) {
3993
+ const name = segment.toolCall?.name ?? "tool";
3994
+ const status = segment.status ?? "pending";
3995
+ const toolCallId = segment.toolCall?.id ?? "";
3996
+ const pill = pillMarkup(segment);
3997
+ const quiet = isQuiet(segment) ? " data-quiet" : "";
3998
+ const toggle = contextConfig().getIcon("expand");
3999
+ const input = behind.kind === "parts" ? describeInput(segment) : "";
4000
+ const outcome = behind.kind === "parts" ? outcomeOf(segment) : null;
4001
+ if (behind.kind === "none" || behind.kind === "parts" && !input && !outcome) {
4002
+ return `
4003
+ <div class="aparte-segment aparte-segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${escapeAttr(status)}" data-tool-call-id="${escapeAttr(toolCallId)}" data-tool-name="${escapeAttr(name)}"${quiet}><div class="aparte-tool-line">${pill}</div>
4004
+ </div>
4005
+ `;
4006
+ }
4007
+ const cfg = contextConfig();
4008
+ const copy = copyButtonMarkup("aparte-tool-copy");
4009
+ const open = segment.collapsed !== void 0 ? !segment.collapsed : cfg.getToolDisplay(name)?.open === true;
4010
+ if (behind.kind === "body") {
4011
+ const tpl = document.createElement("template");
4012
+ tpl.innerHTML = `
4013
+ <details class="aparte-segment aparte-segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${escapeAttr(status)}" data-tool-call-id="${escapeAttr(toolCallId)}" data-tool-name="${escapeAttr(name)}"${quiet}${open ? " open" : ""}>
4014
+ <summary class="aparte-tool-summary"><span class="aparte-tool-toggle">${toggle}</span>${pill}</summary>
4015
+ <div class="aparte-tool-detail" data-details="app"></div>
4016
+ </details>`.trim();
4017
+ const row = tpl.content.firstElementChild;
4018
+ setBody(row, behind.body);
4019
+ return row;
4020
+ }
4021
+ return `
4022
+ <details class="aparte-segment aparte-segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${escapeAttr(status)}" data-tool-call-id="${escapeAttr(toolCallId)}" data-tool-name="${escapeAttr(name)}"${quiet}${open ? " open" : ""}>
4023
+ <summary class="aparte-tool-summary"><span class="aparte-tool-toggle">${toggle}</span>${pill}</summary>
4024
+ <div class="aparte-tool-detail">
4025
+ ${input ? `
4026
+ <div class="aparte-tool-part" data-part="input">
4027
+ <div class="aparte-tool-part-head"><span class="aparte-tool-part-label">${escapeHtml(cfg.t("toolInput"))}</span>${copy}</div>
4028
+ <div class="aparte-tool-part-body" role="region" tabindex="0" aria-label="${escapeAttr(cfg.t("toolInput"))}"><pre><code>${escapeHtml(input)}</code></pre></div>
4029
+ </div>` : ""}
4030
+ ${outcome ? `
4031
+ <div class="aparte-tool-part" data-part="output">
4032
+ <div class="aparte-tool-part-head"><span class="aparte-tool-part-label">${escapeHtml(cfg.t(outcome.key))}</span>${copy}</div>
4033
+ <div class="aparte-tool-part-body" role="region" tabindex="0" aria-label="${escapeAttr(cfg.t(outcome.key))}"><pre><code>${escapeHtml(outcome.text)}</code></pre></div>
4034
+ </div>` : ""}
4035
+ </div>
4036
+ </details>
4037
+ `;
3607
4038
  }
3608
4039
  const toolCallRenderer = {
3609
4040
  type: "tool_call",
3610
4041
  render: (segment) => {
3611
4042
  const name = segment.toolCall?.name ?? "tool";
3612
- const status = segment.status ?? "pending";
3613
- const toolCallId = segment.toolCall?.id ?? "";
3614
- const customRenderer = contextConfig().getToolRenderer(segment.toolCall?.name);
4043
+ if (contextConfig().isToolHidden(name)) return placeholderMarkup(segment);
4044
+ const customRenderer = contextConfig().getToolRenderer(name);
3615
4045
  if (customRenderer) {
3616
- injectToolRendererStyles(segment.toolCall?.name ?? "", customRenderer);
3617
- const out = customRenderer.render(segment);
3618
- if (out) return out;
3619
- }
3620
- const pill = pillMarkup(segment, name, status);
3621
- const toggle = contextConfig().getIcon("expand");
3622
- const input = describeInput(segment);
3623
- const result = segment.result;
3624
- if (!input && !result) {
3625
- return `
3626
- <div class="aparte-segment aparte-segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${escapeAttr(status)}" data-tool-call-id="${escapeAttr(toolCallId)}">${pill}
3627
- </div>
3628
- `;
4046
+ injectToolRendererStyles(name, customRenderer);
4047
+ return customRenderer.render(segment) || placeholderMarkup(segment);
3629
4048
  }
3630
- const cfg = contextConfig();
3631
- return `
3632
- <details class="aparte-segment aparte-segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${escapeAttr(status)}" data-tool-call-id="${escapeAttr(toolCallId)}">
3633
- <summary class="aparte-tool-summary"><span class="aparte-tool-toggle">${toggle}</span>${pill}</summary>
3634
- <div class="aparte-tool-detail">
3635
- ${input ? `
3636
- <div class="aparte-tool-part" data-part="input">
3637
- <span class="aparte-tool-part-label">${escapeHtml(cfg.t("toolInput"))}</span>
3638
- <div class="aparte-tool-part-body"><pre><code>${escapeHtml(input)}</code></pre></div>
3639
- </div>` : ""}
3640
- ${result ? `
3641
- <div class="aparte-tool-part" data-part="output">
3642
- <span class="aparte-tool-part-label">${escapeHtml(cfg.t("toolOutput"))}</span>
3643
- <div class="aparte-tool-part-body"><pre><code>${escapeHtml(result)}</code></pre></div>
3644
- </div>` : ""}
3645
- </div>
3646
- </details>
3647
- `;
4049
+ return drawDefault(segment, behindOf(segment));
3648
4050
  },
3649
4051
  /**
3650
4052
  * The pill's glyphs, and the label it wears while a person is deciding.
@@ -3657,6 +4059,7 @@ const toolCallRenderer = {
3657
4059
  * No child node is added or removed, per the relabel contract.
3658
4060
  */
3659
4061
  relabel: (element, segment) => {
4062
+ if (isPlaceholder(element)) return;
3660
4063
  const cfg = contextConfig();
3661
4064
  const custom = cfg.getToolRenderer(segment.toolCall?.name);
3662
4065
  if (custom) {
@@ -3664,10 +4067,26 @@ const toolCallRenderer = {
3664
4067
  return;
3665
4068
  }
3666
4069
  const icon = element.querySelector(".aparte-tool-icon");
3667
- if (icon) icon.innerHTML = cfg.getIcon("tool");
4070
+ if (icon) icon.innerHTML = iconOf(segment);
4071
+ setName(element, segment);
3668
4072
  setTarget(element, segment);
4073
+ setNote(element, segment);
4074
+ const inputLabel = element.querySelector('[data-part="input"] .aparte-tool-part-label');
4075
+ if (inputLabel) inputLabel.textContent = cfg.t("toolInput");
4076
+ element.querySelector('[data-part="input"] .aparte-tool-part-body')?.setAttribute("aria-label", cfg.t("toolInput"));
4077
+ const outcome = outcomeOf(segment);
4078
+ const outputLabel = element.querySelector('[data-part="output"] .aparte-tool-part-label');
4079
+ if (outputLabel && outcome) outputLabel.textContent = cfg.t(outcome.key);
4080
+ if (outcome) element.querySelector('[data-part="output"] .aparte-tool-part-body')?.setAttribute("aria-label", cfg.t(outcome.key));
4081
+ for (const button of element.querySelectorAll(".aparte-tool-copy")) relabelCopyButton(button);
4082
+ const appDetail = appDetailOf(element);
4083
+ if (appDetail) {
4084
+ const behind = behindOf(segment);
4085
+ if (behind.kind === "body") relabelBody(appDetail, behind.body);
4086
+ }
3669
4087
  const statusEl = element.querySelector(".aparte-tool-state");
3670
4088
  if (statusEl) statusEl.innerHTML = stateBadge(segment);
4089
+ setQuiet(element, segment);
3671
4090
  },
3672
4091
  /**
3673
4092
  * Patch in place. NEVER re-render, and never touch `open`.
@@ -3684,34 +4103,57 @@ const toolCallRenderer = {
3684
4103
  */
3685
4104
  update: (element, segment) => {
3686
4105
  const status = segment.status ?? "pending";
3687
- const input = describeInput(segment);
3688
- const wantsDetail = !!input || !!segment.result;
4106
+ const hidden = contextConfig().isToolHidden(segment.toolCall?.name ?? "tool");
4107
+ if (hidden && isPlaceholder(element)) {
4108
+ element.setAttribute("data-status", status);
4109
+ return;
4110
+ }
3689
4111
  const custom = contextConfig().getToolRenderer(segment.toolCall?.name);
3690
- if (custom?.update) {
4112
+ if (custom?.update && !hidden && !isPlaceholder(element)) {
3691
4113
  custom.update(element, segment);
3692
4114
  return;
3693
4115
  }
3694
- if (custom || wantsDetail !== (element.tagName === "DETAILS")) {
3695
- const out = toolCallRenderer.render(segment);
4116
+ const rebuild = (out) => {
3696
4117
  const tpl = document.createElement("template");
3697
4118
  tpl.innerHTML = typeof out === "string" ? out.trim() : "";
3698
4119
  const rebuilt = typeof out === "string" ? tpl.content.firstElementChild : out;
3699
4120
  if (rebuilt) {
4121
+ if (!rebuilt.hasAttribute("data-segment-id")) rebuilt.setAttribute("data-segment-id", segment.id);
3700
4122
  element.replaceWith(rebuilt);
3701
4123
  toolCallRenderer.setup?.(rebuilt, segment);
3702
4124
  }
4125
+ };
4126
+ if (custom || hidden || isPlaceholder(element)) {
4127
+ rebuild(toolCallRenderer.render(segment));
4128
+ return;
4129
+ }
4130
+ const behind = behindOf(segment);
4131
+ const input = behind.kind === "parts" ? describeInput(segment) : "";
4132
+ const outcome = behind.kind === "parts" ? outcomeOf(segment) : null;
4133
+ const wantsDetail = behind.kind === "body" || !!input || !!outcome;
4134
+ const holdsAppDetail = !!appDetailOf(element);
4135
+ if (wantsDetail !== (element.tagName === "DETAILS") || wantsDetail && behind.kind === "body" !== holdsAppDetail) {
4136
+ rebuild(drawDefault(segment, behind));
3703
4137
  return;
3704
4138
  }
3705
4139
  element.setAttribute("data-status", status);
3706
4140
  const badge = element.querySelector(".aparte-tool-state");
3707
4141
  if (badge) badge.innerHTML = stateBadge(segment);
3708
- element.querySelector(".aparte-tool-spinner")?.toggleAttribute("hidden", status !== "pending");
4142
+ setQuiet(element, segment);
4143
+ setName(element, segment);
3709
4144
  setTarget(element, segment);
3710
- setPart(element, "input", input);
3711
- setPart(element, "output", segment.result ?? "");
4145
+ setNote(element, segment);
4146
+ if (behind.kind === "body") {
4147
+ setBody(element, behind.body);
4148
+ return;
4149
+ }
4150
+ setPart(element, "input", input, "toolInput");
4151
+ setPart(element, "output", outcome?.text ?? "", outcome?.key ?? "toolOutput");
3712
4152
  highlightParts(element);
3713
4153
  },
3714
4154
  setup: (element, segment) => {
4155
+ if (isPlaceholder(element)) return;
4156
+ wireParts(element);
3715
4157
  highlightParts(element);
3716
4158
  const customRenderer = contextConfig().getToolRenderer(segment.toolCall?.name);
3717
4159
  customRenderer?.setup?.(element, segment);
@@ -3784,11 +4226,16 @@ function injectRendererStyles(config = contextConfig()) {
3784
4226
  }
3785
4227
  if (added) styleElement.textContent = Array.from(injectedRendererStyles).join("\n");
3786
4228
  }
4229
+ const toolRendererSheetIds = /* @__PURE__ */ new WeakMap();
3787
4230
  function injectToolRendererStyles(toolName, renderer) {
3788
4231
  if (typeof document === "undefined") return;
3789
4232
  const css = renderer.getStyles?.();
3790
4233
  if (!css) return;
3791
- const id = `aparte-tool-renderer-${toolName}`;
4234
+ let id = toolRendererSheetIds.get(renderer);
4235
+ if (!id) {
4236
+ id = `aparte-tool-renderer-${toolName}`;
4237
+ toolRendererSheetIds.set(renderer, id);
4238
+ }
3792
4239
  if (document.getElementById(id)) return;
3793
4240
  const el2 = document.createElement("style");
3794
4241
  el2.id = id;
@@ -4309,6 +4756,21 @@ function revokeAttachmentUrls(attachments) {
4309
4756
  if (attachment.url?.startsWith("blob:")) URL.revokeObjectURL(attachment.url);
4310
4757
  }
4311
4758
  }
4759
+ const known = /* @__PURE__ */ new Map();
4760
+ function rememberConversationStatus(id, status) {
4761
+ if (status) known.set(id, status);
4762
+ else known.delete(id);
4763
+ }
4764
+ function tellConversationStatus(id, status) {
4765
+ rememberConversationStatus(id, status);
4766
+ try {
4767
+ window.dispatchEvent(new CustomEvent("aparte-conversation-status", { detail: { id, status } }));
4768
+ } catch {
4769
+ }
4770
+ }
4771
+ function conversationStatusOf(id) {
4772
+ return known.get(id) ?? null;
4773
+ }
4312
4774
  const MAX_COMPACTION_SAVE_WAITS = 6;
4313
4775
  class AparteConversationController {
4314
4776
  _binding;
@@ -4317,6 +4779,10 @@ class AparteConversationController {
4317
4779
  _isStreaming = false;
4318
4780
  _isLoadingConversation = false;
4319
4781
  _ensureInFlight = null;
4782
+ /** The conversation the turn in flight started in, and what the lists were told of it. */
4783
+ _turn = null;
4784
+ /** The reply the turn in flight writes into, from its `aparte-message-start`. */
4785
+ _turnMessage = null;
4320
4786
  _onSendCapture = null;
4321
4787
  _onPathChanged = null;
4322
4788
  _onMessageStart = null;
@@ -4324,6 +4790,7 @@ class AparteConversationController {
4324
4790
  _onMessageError = null;
4325
4791
  _onMessageAborted = null;
4326
4792
  _onMessagePaused = null;
4793
+ _onMessageResumed = null;
4327
4794
  _onSelectConversation = null;
4328
4795
  _onCompactDone = null;
4329
4796
  _unsubscribeManager = null;
@@ -4404,6 +4871,33 @@ class AparteConversationController {
4404
4871
  "[aparte] This chat resolves its own config, but the conversation manager was registered on aparteGlobalConfig — so nothing is being persisted. Pass the same config to init(): `init(adapter, myConfig)`, using the config you gave the chat."
4405
4872
  );
4406
4873
  }
4874
+ /**
4875
+ * Tell every conversation list on the page what the turn is doing, through
4876
+ * `aparte-conversation-status` on `window` — the mirror of the list's
4877
+ * `aparte-conversation-select`, which this controller hears. Always about the
4878
+ * conversation the turn started in: the person may have opened another since. A turn
4879
+ * that starts before its conversation exists (a first message) is told once it does.
4880
+ */
4881
+ _setTurn(status) {
4882
+ if (!status) {
4883
+ if (this._turn) tellConversationStatus(this._turn.id, null);
4884
+ this._turn = null;
4885
+ return;
4886
+ }
4887
+ const id = this._turn?.id ?? (status === "running" ? this._activeId ?? this._manager()?.activeId ?? null : null);
4888
+ if (!id) return;
4889
+ this._turn = { id, status };
4890
+ tellConversationStatus(id, status);
4891
+ }
4892
+ /**
4893
+ * Whether a lifecycle event is about the turn in flight. A turn a newer send replaced
4894
+ * (a chip, a retry, an edit) says it stopped AFTER the new one started: its end must not
4895
+ * end the new turn's state. An event without a message id is taken as this turn's.
4896
+ */
4897
+ _ofThisTurn(e) {
4898
+ const id = e.detail?.messageId;
4899
+ return !id || !this._turnMessage || id === this._turnMessage;
4900
+ }
4407
4901
  /**
4408
4902
  * Attach event listeners to the binding host. Returns an unbind function.
4409
4903
  */
@@ -4434,29 +4928,42 @@ class AparteConversationController {
4434
4928
  }
4435
4929
  };
4436
4930
  host.addEventListener("aparte-path-changed", this._onPathChanged);
4437
- this._onMessageStart = () => {
4931
+ this._onMessageStart = (e) => {
4438
4932
  this._isStreaming = true;
4933
+ this._turnMessage = e.detail?.messageId ?? null;
4934
+ this._setTurn("running");
4439
4935
  };
4440
- this._onMessageDone = () => {
4936
+ const ended = (e) => {
4937
+ if (!this._ofThisTurn(e)) return;
4441
4938
  this._isStreaming = false;
4939
+ this._setTurn(null);
4940
+ this._turnMessage = null;
4941
+ };
4942
+ this._onMessageDone = (e) => {
4943
+ ended(e);
4442
4944
  queueMicrotask(() => this._persistActive());
4443
4945
  };
4444
- this._onMessageError = () => {
4445
- this._isStreaming = false;
4946
+ this._onMessageError = (e) => {
4947
+ ended(e);
4446
4948
  queueMicrotask(() => this._persistActive());
4447
4949
  };
4448
- this._onMessageAborted = () => {
4449
- this._isStreaming = false;
4950
+ this._onMessageAborted = (e) => {
4951
+ ended(e);
4450
4952
  queueMicrotask(() => this._persistActive());
4451
4953
  };
4452
- this._onMessagePaused = () => {
4954
+ this._onMessagePaused = (e) => {
4955
+ if (this._ofThisTurn(e)) this._setTurn("waiting");
4453
4956
  queueMicrotask(() => this._persistActive());
4454
4957
  };
4958
+ this._onMessageResumed = (e) => {
4959
+ if (this._ofThisTurn(e)) this._setTurn("running");
4960
+ };
4455
4961
  host.addEventListener("aparte-message-start", this._onMessageStart);
4456
4962
  host.addEventListener("aparte-message-done", this._onMessageDone);
4457
4963
  host.addEventListener("aparte-message-error", this._onMessageError);
4458
4964
  host.addEventListener("aparte-message-aborted", this._onMessageAborted);
4459
4965
  host.addEventListener("aparte-message-paused", this._onMessagePaused);
4966
+ host.addEventListener("aparte-message-resumed", this._onMessageResumed);
4460
4967
  this._onSelectConversation = (e) => {
4461
4968
  const evt = e;
4462
4969
  const targetId = evt.detail?.targetId;
@@ -4483,6 +4990,9 @@ class AparteConversationController {
4483
4990
  this._bound = false;
4484
4991
  this._subscribedTo = null;
4485
4992
  this._persistActive();
4993
+ if (this._turn?.status === "running") this._setTurn(null);
4994
+ this._turn = null;
4995
+ this._turnMessage = null;
4486
4996
  this._stopHydrationRetry?.();
4487
4997
  this._stopHydrationRetry = null;
4488
4998
  const host = this._binding.host;
@@ -4514,6 +5024,10 @@ class AparteConversationController {
4514
5024
  host.removeEventListener("aparte-message-paused", this._onMessagePaused);
4515
5025
  this._onMessagePaused = null;
4516
5026
  }
5027
+ if (this._onMessageResumed) {
5028
+ host.removeEventListener("aparte-message-resumed", this._onMessageResumed);
5029
+ this._onMessageResumed = null;
5030
+ }
4517
5031
  if (this._onCompactDone && typeof window !== "undefined") {
4518
5032
  window.removeEventListener("aparte-compact-done", this._onCompactDone);
4519
5033
  this._onCompactDone = null;
@@ -4542,6 +5056,8 @@ class AparteConversationController {
4542
5056
  const isSwitch = id !== this._activeId;
4543
5057
  const manager = this._manager();
4544
5058
  if (isSwitch && this._isStreaming) {
5059
+ if (this._turn?.status === "waiting") this._turn = null;
5060
+ else this._setTurn(null);
4545
5061
  this._persistActive();
4546
5062
  try {
4547
5063
  window.dispatchEvent(new CustomEvent("aparte-abort", {
@@ -4723,6 +5239,7 @@ class AparteConversationController {
4723
5239
  }
4724
5240
  this._activeId = convId;
4725
5241
  this._options.onConversationCreated?.(convId);
5242
+ if (this._isStreaming && !this._turn) this._setTurn("running");
4726
5243
  } else if (this._activeId !== null && this._activeId !== convId) {
4727
5244
  await manager.addMessage(convId, userMsg).catch(() => {
4728
5245
  });
@@ -6291,7 +6808,16 @@ class AparteClient {
6291
6808
  */
6292
6809
  message: () => {
6293
6810
  const loc = this._config.getLocale();
6294
- return (loc.approvalAsk ?? "Run {tool}?").replace("{tool}", () => event.name);
6811
+ const chat = resolveConfig(target);
6812
+ const call = {
6813
+ id: `tool-${event.id}`,
6814
+ type: "tool_call",
6815
+ status: "awaiting-approval",
6816
+ toolCall: { id: event.id, name: event.name, input: event.input }
6817
+ };
6818
+ const name = toolNameOf(call, chat).text;
6819
+ const what = toolTargetOf(call, chat);
6820
+ return (loc.approvalAsk ?? "Allow {tool}?").replace("{tool}", () => what ? `${name} ${what}` : name);
6295
6821
  },
6296
6822
  // A plain string, not a function: the arguments are the model's — its JSON, or
6297
6823
  // the tool's own formatter's reading of it — and do not change with the
@@ -6308,11 +6834,11 @@ class AparteClient {
6308
6834
  signal,
6309
6835
  target
6310
6836
  });
6311
- if (answer.action !== "accept") return { approved: false };
6837
+ if (answer.action !== "accept") return { approved: false, by: "person" };
6312
6838
  const content = answer.content;
6313
6839
  const instruction = content?.instruction?.trim();
6314
- if (instruction) return { approved: false, instruction };
6315
- return { approved: content?.option === "allow" };
6840
+ if (instruction) return { approved: false, instruction, by: "person" };
6841
+ return content?.option === "allow" ? { approved: true } : { approved: false, by: "person" };
6316
6842
  }
6317
6843
  /**
6318
6844
  * Resolve the auth for a provider: `options.keyResolver` takes precedence,
@@ -6378,6 +6904,7 @@ class AparteClient {
6378
6904
  return;
6379
6905
  }
6380
6906
  dispatchLifecycleEvent(targetElement, "aparte-message-start", { messageId, role: "assistant" }, chatId);
6907
+ if (opts?.resume) dispatchLifecycleEvent(targetElement, "aparte-message-paused", { messageId }, chatId);
6381
6908
  if (!opts?.resume && provider.id) {
6382
6909
  this._updateMessage(targetElement, messageId, {
6383
6910
  model: {
@@ -6390,6 +6917,7 @@ class AparteClient {
6390
6917
  const streamController = new AbortController();
6391
6918
  if (this._streamController !== streamController) this._streamController?.abort();
6392
6919
  this._streamController = streamController;
6920
+ this._turnsInFlight.add(messageId);
6393
6921
  try {
6394
6922
  let result = opts?.resume ? { usage: void 0, suspended: opts.resume } : await this._streamLoop(targetElement, messageId, provider, baseRequest, authConfig, streamController);
6395
6923
  const maxResumes = this.options.maxTurns ?? 10;
@@ -6421,6 +6949,7 @@ class AparteClient {
6421
6949
  };
6422
6950
  const messages2 = this._resumeMessages(targetElement, messageId, baseRequest.messages);
6423
6951
  this._spendPause(targetElement, messageId);
6952
+ dispatchLifecycleEvent(targetElement, "aparte-message-resumed", { messageId }, chatId);
6424
6953
  result = await this._streamLoop(
6425
6954
  targetElement,
6426
6955
  messageId,
@@ -6447,8 +6976,15 @@ class AparteClient {
6447
6976
  }
6448
6977
  const aparteError = AparteError.from(error, AparteErrorCode.UNKNOWN_ERROR);
6449
6978
  this._handleLifecycleError(targetElement, messageId, aparteError);
6979
+ } finally {
6980
+ this._turnsInFlight.delete(messageId);
6450
6981
  }
6451
6982
  }
6983
+ /**
6984
+ * Replies a turn is running into right now, a pause it waits on included: a second
6985
+ * load of their conversation must not start them again (`resumeDeferred`).
6986
+ */
6987
+ _turnsInFlight = /* @__PURE__ */ new Set();
6452
6988
  /**
6453
6989
  * Handle aparte-retry — add a sibling branch to the assistant message and re-stream
6454
6990
  * using the same conversation history minus the retried reply.
@@ -7132,7 +7668,7 @@ ${content}
7132
7668
  const ruling = gatedElsewhere ? this._config.getApprovalPolicy()?.(call, this._config.getTools().find((t) => t.name === call.name)) ?? { verdict: "ask" } : this._config.ruleOnToolCall(call);
7133
7669
  if (ruling.verdict === "allow") return { approved: true };
7134
7670
  if (ruling.verdict === "deny") {
7135
- return { approved: false, reason: ruling.reason?.trim() || "Tool execution was refused by the approval policy." };
7671
+ return { approved: false, reason: ruling.reason?.trim() || "Tool execution was refused by the approval policy.", by: "policy" };
7136
7672
  }
7137
7673
  return this._askForApproval(call, sig, targetElement);
7138
7674
  });
@@ -7150,7 +7686,7 @@ ${content}
7150
7686
  const resolver = this._approvalResolverFor(targetElement, true);
7151
7687
  const refuseTheRest = (after) => {
7152
7688
  for (const rest of pending.slice(after + 1)) {
7153
- outcomes[rest.toolCallId] = { kind: "decision", approved: false, reason: NOT_RUN_AFTER_A_REFUSAL };
7689
+ outcomes[rest.toolCallId] = { kind: "decision", approved: false, reason: NOT_RUN_AFTER_A_REFUSAL, by: "earlier-refusal" };
7154
7690
  }
7155
7691
  };
7156
7692
  for (const [index, p] of pending.entries()) {
@@ -7320,7 +7856,7 @@ ${content}
7320
7856
  const last = [...messages].reverse().find((m) => m.role === "assistant");
7321
7857
  const open = (last?.segments ?? []).filter(isOpenDeferred);
7322
7858
  const ticket = open.find((s) => s.deferred?.ticket)?.deferred?.ticket;
7323
- if (!last || open.length === 0 || !ticket || this._resuming.has(last.id)) return false;
7859
+ if (!last || open.length === 0 || !ticket || this._resuming.has(last.id) || this._turnsInFlight.has(last.id)) return false;
7324
7860
  const config = this._config.getModelConfig();
7325
7861
  const recorded = last.model && this._config.getAIProvider(last.model.providerId) ? last.model : void 0;
7326
7862
  const providerId = recorded?.providerId ?? config.defaultProvider;
@@ -7367,7 +7903,7 @@ ${content}
7367
7903
  id: `error-${uuid()}`,
7368
7904
  type: "error",
7369
7905
  content: error.message,
7370
- details: error.code
7906
+ code: error.code
7371
7907
  };
7372
7908
  if (target.updateMessage) {
7373
7909
  const rendered = target.getMessage?.(messageId)?.segments ?? target.getMessages?.()?.find((m) => m.id === messageId)?.segments ?? [];
@@ -7753,6 +8289,7 @@ const APARTE_DEFAULT_UI_EVENTS = [
7753
8289
  "aparte-message-error",
7754
8290
  "aparte-message-aborted",
7755
8291
  "aparte-message-paused",
8292
+ "aparte-message-resumed",
7756
8293
  "aparte-tool-approval-request"
7757
8294
  ];
7758
8295
  function applyElementProps(el2, props, transformValue = (value) => value) {
@@ -7864,24 +8401,28 @@ export {
7864
8401
  segmentDuration as a4,
7865
8402
  segmentTiming as a5,
7866
8403
  subscribeConfigChange as a6,
7867
- unregisterSegmentRenderer as a7,
7868
- uuid as a8,
7869
- eventOrigin as a9,
7870
- activeElementIn as aa,
7871
- gateGlide as ab,
7872
- renderingVersionOf as ac,
7873
- mergeSegmentUpdate as ad,
7874
- writeStreamedMarkdown as ae,
7875
- stampSegmentActivity as af,
7876
- stampSegmentOnInsert as ag,
7877
- stampSegmentOnUpdate as ah,
7878
- renumberSegments as ai,
7879
- openSegmentIds as aj,
7880
- isTerminalStatus as ak,
7881
- adoptMessageSegments as al,
7882
- APARTE_DERIVED_SEGMENTS as am,
7883
- chatBoundaryOf as an,
7884
- rootOf as ao,
8404
+ toolRefusal as a7,
8405
+ unregisterSegmentRenderer as a8,
8406
+ uuid as a9,
8407
+ eventOrigin as aa,
8408
+ activeElementIn as ab,
8409
+ gateGlide as ac,
8410
+ renderingVersionOf as ad,
8411
+ mergeSegmentUpdate as ae,
8412
+ writeStreamedMarkdown as af,
8413
+ stampSegmentActivity as ag,
8414
+ stampSegmentOnInsert as ah,
8415
+ stampSegmentOnUpdate as ai,
8416
+ renumberSegments as aj,
8417
+ openSegmentIds as ak,
8418
+ isTerminalStatus as al,
8419
+ adoptMessageSegments as am,
8420
+ APARTE_DERIVED_SEGMENTS as an,
8421
+ rememberConversationStatus as ao,
8422
+ tellConversationStatus as ap,
8423
+ conversationStatusOf as aq,
8424
+ chatBoundaryOf as ar,
8425
+ rootOf as as,
7885
8426
  APARTE_DEFAULT_BUBBLE_ACTIONS as b,
7886
8427
  APARTE_DEFAULT_HOST_HANDLERS as c,
7887
8428
  APARTE_DEFAULT_ICON_FALLBACKS as d,
@@ -7908,4 +8449,4 @@ export {
7908
8449
  collectRendererStyles as y,
7909
8450
  contentToText as z
7910
8451
  };
7911
- //# sourceMappingURL=dialog-triggers-3Rk7PuZa.js.map
8452
+ //# sourceMappingURL=dialog-triggers-BqXRdHWZ.js.map