zidane 5.6.14 → 5.7.4

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 (119) hide show
  1. package/README.md +3 -1
  2. package/dist/{agent-ClkpElCZ.d.ts → agent-BNS2nx_T.d.ts} +535 -15
  3. package/dist/agent-BNS2nx_T.d.ts.map +1 -0
  4. package/dist/chat/pure.d.ts +4 -0
  5. package/dist/chat/pure.js +3 -0
  6. package/dist/chat.d.ts +31 -661
  7. package/dist/chat.d.ts.map +1 -1
  8. package/dist/chat.js +5 -3
  9. package/dist/chat.js.map +1 -1
  10. package/dist/contexts/docker.d.ts +1 -1
  11. package/dist/contexts/docker.d.ts.map +1 -1
  12. package/dist/contexts/docker.js.map +1 -1
  13. package/dist/{contexts-BOtMvzli.js → contexts-BD2U_xpi.js} +2 -2
  14. package/dist/{contexts-BOtMvzli.js.map → contexts-BD2U_xpi.js.map} +1 -1
  15. package/dist/contexts.d.ts +3 -3
  16. package/dist/contexts.js +1 -1
  17. package/dist/edit-utils-DnfNoj16.js +574 -0
  18. package/dist/edit-utils-DnfNoj16.js.map +1 -0
  19. package/dist/{errors-DdZXnyXE.js → errors-CoQnKRf1.js} +32 -2
  20. package/dist/{errors-DdZXnyXE.js.map → errors-CoQnKRf1.js.map} +1 -1
  21. package/dist/fetch-url-CPxfiXDa.js +518 -0
  22. package/dist/fetch-url-CPxfiXDa.js.map +1 -0
  23. package/dist/image-sniff-B7uFSNO1.js +90 -0
  24. package/dist/image-sniff-B7uFSNO1.js.map +1 -0
  25. package/dist/{index-CbS75MD3.d.ts → index-CZOwAJIX.d.ts} +2 -2
  26. package/dist/index-CZOwAJIX.d.ts.map +1 -0
  27. package/dist/{index-CTDMMdIy.d.ts → index-Ck_AWt8P.d.ts} +3 -4
  28. package/dist/index-Ck_AWt8P.d.ts.map +1 -0
  29. package/dist/{index-v3Tzobqr.d.ts → index-KiS7w0dC.d.ts} +3 -3
  30. package/dist/index-KiS7w0dC.d.ts.map +1 -0
  31. package/dist/index.d.ts +6 -6
  32. package/dist/index.js +13 -12
  33. package/dist/index.js.map +1 -1
  34. package/dist/{interpolate-DM1UcKeQ.js → interpolate-TySiqKzc.js} +23 -23
  35. package/dist/{interpolate-DM1UcKeQ.js.map → interpolate-TySiqKzc.js.map} +1 -1
  36. package/dist/{login-7tHcckmX.js → login-BDeqENSe.js} +7 -58
  37. package/dist/login-BDeqENSe.js.map +1 -0
  38. package/dist/{mcp-DGeB7-3D.js → mcp-Kqzz-Rs_.js} +8 -6
  39. package/dist/mcp-Kqzz-Rs_.js.map +1 -0
  40. package/dist/mcp.d.ts +2 -2
  41. package/dist/mcp.js +1 -1
  42. package/dist/{messages-Dym8S_YH.js → messages-CvRQTdbR.js} +118 -39
  43. package/dist/messages-CvRQTdbR.js.map +1 -0
  44. package/dist/{presets-w9Px_aAm.js → presets-JuOnSI-i.js} +2 -2
  45. package/dist/{presets-w9Px_aAm.js.map → presets-JuOnSI-i.js.map} +1 -1
  46. package/dist/presets.d.ts +3 -3
  47. package/dist/presets.js +1 -1
  48. package/dist/{providers-beXyD9W9.js → providers-h4HJPbbv.js} +485 -31
  49. package/dist/providers-h4HJPbbv.js.map +1 -0
  50. package/dist/providers.d.ts +2 -2
  51. package/dist/providers.js +3 -3
  52. package/dist/restate.d.ts +1 -1
  53. package/dist/restate.d.ts.map +1 -1
  54. package/dist/restate.js.map +1 -1
  55. package/dist/session/sqlite.d.ts +1 -1
  56. package/dist/session/sqlite.d.ts.map +1 -1
  57. package/dist/session/sqlite.js +1 -1
  58. package/dist/session/sqlite.js.map +1 -1
  59. package/dist/{session-BRIsmBSY.js → session-BzLou2_-.js} +2 -2
  60. package/dist/{session-BRIsmBSY.js.map → session-BzLou2_-.js.map} +1 -1
  61. package/dist/session.d.ts +2 -2
  62. package/dist/session.js +2 -2
  63. package/dist/skills.d.ts +3 -3
  64. package/dist/skills.js +1 -1
  65. package/dist/skills.js.map +1 -1
  66. package/dist/{stats-Lc3zL3RM.js → stats-DAKBEKjc.js} +12 -2
  67. package/dist/stats-DAKBEKjc.js.map +1 -0
  68. package/dist/{stdio-loader-EVAF5KlU.js → stdio-loader-Ce68wUmM.js} +4 -4
  69. package/dist/stdio-loader-Ce68wUmM.js.map +1 -0
  70. package/dist/tool-formatters-CU-j3a3e.d.ts +1471 -0
  71. package/dist/tool-formatters-CU-j3a3e.d.ts.map +1 -0
  72. package/dist/tools/fetch-url.d.ts +70 -0
  73. package/dist/tools/fetch-url.d.ts.map +1 -0
  74. package/dist/tools/fetch-url.js +2 -0
  75. package/dist/tools/web-search.d.ts +7 -0
  76. package/dist/tools/web-search.d.ts.map +1 -0
  77. package/dist/tools/web-search.js +190 -0
  78. package/dist/tools/web-search.js.map +1 -0
  79. package/dist/{tools-DhrLrOEr.js → tools-BGtJK0vo.js} +1368 -421
  80. package/dist/tools-BGtJK0vo.js.map +1 -0
  81. package/dist/tools.d.ts +3 -3
  82. package/dist/tools.js +1 -1
  83. package/dist/{turn-operations-UAkOjO-u.js → transcript-anchors-BTSZAPVc.js} +147 -2713
  84. package/dist/transcript-anchors-BTSZAPVc.js.map +1 -0
  85. package/dist/{transcript-anchors-D0TR6djV.d.ts → transcript-anchors-DX90kXc4.d.ts} +13 -1299
  86. package/dist/transcript-anchors-DX90kXc4.d.ts.map +1 -0
  87. package/dist/tui.d.ts +58 -28
  88. package/dist/tui.d.ts.map +1 -1
  89. package/dist/tui.js +1349 -422
  90. package/dist/tui.js.map +1 -1
  91. package/dist/turn-operations-CCHfR9eC.js +1938 -0
  92. package/dist/turn-operations-CCHfR9eC.js.map +1 -0
  93. package/dist/turn-operations-DDIl4YVk.d.ts +658 -0
  94. package/dist/turn-operations-DDIl4YVk.d.ts.map +1 -0
  95. package/dist/{types-oKPBdCmL.js → types-BPw_i5vb.js} +1 -1
  96. package/dist/types-BPw_i5vb.js.map +1 -0
  97. package/dist/{types-KukEp-mi.d.ts → types-CEAMIUXw.d.ts} +1 -1
  98. package/dist/types-CEAMIUXw.d.ts.map +1 -0
  99. package/dist/types.d.ts +4 -4
  100. package/dist/types.js +3 -3
  101. package/docs/CHAT.md +53 -6
  102. package/docs/SKILL.md +3 -0
  103. package/docs/TUI.md +7 -0
  104. package/package.json +18 -2
  105. package/dist/agent-ClkpElCZ.d.ts.map +0 -1
  106. package/dist/index-CTDMMdIy.d.ts.map +0 -1
  107. package/dist/index-CbS75MD3.d.ts.map +0 -1
  108. package/dist/index-v3Tzobqr.d.ts.map +0 -1
  109. package/dist/login-7tHcckmX.js.map +0 -1
  110. package/dist/mcp-DGeB7-3D.js.map +0 -1
  111. package/dist/messages-Dym8S_YH.js.map +0 -1
  112. package/dist/providers-beXyD9W9.js.map +0 -1
  113. package/dist/stats-Lc3zL3RM.js.map +0 -1
  114. package/dist/stdio-loader-EVAF5KlU.js.map +0 -1
  115. package/dist/tools-DhrLrOEr.js.map +0 -1
  116. package/dist/transcript-anchors-D0TR6djV.d.ts.map +0 -1
  117. package/dist/turn-operations-UAkOjO-u.js.map +0 -1
  118. package/dist/types-KukEp-mi.d.ts.map +0 -1
  119. package/dist/types-oKPBdCmL.js.map +0 -1
@@ -0,0 +1,1938 @@
1
+ import { h as utf8ByteLength, r as styleReplacementForVia, t as resolveOldString } from "./edit-utils-DnfNoj16.js";
2
+ import { t as effectiveInputFromTurn } from "./stats-DAKBEKjc.js";
3
+ import { Fzf } from "fzf";
4
+ //#region src/chat/color-gradient.ts
5
+ /** Parse `#rrggbb` (case-insensitive) into `[r, g, b]` 0–255 integers. */
6
+ function parseHex(hex) {
7
+ const h = hex.replace("#", "");
8
+ return [
9
+ Number.parseInt(h.slice(0, 2), 16),
10
+ Number.parseInt(h.slice(2, 4), 16),
11
+ Number.parseInt(h.slice(4, 6), 16)
12
+ ];
13
+ }
14
+ /** Convert sRGB 0–255 → HSL 0–1. */
15
+ function rgbToHsl(r, g, b) {
16
+ r /= 255;
17
+ g /= 255;
18
+ b /= 255;
19
+ const max = Math.max(r, g, b);
20
+ const min = Math.min(r, g, b);
21
+ const l = (max + min) / 2;
22
+ if (max === min) return [
23
+ 0,
24
+ 0,
25
+ l
26
+ ];
27
+ const d = max - min;
28
+ const s = l > .5 ? d / (2 - max - min) : d / (max + min);
29
+ let h;
30
+ if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
31
+ else if (max === g) h = (b - r) / d + 2;
32
+ else h = (r - g) / d + 4;
33
+ return [
34
+ h / 6,
35
+ s,
36
+ l
37
+ ];
38
+ }
39
+ /** Convert HSL 0–1 → sRGB 0–255. Standard piecewise formula. */
40
+ function hslToRgb(h, s, l) {
41
+ if (s === 0) return [
42
+ l * 255,
43
+ l * 255,
44
+ l * 255
45
+ ];
46
+ const hue2rgb = (p, q, t) => {
47
+ if (t < 0) t += 1;
48
+ if (t > 1) t -= 1;
49
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
50
+ if (t < 1 / 2) return q;
51
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
52
+ return p;
53
+ };
54
+ const q = l < .5 ? l * (1 + s) : l + s - l * s;
55
+ const p = 2 * l - q;
56
+ return [
57
+ hue2rgb(p, q, h + 1 / 3) * 255,
58
+ hue2rgb(p, q, h) * 255,
59
+ hue2rgb(p, q, h - 1 / 3) * 255
60
+ ];
61
+ }
62
+ function toHex(rgb) {
63
+ const pad = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0");
64
+ return `#${pad(rgb[0])}${pad(rgb[1])}${pad(rgb[2])}`;
65
+ }
66
+ /**
67
+ * Blend two hex colors in HSL space with shortest-path hue interpolation.
68
+ * `t` ∈ [0, 1]; `t=0` returns `from`, `t=1` returns `to`.
69
+ */
70
+ function blendHsl(from, to, t) {
71
+ const [r1, g1, b1] = parseHex(from);
72
+ const [r2, g2, b2] = parseHex(to);
73
+ const [h1, s1, l1] = rgbToHsl(r1, g1, b1);
74
+ const [h2, s2, l2] = rgbToHsl(r2, g2, b2);
75
+ let dh = h2 - h1;
76
+ if (dh > .5) dh -= 1;
77
+ else if (dh < -.5) dh += 1;
78
+ return toHex(hslToRgb((h1 + dh * t + 1) % 1, s1 + (s2 - s1) * t, l1 + (l2 - l1) * t));
79
+ }
80
+ /**
81
+ * Static gradient ramp of length `n` going from `from` (index 0) to
82
+ * `to` (index n-1) in HSL space. For the cycling A→B→A→B ramp the
83
+ * throbber uses, see `buildCycleRamp` in `src/tui/crush-throbber.tsx`.
84
+ */
85
+ function buildLinearRamp(from, to, n) {
86
+ if (n <= 0) return [];
87
+ if (n === 1) return [blendHsl(from, to, .5)];
88
+ const ramp = [];
89
+ for (let i = 0; i < n; i++) ramp.push(blendHsl(from, to, i / (n - 1)));
90
+ return ramp;
91
+ }
92
+ //#endregion
93
+ //#region src/chat/completion-core.ts
94
+ /**
95
+ * Resolve the provider trigger active at `cursor`, or `null` when none fits.
96
+ *
97
+ * Rules:
98
+ * - The trigger character must sit at position 0 of the buffer OR be
99
+ * preceded by whitespace. This prevents `http://` from triggering the
100
+ * `/`-bound skills provider mid-URL.
101
+ * - The cursor must be at or past the trigger position.
102
+ * - Nothing between the trigger and the cursor may be whitespace (the
103
+ * query is one contiguous token).
104
+ * - The query length is bounded — `maxQueryLength` defaults to 64 — so
105
+ * a runaway buffer scan can't pin the renderer.
106
+ */
107
+ function findActiveTrigger(text, cursor, providers, options = {}) {
108
+ if (providers.length === 0) return null;
109
+ const max = options.maxQueryLength ?? 64;
110
+ const safeCursor = Math.max(0, Math.min(cursor, text.length));
111
+ const isWhitespace = (ch) => ch === void 0 ? false : /\s/.test(ch);
112
+ for (let i = safeCursor - 1; i >= 0 && safeCursor - i <= max + 1; i--) {
113
+ const ch = text[i];
114
+ if (isWhitespace(ch)) return null;
115
+ const provider = providers.find((p) => p.trigger === ch);
116
+ if (!provider) continue;
117
+ const before = i > 0 ? text[i - 1] : "";
118
+ if (before !== "" && !isWhitespace(before)) continue;
119
+ return {
120
+ provider,
121
+ query: text.slice(i + 1, safeCursor),
122
+ span: {
123
+ start: i,
124
+ end: safeCursor
125
+ }
126
+ };
127
+ }
128
+ return null;
129
+ }
130
+ /**
131
+ * Replace `[span.start, span.end)` in `text` with `insertText`. Returns the
132
+ * mutated text and the new cursor position (end of insertion).
133
+ */
134
+ function applyInsert(text, span, insertText) {
135
+ return {
136
+ text: text.slice(0, span.start) + insertText + text.slice(span.end),
137
+ cursor: span.start + insertText.length
138
+ };
139
+ }
140
+ /**
141
+ * Merge reference lists from multiple providers into one ordered list with
142
+ * earlier-start-wins disambiguation when spans overlap. Ties broken by
143
+ * insertion order. Spans are sorted ascending so renderers can walk them
144
+ * sequentially with a cursor through the source string.
145
+ */
146
+ function mergeReferences(refs) {
147
+ const sorted = [...refs].sort((a, b) => a.start - b.start);
148
+ const merged = [];
149
+ let lastEnd = -1;
150
+ for (const ref of sorted) {
151
+ if (ref.start < lastEnd) continue;
152
+ merged.push(ref);
153
+ lastEnd = ref.end;
154
+ }
155
+ return merged;
156
+ }
157
+ /**
158
+ * Collect every provider's references in one pass. Convenience wrapper —
159
+ * the TUI textarea component calls this on every keystroke to highlight
160
+ * in-prompt mentions.
161
+ */
162
+ function collectReferences(text, providers, cursor = text.length) {
163
+ const ctx = {
164
+ text,
165
+ cursor
166
+ };
167
+ const refs = [];
168
+ for (const p of providers) for (const ref of p.parseReferences(text, ctx)) refs.push(ref);
169
+ return mergeReferences(refs);
170
+ }
171
+ //#endregion
172
+ //#region src/chat/completion-files.ts
173
+ /** Trigger character — `@` is the conventional file-mention prefix in chat UIs. */
174
+ const FILES_TRIGGER = "@";
175
+ /** Cap on returned items. Keeps the popover compact + render-cheap. */
176
+ const DEFAULT_RESULT_LIMIT = 50;
177
+ /** Identity formatter — preserves the discovery path verbatim. */
178
+ const IDENTITY_FORMAT = (entry) => entry.path;
179
+ /**
180
+ * Rank-and-slice a file catalog against a query. Hoisted to a module
181
+ * helper so both the sync and async branches of `suggest()` share one
182
+ * implementation (the async branch hits this once the lazy directory
183
+ * walk resolves; sync branch hits it on every keystroke thereafter).
184
+ *
185
+ * `formatPath` rewrites the catalog's project-root-relative path into
186
+ * the form the host wants emitted into the prompt (typically CWD-rel
187
+ * or absolute when launched from a project subdir — see
188
+ * `formatPathForCwd` in `path-display.ts`). Falls back to the raw
189
+ * `entry.path` when omitted.
190
+ */
191
+ function scoreFiles(catalog, query, limit, formatPath) {
192
+ const q = query.trim().toLowerCase();
193
+ const fuzzyHits = q.length > 0 ? new Set(new Fzf([...catalog], {
194
+ selector: (f) => f.name,
195
+ casing: "case-insensitive"
196
+ }).find(q).map((r) => r.item)) : null;
197
+ const scored = [];
198
+ for (const file of catalog) {
199
+ const display = formatPath(file);
200
+ const name = file.name.toLowerCase();
201
+ const path = display.toLowerCase();
202
+ if (q.length === 0) {
203
+ scored.push({
204
+ entry: file,
205
+ display,
206
+ rank: 5
207
+ });
208
+ continue;
209
+ }
210
+ if (name === q) {
211
+ scored.push({
212
+ entry: file,
213
+ display,
214
+ rank: 0
215
+ });
216
+ continue;
217
+ }
218
+ if (name.startsWith(q)) {
219
+ scored.push({
220
+ entry: file,
221
+ display,
222
+ rank: 1
223
+ });
224
+ continue;
225
+ }
226
+ if (name.includes(q)) {
227
+ scored.push({
228
+ entry: file,
229
+ display,
230
+ rank: 2
231
+ });
232
+ continue;
233
+ }
234
+ if (path.includes(q)) {
235
+ scored.push({
236
+ entry: file,
237
+ display,
238
+ rank: 3
239
+ });
240
+ continue;
241
+ }
242
+ if (fuzzyHits?.has(file)) {
243
+ scored.push({
244
+ entry: file,
245
+ display,
246
+ rank: 4
247
+ });
248
+ continue;
249
+ }
250
+ }
251
+ scored.sort((a, b) => {
252
+ if (a.rank !== b.rank) return a.rank - b.rank;
253
+ return a.display.localeCompare(b.display);
254
+ });
255
+ return scored.slice(0, limit).map(({ entry, display }) => ({
256
+ id: display,
257
+ label: entry.name,
258
+ description: parentDir(entry.path),
259
+ insertText: `@${display} `,
260
+ data: entry
261
+ }));
262
+ }
263
+ /**
264
+ * Build an `@`-prefixed files completion provider against a *live* catalog.
265
+ *
266
+ * The factory captures a getter so the catalog can be re-scanned (cwd
267
+ * change, manual refresh) without re-instantiating the provider — the
268
+ * App keeps one provider for the lifetime of the prompt block and just
269
+ * mutates the underlying state.
270
+ *
271
+ * `limit` caps the result list so the popover stays bounded on huge
272
+ * monorepos. Filtering is substring on `path` + `name`, case-insensitive,
273
+ * with an fzf-scored basename fuzzy fallback so small typos / omissions
274
+ * (`lop.ts` → `loop.ts`) still surface results; ranking prefers (in
275
+ * order): exact name match, name prefix, name substring, path substring,
276
+ * basename fuzzy match, alphabetical.
277
+ */
278
+ function createFilesCompletionProvider(opts) {
279
+ const limit = opts.limit ?? DEFAULT_RESULT_LIMIT;
280
+ const formatPath = opts.formatPath ?? IDENTITY_FORMAT;
281
+ return {
282
+ id: "files",
283
+ trigger: "@",
284
+ label: "Files",
285
+ suggest(query) {
286
+ if (opts.ensureCatalog) {
287
+ const pending = opts.ensureCatalog();
288
+ if (opts.getCatalog().length === 0) return pending.then((loaded) => scoreFiles(loaded, query, limit, formatPath));
289
+ }
290
+ return scoreFiles(opts.getCatalog(), query, limit, formatPath);
291
+ },
292
+ parseReferences(text, _ctx) {
293
+ const catalog = opts.getCatalog();
294
+ if (catalog.length === 0) return [];
295
+ const byPath = /* @__PURE__ */ new Map();
296
+ for (const file of catalog) byPath.set(formatPath(file), file);
297
+ const refs = [];
298
+ for (const m of text.matchAll(/(^|\s)@(\S+)/g)) {
299
+ const rawCandidate = m[2];
300
+ const stripped = byPath.has(rawCandidate) ? rawCandidate : rawCandidate.replace(/[.,;:)\]}!?]+$/, "");
301
+ const file = byPath.get(stripped);
302
+ if (!file) continue;
303
+ const start = m.index + m[1].length;
304
+ const trimmedLen = 1 + stripped.length;
305
+ refs.push({
306
+ providerId: "files",
307
+ start,
308
+ end: start + trimmedLen,
309
+ itemId: stripped,
310
+ data: file
311
+ });
312
+ }
313
+ return refs;
314
+ }
315
+ };
316
+ }
317
+ /** Return the parent directory of a forward-slashed path, or `''` for root entries. */
318
+ function parentDir(path) {
319
+ const lastSlash = path.lastIndexOf("/");
320
+ return lastSlash <= 0 ? "" : path.slice(0, lastSlash);
321
+ }
322
+ /**
323
+ * Walk a reference list and return the deduplicated set of files in
324
+ * first-mention order — input to "attach these files to the prompt"
325
+ * downstream logic.
326
+ */
327
+ function uniqueFilesFromReferences(references) {
328
+ const out = [];
329
+ const seen = /* @__PURE__ */ new Set();
330
+ for (const ref of references) {
331
+ if (ref.providerId !== "files") continue;
332
+ if (seen.has(ref.itemId)) continue;
333
+ seen.add(ref.itemId);
334
+ out.push(ref.data);
335
+ }
336
+ return out;
337
+ }
338
+ //#endregion
339
+ //#region src/chat/completion-skills.ts
340
+ /** Trigger character — slash-commands convention. */
341
+ const SKILLS_TRIGGER = "/";
342
+ /** Valid skill-name shape (matches the parser): lowercase alnum + dashes. */
343
+ const SKILL_NAME_RX = /^[a-z0-9][a-z0-9-]*$/;
344
+ /**
345
+ * Filter + rank visible skills against a query. Hoisted to a module
346
+ * helper so the sync and async branches of `suggest()` share one
347
+ * implementation (the async branch hits this once the lazy SKILL.md
348
+ * scan resolves; sync branch hits it on every keystroke thereafter).
349
+ */
350
+ function scoreSkills(catalog, query) {
351
+ const q = query.trim().toLowerCase();
352
+ const valid = catalog.filter((skill) => SKILL_NAME_RX.test(skill.name));
353
+ const fuzzyHits = q.length > 0 ? new Set(new Fzf(valid, {
354
+ selector: (s) => s.name,
355
+ casing: "case-insensitive"
356
+ }).find(q).map((r) => r.item)) : null;
357
+ const scored = [];
358
+ for (const skill of valid) {
359
+ const name = skill.name.toLowerCase();
360
+ const desc = skill.description.toLowerCase();
361
+ if (q.length === 0) {
362
+ scored.push({
363
+ skill,
364
+ rank: 4
365
+ });
366
+ continue;
367
+ }
368
+ if (name.startsWith(q)) {
369
+ scored.push({
370
+ skill,
371
+ rank: 0
372
+ });
373
+ continue;
374
+ }
375
+ if (name.includes(q)) {
376
+ scored.push({
377
+ skill,
378
+ rank: 1
379
+ });
380
+ continue;
381
+ }
382
+ if (desc.includes(q)) {
383
+ scored.push({
384
+ skill,
385
+ rank: 2
386
+ });
387
+ continue;
388
+ }
389
+ if (fuzzyHits?.has(skill)) {
390
+ scored.push({
391
+ skill,
392
+ rank: 3
393
+ });
394
+ continue;
395
+ }
396
+ }
397
+ scored.sort((a, b) => {
398
+ if (a.rank !== b.rank) return a.rank - b.rank;
399
+ return a.skill.name.localeCompare(b.skill.name);
400
+ });
401
+ return scored.map(({ skill }) => ({
402
+ id: skill.name,
403
+ label: skill.name,
404
+ description: skill.description,
405
+ insertText: `/${skill.name} `,
406
+ data: skill
407
+ }));
408
+ }
409
+ /**
410
+ * Build a slash-command completion provider against a *live* skills
411
+ * catalog. The factory captures a getter so the catalog can change across
412
+ * renders (toggles, reload) without re-instantiating the provider.
413
+ *
414
+ * Pass `getEnabled` to additionally hide skills the user has toggled off
415
+ * — when undefined, every catalog entry is offered.
416
+ */
417
+ function createSkillsCompletionProvider(opts) {
418
+ const visible = () => {
419
+ const all = opts.getCatalog();
420
+ const enabled = opts.getEnabled?.();
421
+ if (enabled === void 0) return [...all];
422
+ const allow = new Set(enabled);
423
+ return all.filter((s) => allow.has(s.name));
424
+ };
425
+ return {
426
+ id: "skills",
427
+ trigger: "/",
428
+ label: "Skills",
429
+ suggest(query) {
430
+ if (opts.ensureCatalog) {
431
+ const pending = opts.ensureCatalog();
432
+ if (opts.getCatalog().length === 0) return pending.then(() => scoreSkills(visible(), query));
433
+ }
434
+ return scoreSkills(visible(), query);
435
+ },
436
+ parseReferences(text, _ctx) {
437
+ const catalog = visible();
438
+ if (catalog.length === 0) return [];
439
+ const byName = /* @__PURE__ */ new Map();
440
+ for (const skill of catalog) byName.set(skill.name, skill);
441
+ const refs = [];
442
+ for (const m of text.matchAll(/(^|\s)(\/([a-z0-9][a-z0-9-]*))/g)) {
443
+ const name = m[3];
444
+ const skill = byName.get(name);
445
+ if (!skill) continue;
446
+ const start = m.index + m[1].length;
447
+ refs.push({
448
+ providerId: "skills",
449
+ start,
450
+ end: start + m[2].length,
451
+ itemId: skill.name,
452
+ data: skill
453
+ });
454
+ }
455
+ return refs;
456
+ }
457
+ };
458
+ }
459
+ /**
460
+ * Walk a parsed prompt for skill references and return the deduplicated
461
+ * list of skill names — input to `agent.activateSkill(name)` calls on
462
+ * submit.
463
+ */
464
+ function uniqueSkillNamesFromReferences(references) {
465
+ const out = [];
466
+ const seen = /* @__PURE__ */ new Set();
467
+ for (const ref of references) {
468
+ if (ref.providerId !== "skills") continue;
469
+ if (seen.has(ref.itemId)) continue;
470
+ seen.add(ref.itemId);
471
+ out.push(ref.itemId);
472
+ }
473
+ return out;
474
+ }
475
+ //#endregion
476
+ //#region src/chat/edit-approval.ts
477
+ /**
478
+ * Convert a per-hunk approval mask into an `EditOutcome[]`. `true` →
479
+ * `applied`; `false` → `denied` with the supplied reason.
480
+ *
481
+ * Length is `Math.max(mask.length, fallbackLength)` so callers passing a
482
+ * shorter mask still get a fully-populated array — missing entries
483
+ * default to applied, matching the "no decision => keep" convention.
484
+ */
485
+ function maskToOutcomeKinds(mask, fallbackLength, deniedReason = "denied by user") {
486
+ const len = Math.max(mask.length, fallbackLength);
487
+ const out = [];
488
+ for (let i = 0; i < len; i++) {
489
+ const keep = i < mask.length ? mask[i] : true;
490
+ out.push(keep ? { kind: "applied" } : {
491
+ kind: "denied",
492
+ reason: deniedReason
493
+ });
494
+ }
495
+ return out;
496
+ }
497
+ function resolveApprovalForPayload(decision, payload) {
498
+ const total = payload.hunks.length;
499
+ if (decision === "deny") {
500
+ const outcomes = Array.from({ length: total }, () => ({
501
+ kind: "denied",
502
+ reason: "denied by user"
503
+ }));
504
+ return {
505
+ outcomes,
506
+ shouldBlock: true,
507
+ syntheticEvent: {
508
+ ...payload,
509
+ outcomes
510
+ }
511
+ };
512
+ }
513
+ if (typeof decision === "object" && decision.kind === "partial") {
514
+ const outcomes = maskToOutcomeKinds(decision.mask, total);
515
+ return {
516
+ outcomes,
517
+ shouldBlock: !outcomes.some((o) => o.kind === "applied"),
518
+ syntheticEvent: {
519
+ ...payload,
520
+ outcomes
521
+ }
522
+ };
523
+ }
524
+ return {
525
+ outcomes: Array.from({ length: total }, () => ({ kind: "applied" })),
526
+ shouldBlock: false,
527
+ syntheticEvent: null
528
+ };
529
+ }
530
+ /** Sentinel tags used by {@link buildEditOutcomesAnnotation} / parser. */
531
+ const ANNOTATION_OPEN = "<edit-outcomes>";
532
+ const ANNOTATION_CLOSE = "</edit-outcomes>";
533
+ const OUTCOME_KIND_RE = /^applied|denied|skipped|failed$/;
534
+ const OUTCOME_LINE_RE = /^#(\d+) (applied|denied|skipped|failed)(?:: ?(.*))?$/;
535
+ /**
536
+ * Render an `EditOutcome[]` as the wire-format annotation block. Returns
537
+ * the body to APPEND to a tool result; callers join with a leading
538
+ * `\n\n` separator. Idempotent on missing reasons — bare `applied` lines
539
+ * stay terse.
540
+ */
541
+ function buildEditOutcomesAnnotation(outcomes) {
542
+ const lines = [ANNOTATION_OPEN];
543
+ for (let i = 0; i < outcomes.length; i++) {
544
+ const o = outcomes[i];
545
+ if (!OUTCOME_KIND_RE.test(o.kind)) continue;
546
+ const reason = o.reason ? `: ${o.reason}` : "";
547
+ lines.push(`#${i + 1} ${o.kind}${reason}`);
548
+ }
549
+ lines.push(ANNOTATION_CLOSE);
550
+ return lines.join("\n");
551
+ }
552
+ /**
553
+ * Parse an `<edit-outcomes>…</edit-outcomes>` annotation block out of a
554
+ * tool result body. Returns the outcomes keyed by 1-based hunk index, or
555
+ * `null` when the block is missing / malformed.
556
+ *
557
+ * Anchored on the explicit tag pair so the parser doesn't false-positive
558
+ * on natural prose that happens to contain `#1 applied`.
559
+ */
560
+ function parseEditOutcomesFromResult(result) {
561
+ const text = typeof result === "string" ? result : result.filter((b) => b.type === "text").map((b) => b.text).join("\n");
562
+ if (!text) return null;
563
+ const openIdx = text.indexOf(`\n${ANNOTATION_OPEN}\n`);
564
+ const startIdx = openIdx >= 0 ? openIdx + 1 : text.startsWith(`${ANNOTATION_OPEN}\n`) ? 0 : -1;
565
+ if (startIdx < 0) return null;
566
+ const closeNeedle = `\n${ANNOTATION_CLOSE}`;
567
+ const closeIdx = text.indexOf(closeNeedle, startIdx);
568
+ if (closeIdx < 0) return null;
569
+ const body = text.slice(startIdx + 15 + 1, closeIdx);
570
+ const found = [];
571
+ for (const line of body.split("\n")) {
572
+ if (line.length === 0) continue;
573
+ const m = OUTCOME_LINE_RE.exec(line);
574
+ if (!m) return null;
575
+ const idx = Number.parseInt(m[1], 10);
576
+ if (!Number.isFinite(idx) || idx < 1) return null;
577
+ const kind = m[2];
578
+ const reason = m[3]?.trim();
579
+ found.push({
580
+ idx,
581
+ outcome: {
582
+ kind,
583
+ ...reason ? { reason } : {}
584
+ }
585
+ });
586
+ }
587
+ if (found.length === 0) return null;
588
+ const maxIdx = Math.max(...found.map((f) => f.idx));
589
+ const outcomes = Array.from({ length: maxIdx }, () => ({ kind: "applied" }));
590
+ for (const { idx, outcome } of found) outcomes[idx - 1] = outcome;
591
+ return outcomes;
592
+ }
593
+ /**
594
+ * Strip the first `<edit-outcomes>…</edit-outcomes>` block out of a tool
595
+ * result body, returning the surrounding text. Used by the
596
+ * `tool:transform` hook to peel a body-emitted annotation before
597
+ * re-appending the merged (approval ∪ body) version — otherwise the
598
+ * result would carry two annotation blocks and
599
+ * {@link parseEditOutcomesFromResult} would only see the first.
600
+ *
601
+ * Anchored on the same `\n<edit-outcomes>\n` / start-of-string newline
602
+ * shape the parser uses, so prose that incidentally mentions
603
+ * `<edit-outcomes>` (e.g. a model summarizing its own format) isn't
604
+ * mistakenly stripped. Trims a single leading `\n\n` separator when
605
+ * present so successive strips don't leave dangling blank lines.
606
+ * Idempotent on inputs that don't contain a properly-anchored block.
607
+ */
608
+ function stripEditOutcomesAnnotation(text) {
609
+ const newlineNeedle = `\n${ANNOTATION_OPEN}\n`;
610
+ const newlineIdx = text.indexOf(newlineNeedle);
611
+ let openIdx;
612
+ if (newlineIdx >= 0) openIdx = newlineIdx + 1;
613
+ else if (text.startsWith(`${ANNOTATION_OPEN}\n`)) openIdx = 0;
614
+ else return text;
615
+ const closeIdx = text.indexOf(ANNOTATION_CLOSE, openIdx);
616
+ if (closeIdx < 0) return text;
617
+ const blockEnd = closeIdx + 16;
618
+ const sepStart = openIdx >= 2 && text.slice(openIdx - 2, openIdx) === "\n\n" ? openIdx - 2 : openIdx;
619
+ return text.slice(0, sepStart) + text.slice(blockEnd);
620
+ }
621
+ /**
622
+ * Merge body-side outcomes (keyed against the approved subset the tool
623
+ * actually ran on, in subset-position order) into approval-side outcomes
624
+ * (1:1 with the model's ORIGINAL `edits` list, with `denied` entries for
625
+ * every hunk the user dropped).
626
+ *
627
+ * Algorithm: walk the approval array; every `applied` placeholder
628
+ * corresponds to one approved hunk that the body ran. Consume body's
629
+ * outcomes in order against those placeholders. Non-`applied` approval
630
+ * entries (`denied`, `skipped`) stay untouched — they describe gate-
631
+ * level decisions the body never saw.
632
+ *
633
+ * Pure. Returns a fresh array; never mutates either input.
634
+ *
635
+ * Edge cases:
636
+ * - `body` is empty / shorter than the approved count → remaining
637
+ * approval `applied` placeholders stay as `applied` (the body ran
638
+ * happily; absence of a body entry means nothing failed).
639
+ * - `body` longer than approved count → trailing body entries are
640
+ * ignored. Shouldn't happen in practice (body sees the rebound
641
+ * subset), but the guard keeps the merge total-pure.
642
+ */
643
+ function mergeApprovalAndBodyOutcomes(approval, body) {
644
+ if (!body || body.length === 0) return approval.slice();
645
+ const out = [];
646
+ let bi = 0;
647
+ for (const entry of approval) if (entry.kind === "applied" && bi < body.length) {
648
+ out.push(body[bi]);
649
+ bi++;
650
+ } else out.push(entry);
651
+ return out;
652
+ }
653
+ /**
654
+ * Rewrite a `multi_edit` body header so the totals reflect the model's
655
+ * ORIGINAL edit list (the merged outcomes count) instead of the subset
656
+ * the body actually saw after gate rebinding. Without this, a partially
657
+ * approved call surfaces a misleading `applied 2 of 2 edits` (subset
658
+ * counts) on the wire even when the original was `applied 2 of 3`.
659
+ *
660
+ * Three body-side shapes are handled (matching `multi_edit`'s emit):
661
+ * 1. `Edited <path>: applied N edits (R replacements).`
662
+ * 2. `Edited <path>: applied N of M edits (R replacements).`
663
+ * 3. `multi_edit error: no edits applied to <path> (M attempted).`
664
+ *
665
+ * The replacements count is preserved verbatim — it's a body-side stat
666
+ * the chat layer can't recompute. When the first line doesn't look like
667
+ * any of the three shapes (e.g. an unrelated error preamble bubbled up),
668
+ * the text is returned unchanged.
669
+ */
670
+ function rewriteMultiEditHeader(text, merged, path) {
671
+ const newlineIdx = text.indexOf("\n");
672
+ const firstLine = newlineIdx < 0 ? text : text.slice(0, newlineIdx);
673
+ const rest = newlineIdx < 0 ? "" : text.slice(newlineIdx);
674
+ const successMatch = firstLine.match(/^Edited .+: applied \d+(?: of \d+)? edits? \((\d+) replacement/);
675
+ const isFailedShape = firstLine.startsWith("multi_edit error: no edits applied to ") && firstLine.endsWith(" attempted).");
676
+ if (!successMatch && !isFailedShape) return text;
677
+ const replacements = successMatch ? Number.parseInt(successMatch[1], 10) || 0 : 0;
678
+ const applied = summarizeOutcomes(merged).applied;
679
+ const total = merged.length;
680
+ let newHeader;
681
+ if (applied === total) newHeader = `Edited ${path}: applied ${total} edit${total === 1 ? "" : "s"} (${replacements} replacement${replacements === 1 ? "" : "s"}).`;
682
+ else if (applied > 0) newHeader = `Edited ${path}: applied ${applied} of ${total} edits (${replacements} replacement${replacements === 1 ? "" : "s"}).`;
683
+ else newHeader = `multi_edit error: no edits applied to ${path} (${total} attempted).`;
684
+ return newHeader + rest;
685
+ }
686
+ /**
687
+ * Aggregate counts for the transcript's summary badge (`3 applied · 1
688
+ * denied · 1 skipped`). Exported so renderers don't reimplement the
689
+ * tally. Pure / O(n).
690
+ */
691
+ function summarizeOutcomes(outcomes) {
692
+ const counts = {
693
+ applied: 0,
694
+ denied: 0,
695
+ skipped: 0,
696
+ failed: 0,
697
+ pending: 0
698
+ };
699
+ if (!outcomes) return {
700
+ ...counts,
701
+ total: 0
702
+ };
703
+ for (const o of outcomes) counts[o.kind] += 1;
704
+ return {
705
+ ...counts,
706
+ total: outcomes.length
707
+ };
708
+ }
709
+ //#endregion
710
+ //#region src/chat/edit-diff.ts
711
+ function extractEditPayload(name, input, priorContent) {
712
+ const path = input.path;
713
+ if (typeof path !== "string" || path === "") return void 0;
714
+ if (name === "edit") {
715
+ const oldString = input.old_string;
716
+ const newString = input.new_string;
717
+ if (typeof oldString !== "string" || typeof newString !== "string") return void 0;
718
+ return {
719
+ tool: "edit",
720
+ path,
721
+ hunks: [{
722
+ oldString,
723
+ newString,
724
+ ...input.replace_all === true ? { replaceAll: true } : {}
725
+ }],
726
+ ...priorContent !== void 0 ? { priorContent } : {}
727
+ };
728
+ }
729
+ if (name === "multi_edit") {
730
+ const steps = input.edits;
731
+ if (!Array.isArray(steps) || steps.length === 0) return void 0;
732
+ const hunks = [];
733
+ for (const raw of steps) {
734
+ if (typeof raw?.old_string !== "string" || typeof raw?.new_string !== "string") return void 0;
735
+ hunks.push({
736
+ oldString: raw.old_string,
737
+ newString: raw.new_string,
738
+ ...raw.replace_all === true ? { replaceAll: true } : {}
739
+ });
740
+ }
741
+ return {
742
+ tool: "multi_edit",
743
+ path,
744
+ hunks,
745
+ ...priorContent !== void 0 ? { priorContent } : {}
746
+ };
747
+ }
748
+ if (name === "write_file") {
749
+ const content = input.content;
750
+ if (typeof content !== "string") return void 0;
751
+ return {
752
+ tool: "write_file",
753
+ path,
754
+ hunks: [{
755
+ oldString: priorContent ?? "",
756
+ newString: content
757
+ }],
758
+ ...priorContent !== void 0 ? { priorContent } : {}
759
+ };
760
+ }
761
+ }
762
+ function computeLineDiff(oldString, newString) {
763
+ const oldLines = splitLines(oldString);
764
+ const newLines = splitLines(newString);
765
+ const n = oldLines.length;
766
+ const m = newLines.length;
767
+ const lcs = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }).fill(0));
768
+ for (let i = 0; i < n; i++) for (let j = 0; j < m; j++) lcs[i + 1][j + 1] = oldLines[i] === newLines[j] ? lcs[i][j] + 1 : Math.max(lcs[i][j + 1], lcs[i + 1][j]);
769
+ const out = [];
770
+ let i = n;
771
+ let j = m;
772
+ while (i > 0 || j > 0) {
773
+ if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
774
+ out.push({
775
+ op: "context",
776
+ text: oldLines[i - 1]
777
+ });
778
+ i--;
779
+ j--;
780
+ continue;
781
+ }
782
+ if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
783
+ out.push({
784
+ op: "add",
785
+ text: newLines[j - 1]
786
+ });
787
+ j--;
788
+ continue;
789
+ }
790
+ out.push({
791
+ op: "remove",
792
+ text: oldLines[i - 1]
793
+ });
794
+ i--;
795
+ }
796
+ out.reverse();
797
+ return out;
798
+ }
799
+ /**
800
+ * Split a string into lines preserving empty lines but dropping the
801
+ * implicit trailing `""` produced by a final `\n`. Exported only for
802
+ * its tests — callers should use `computeLineDiff`.
803
+ */
804
+ function splitLines(s) {
805
+ if (s === "") return [];
806
+ const parts = s.split("\n");
807
+ if (parts[parts.length - 1] === "") parts.pop();
808
+ return parts;
809
+ }
810
+ function computeInlineDiff(oldLine, newLine) {
811
+ const oldTokens = tokenize(oldLine);
812
+ const newTokens = tokenize(newLine);
813
+ const n = oldTokens.length;
814
+ const m = newTokens.length;
815
+ const lcs = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }).fill(0));
816
+ for (let i = 0; i < n; i++) for (let j = 0; j < m; j++) lcs[i + 1][j + 1] = oldTokens[i] === newTokens[j] ? lcs[i][j] + 1 : Math.max(lcs[i][j + 1], lcs[i + 1][j]);
817
+ const oldSegments = [];
818
+ const newSegments = [];
819
+ let i = n;
820
+ let j = m;
821
+ while (i > 0 || j > 0) {
822
+ if (i > 0 && j > 0 && oldTokens[i - 1] === newTokens[j - 1]) {
823
+ pushSegment(oldSegments, {
824
+ text: oldTokens[i - 1],
825
+ changed: false
826
+ });
827
+ pushSegment(newSegments, {
828
+ text: newTokens[j - 1],
829
+ changed: false
830
+ });
831
+ i--;
832
+ j--;
833
+ continue;
834
+ }
835
+ if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
836
+ pushSegment(newSegments, {
837
+ text: newTokens[j - 1],
838
+ changed: true
839
+ });
840
+ j--;
841
+ continue;
842
+ }
843
+ pushSegment(oldSegments, {
844
+ text: oldTokens[i - 1],
845
+ changed: true
846
+ });
847
+ i--;
848
+ }
849
+ oldSegments.reverse();
850
+ newSegments.reverse();
851
+ return {
852
+ oldSegments,
853
+ newSegments
854
+ };
855
+ }
856
+ /**
857
+ * Coalesce adjacent same-state segments so the renderer emits one
858
+ * `<span>` per run instead of one per token — keeps the React tree
859
+ * shallow on dense lines without changing the visual output.
860
+ *
861
+ * Walking direction is reverse (we push during the reverse walk, then
862
+ * the caller reverses the array), so we coalesce against the *tail*.
863
+ */
864
+ function pushSegment(buf, seg) {
865
+ const tail = buf[buf.length - 1];
866
+ if (tail && tail.changed === seg.changed) tail.text = seg.text + tail.text;
867
+ else buf.push(seg);
868
+ }
869
+ /**
870
+ * Tokenize on word / non-word boundaries. Each run of `\w+` is one
871
+ * token; each run of `\W+` (whitespace, punctuation) is another. This
872
+ * gives the right granularity for renames (`oldName` → `newName`) and
873
+ * for symbol swaps (`+ → -`) without exploding into per-char segments.
874
+ *
875
+ * Exported only for its tests.
876
+ */
877
+ function tokenize(s) {
878
+ if (s === "") return [];
879
+ const out = [];
880
+ for (const match of s.matchAll(/\w+|\W+/g)) out.push(match[0]);
881
+ return out;
882
+ }
883
+ /**
884
+ * Apply the payload's hunks against `priorContent` and return the
885
+ * resulting file body. Mirrors the agent's tool-side semantics:
886
+ * - `replaceAll === true` → `String.replaceAll`
887
+ * - otherwise → first-occurrence `String.replace`
888
+ *
889
+ * Hunks are applied in order — a `multi_edit` later hunk operates on
890
+ * the output of the earlier ones, just like the actual tool.
891
+ */
892
+ function applyEditPayload(payload, priorContent) {
893
+ let out = priorContent;
894
+ for (const hunk of payload.hunks) out = hunk.replaceAll ? out.replaceAll(hunk.oldString, hunk.newString) : out.replace(hunk.oldString, hunk.newString);
895
+ return out;
896
+ }
897
+ /**
898
+ * Like `buildUnifiedDiff` but operating against the full file content
899
+ * so the diff carries *real* file line numbers and configurable
900
+ * surrounding context.
901
+ *
902
+ * Strategy:
903
+ * 1. Apply the payload to `priorContent` → `newContent`.
904
+ * 2. Run `computeLineDiff` over the whole file.
905
+ * 3. Group non-context ops into hunks, padding each with up to
906
+ * `contextLines` of context above and below. Adjacent hunks
907
+ * whose context regions touch are merged so we don't emit two
908
+ * `@@` headers separated by zero context lines.
909
+ *
910
+ * The output line numbers in the `@@` header are 1-based and reflect
911
+ * the change's position in the actual file — what the user expects
912
+ * when reading a diff alongside their editor.
913
+ *
914
+ * For `write_file` creating a new file (priorContent === ''), this
915
+ * falls back to the same `--- /dev/null` convention as
916
+ * `buildUnifiedDiff`.
917
+ */
918
+ function buildContextualDiff(payload, priorContent, contextLines = 3) {
919
+ const newContent = applyEditPayload(payload, priorContent);
920
+ const isNewFile = priorContent === "";
921
+ const ops = computeLineDiff(priorContent, newContent);
922
+ const oldLineFor = [];
923
+ const newLineFor = [];
924
+ let ol = 1;
925
+ let nl = 1;
926
+ for (const op of ops) {
927
+ oldLineFor.push(ol);
928
+ newLineFor.push(nl);
929
+ if (op.op !== "add") ol++;
930
+ if (op.op !== "remove") nl++;
931
+ }
932
+ const hunks = [];
933
+ for (let i = 0; i < ops.length; i++) {
934
+ if (ops[i].op === "context") continue;
935
+ const start = Math.max(0, i - contextLines);
936
+ const end = Math.min(ops.length - 1, i + contextLines);
937
+ const last = hunks[hunks.length - 1];
938
+ if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end);
939
+ else hunks.push([start, end]);
940
+ }
941
+ if (hunks.length === 0) return "";
942
+ const parts = [];
943
+ parts.push(isNewFile ? "--- /dev/null" : `--- a/${payload.path}`);
944
+ parts.push(`+++ b/${payload.path}`);
945
+ for (const [start, end] of hunks) {
946
+ const slice = ops.slice(start, end + 1);
947
+ const oldCount = slice.filter((l) => l.op !== "add").length;
948
+ const newCount = slice.filter((l) => l.op !== "remove").length;
949
+ const oldStart = oldCount === 0 ? Math.max(0, oldLineFor[start] - 1) : oldLineFor[start];
950
+ const newStart = newCount === 0 ? Math.max(0, newLineFor[start] - 1) : newLineFor[start];
951
+ parts.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
952
+ for (const line of slice) {
953
+ const prefix = line.op === "add" ? "+" : line.op === "remove" ? "-" : " ";
954
+ parts.push(`${prefix}${line.text}`);
955
+ }
956
+ }
957
+ return `${parts.join("\n")}\n`;
958
+ }
959
+ /**
960
+ * Build a per-hunk digest used by the compact diff view.
961
+ *
962
+ * Strategy:
963
+ * - When `priorContent` is present and the payload describes a real
964
+ * file transformation, compute the contextual diff once, then walk
965
+ * the LCS ops splitting at runs of `add` / `remove` to anchor each
966
+ * summary entry to the **real** file line. This guarantees the
967
+ * summary's `L<n>` matches what the user sees in their editor.
968
+ * - Otherwise, fall back to per-hunk LCS over the (oldString,
969
+ * newString) snippet pair. Line numbers are absent because the
970
+ * snippet has no file position.
971
+ */
972
+ function summarizeEditPayload(payload) {
973
+ const prior = payload.priorContent;
974
+ if (prior !== void 0) return summarizeOpsByHunk(computeLineDiff(prior, applyEditPayload(payload, prior)));
975
+ const hunks = [];
976
+ let totalAdded = 0;
977
+ let totalRemoved = 0;
978
+ for (const hunk of payload.hunks) {
979
+ const ops = computeLineDiff(hunk.oldString, hunk.newString);
980
+ let added = 0;
981
+ let removed = 0;
982
+ let firstOld;
983
+ let firstNew;
984
+ for (const op of ops) if (op.op === "add") {
985
+ added++;
986
+ if (firstNew === void 0) firstNew = op.text;
987
+ } else if (op.op === "remove") {
988
+ removed++;
989
+ if (firstOld === void 0) firstOld = op.text;
990
+ }
991
+ totalAdded += added;
992
+ totalRemoved += removed;
993
+ hunks.push({
994
+ added,
995
+ removed,
996
+ ...firstOld !== void 0 ? { firstOld } : {},
997
+ ...firstNew !== void 0 ? { firstNew } : {}
998
+ });
999
+ }
1000
+ return {
1001
+ totalAdded,
1002
+ totalRemoved,
1003
+ hunks
1004
+ };
1005
+ }
1006
+ /**
1007
+ * Walk an LCS op stream and emit one summary entry per *run* of
1008
+ * non-context ops, with the new-file line number where each run
1009
+ * starts. Adjacent add/remove ops collapse into the same entry —
1010
+ * matches git's hunk grouping at zero context.
1011
+ */
1012
+ function summarizeOpsByHunk(ops) {
1013
+ const hunks = [];
1014
+ let totalAdded = 0;
1015
+ let totalRemoved = 0;
1016
+ let nl = 1;
1017
+ let i = 0;
1018
+ while (i < ops.length) {
1019
+ if (ops[i].op === "context") {
1020
+ nl++;
1021
+ i++;
1022
+ continue;
1023
+ }
1024
+ const runStartLine = nl;
1025
+ let added = 0;
1026
+ let removed = 0;
1027
+ let firstOld;
1028
+ let firstNew;
1029
+ while (i < ops.length && ops[i].op !== "context") {
1030
+ const cur = ops[i];
1031
+ if (cur.op === "add") {
1032
+ added++;
1033
+ if (firstNew === void 0) firstNew = cur.text;
1034
+ nl++;
1035
+ } else {
1036
+ removed++;
1037
+ if (firstOld === void 0) firstOld = cur.text;
1038
+ }
1039
+ i++;
1040
+ }
1041
+ totalAdded += added;
1042
+ totalRemoved += removed;
1043
+ hunks.push({
1044
+ line: runStartLine,
1045
+ added,
1046
+ removed,
1047
+ ...firstOld !== void 0 ? { firstOld } : {},
1048
+ ...firstNew !== void 0 ? { firstNew } : {}
1049
+ });
1050
+ }
1051
+ return {
1052
+ totalAdded,
1053
+ totalRemoved,
1054
+ hunks
1055
+ };
1056
+ }
1057
+ function previewEditPayload(payload, priorContent, contextLines = 3) {
1058
+ const resolution = [];
1059
+ const resolvedHunks = [];
1060
+ const perHunkDiff = [];
1061
+ let running = priorContent;
1062
+ for (const hunk of payload.hunks) {
1063
+ if (hunk.oldString === "" || hunk.oldString === running) {
1064
+ resolution.push({
1065
+ resolved: true,
1066
+ via: "exact",
1067
+ occurrences: 1
1068
+ });
1069
+ resolvedHunks.push(hunk);
1070
+ perHunkDiff.push(buildContextualDiff({
1071
+ ...payload,
1072
+ hunks: [hunk]
1073
+ }, running, contextLines));
1074
+ running = hunk.newString;
1075
+ continue;
1076
+ }
1077
+ const match = resolveOldString(running, hunk.oldString);
1078
+ if (!match) {
1079
+ resolution.push({ resolved: false });
1080
+ resolvedHunks.push(hunk);
1081
+ perHunkDiff.push("");
1082
+ continue;
1083
+ }
1084
+ const ambiguous = match.occurrences > 1 && !hunk.replaceAll;
1085
+ const styledNew = styleReplacementForVia(hunk.newString, match.via, match.actual);
1086
+ const resolvedHunk = {
1087
+ oldString: match.actual,
1088
+ newString: styledNew,
1089
+ ...hunk.replaceAll ? { replaceAll: true } : {}
1090
+ };
1091
+ resolution.push({
1092
+ resolved: !ambiguous,
1093
+ via: match.via,
1094
+ occurrences: match.occurrences,
1095
+ ...ambiguous ? { ambiguous: true } : {}
1096
+ });
1097
+ resolvedHunks.push(resolvedHunk);
1098
+ perHunkDiff.push(ambiguous ? "" : buildContextualDiff({
1099
+ ...payload,
1100
+ hunks: [resolvedHunk]
1101
+ }, running, contextLines));
1102
+ if (!ambiguous) running = hunk.replaceAll ? running.replaceAll(match.actual, styledNew) : running.replace(match.actual, styledNew);
1103
+ }
1104
+ const resolvedPayload = {
1105
+ ...payload,
1106
+ hunks: resolvedHunks
1107
+ };
1108
+ const applicableHunks = resolvedHunks.filter((_, i) => resolution[i].resolved);
1109
+ return {
1110
+ diffText: applicableHunks.length === 0 ? "" : buildContextualDiff({
1111
+ ...payload,
1112
+ hunks: applicableHunks
1113
+ }, priorContent, contextLines),
1114
+ resolution,
1115
+ perHunkDiff,
1116
+ resolvedPayload
1117
+ };
1118
+ }
1119
+ function buildUnifiedDiff(payload) {
1120
+ const parts = [];
1121
+ const isNewFile = payload.tool === "write_file" && payload.hunks[0]?.oldString === "";
1122
+ parts.push(isNewFile ? `--- /dev/null` : `--- a/${payload.path}`);
1123
+ parts.push(`+++ b/${payload.path}`);
1124
+ for (const hunk of payload.hunks) {
1125
+ const lines = computeLineDiff(hunk.oldString, hunk.newString);
1126
+ const oldCount = lines.filter((l) => l.op !== "add").length;
1127
+ const newCount = lines.filter((l) => l.op !== "remove").length;
1128
+ const oldStart = oldCount === 0 ? 0 : 1;
1129
+ const newStart = newCount === 0 ? 0 : 1;
1130
+ parts.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
1131
+ for (const line of lines) {
1132
+ const prefix = line.op === "add" ? "+" : line.op === "remove" ? "-" : " ";
1133
+ parts.push(`${prefix}${line.text}`);
1134
+ }
1135
+ }
1136
+ return `${parts.join("\n")}\n`;
1137
+ }
1138
+ const FILETYPE_BY_EXT = {
1139
+ ts: "typescript",
1140
+ mts: "typescript",
1141
+ cts: "typescript",
1142
+ tsx: "tsx",
1143
+ js: "javascript",
1144
+ mjs: "javascript",
1145
+ cjs: "javascript",
1146
+ jsx: "jsx",
1147
+ py: "python",
1148
+ pyi: "python",
1149
+ rs: "rust",
1150
+ go: "go",
1151
+ json: "json",
1152
+ jsonc: "json",
1153
+ sh: "bash",
1154
+ bash: "bash",
1155
+ zsh: "bash",
1156
+ yaml: "yaml",
1157
+ yml: "yaml",
1158
+ html: "html",
1159
+ htm: "html",
1160
+ css: "css",
1161
+ md: "markdown",
1162
+ markdown: "markdown"
1163
+ };
1164
+ function filetypeFromPath(path) {
1165
+ const cleaned = path.split(/[?#]/, 1)[0];
1166
+ const lastDot = cleaned.lastIndexOf(".");
1167
+ if (lastDot === -1 || lastDot === cleaned.length - 1) return void 0;
1168
+ return FILETYPE_BY_EXT[cleaned.slice(lastDot + 1).toLowerCase()];
1169
+ }
1170
+ //#endregion
1171
+ //#region src/chat/turn-selection.ts
1172
+ /** Tools whose `tool-result` event is suppressed when `showEditDiffs` is on. */
1173
+ const EDIT_TOOL_NAMES = new Set([
1174
+ "edit",
1175
+ "multi_edit",
1176
+ "write_file"
1177
+ ]);
1178
+ /**
1179
+ * Recognize a tool-result body as carrying NON-success information so the
1180
+ * renderer doesn't suppress it under `showEditDiffs`. Three categories:
1181
+ *
1182
+ * - `edit` → "Edit error: …"
1183
+ * - `write_file` permission errors wrapped by the loop → "Tool failed: …"
1184
+ * - `multi_edit` → legacy single-line error `multi_edit error: …`, OR
1185
+ * a result carrying an `<edit-outcomes>…</edit-outcomes>` annotation
1186
+ * block. The TUI only appends the annotation when at least one hunk
1187
+ * was NOT applied, so its mere presence is the signal — the result
1188
+ * body needs to stay visible next to the diff so the user can read
1189
+ * denial / skip / failure reasons longer than the per-hunk badge.
1190
+ * - Fully-denied gate emit (`[fully denied] <edit-outcomes>…`) likewise
1191
+ * stays visible.
1192
+ *
1193
+ * Exported for unit-testability of the visibility matrix.
1194
+ */
1195
+ function isEditErrorResult(text) {
1196
+ if (text.startsWith("Edit error:")) return true;
1197
+ if (text.startsWith("Tool failed:")) return true;
1198
+ if (text.startsWith("multi_edit error:")) return true;
1199
+ if (text.startsWith("[fully denied]")) return true;
1200
+ if (text.includes("\n<edit-outcomes>\n") || text.startsWith("<edit-outcomes>\n")) return true;
1201
+ return false;
1202
+ }
1203
+ /**
1204
+ * Per-event visibility — filters honor user toggles and the
1205
+ * `hideSubagentOutput` setting. When subagent output is hidden:
1206
+ * - Child-agent events are filtered down to the `spawn-start` /
1207
+ * `spawn-end` markers so the user still sees "🌱 working… 🌳 done".
1208
+ * - The parent's `tool-result` for `spawn` is hidden too. Its body
1209
+ * duplicates `spawn-end`'s stats line *and* the parent's next
1210
+ * markdown turn; showing it again produces an extra
1211
+ * `┃ [sub-agent child-1] Completed …` block users just want gone.
1212
+ *
1213
+ * Renderer-agnostic — returns plain `boolean` so TUI / GUI consumers
1214
+ * can filter events identically.
1215
+ */
1216
+ function isVisible(event, settings) {
1217
+ if (settings.hideSubagentOutput) {
1218
+ if ((event.depth ?? 0) > 0) return event.kind === "spawn-start" || event.kind === "spawn-end";
1219
+ if (event.kind === "tool-result" && event.tool === "spawn") return false;
1220
+ }
1221
+ if (settings.showEditDiffs && event.kind === "tool-result" && event.tool && EDIT_TOOL_NAMES.has(event.tool) && !isEditErrorResult(event.text)) return false;
1222
+ switch (event.kind) {
1223
+ case "thinking": return settings.showThinking;
1224
+ case "tool": return settings.toolCallDisplay !== "hidden";
1225
+ case "tool-result": return settings.showToolResults;
1226
+ default: return true;
1227
+ }
1228
+ }
1229
+ /**
1230
+ * Build the `resultTurnId → owningAssistantTurnId` map used by the select-
1231
+ * turn mode to coalesce a tool-call's surrounding turns into ONE navigation
1232
+ * stop.
1233
+ *
1234
+ * Protocol shape: every `tool_call` block in an assistant turn is closed by
1235
+ * a matching `tool_result` block in the *next* user turn (the agent loop's
1236
+ * history validator depends on this). When the next user turn's only events
1237
+ * are `tool-result`s — i.e. it's pure plumbing for the prior assistant
1238
+ * turn — we map it back to that assistant turn here. The select-turn nav
1239
+ * index ({@link selectableTurnIds}) skips owned turns, and the renderer's
1240
+ * highlight gate ({@link isTurnHighlighted}) extends the selection accent
1241
+ * from the assistant turn to the events of any turn it owns. Net effect:
1242
+ *
1243
+ * - Navigation never lands the cursor on a result-only turn whose own
1244
+ * events may be hidden by `showToolResults: false` — the cursor
1245
+ * wouldn't be visible.
1246
+ * - Selecting an assistant turn highlights the call AND its result as
1247
+ * one unit, matching the user's mental model of "one message".
1248
+ *
1249
+ * Owner-lookup is conservative: result-only turns with no matching prior
1250
+ * assistant turn (orphaned — usually because the parent was deleted)
1251
+ * stay selectable so the user can act on them via the turn-details modal.
1252
+ *
1253
+ * Subagent (`childId` set) events are ignored — they live in a separate
1254
+ * conversation tree.
1255
+ */
1256
+ function turnSelectionOwnership(events) {
1257
+ const orderedTurnIds = [];
1258
+ const eventKindsByTurn = /* @__PURE__ */ new Map();
1259
+ for (const e of events) {
1260
+ if (!e.turnId) continue;
1261
+ if (e.childId) continue;
1262
+ if (!eventKindsByTurn.has(e.turnId)) {
1263
+ orderedTurnIds.push(e.turnId);
1264
+ eventKindsByTurn.set(e.turnId, []);
1265
+ }
1266
+ eventKindsByTurn.get(e.turnId).push(e.kind);
1267
+ }
1268
+ const ownership = /* @__PURE__ */ new Map();
1269
+ let lastToolEmitterTurnId = null;
1270
+ for (const tid of orderedTurnIds) {
1271
+ const kinds = eventKindsByTurn.get(tid);
1272
+ if (kinds.length > 0 && kinds.every((k) => k === "tool-result")) {
1273
+ if (lastToolEmitterTurnId) ownership.set(tid, lastToolEmitterTurnId);
1274
+ continue;
1275
+ }
1276
+ if (kinds.includes("tool")) lastToolEmitterTurnId = tid;
1277
+ }
1278
+ return ownership;
1279
+ }
1280
+ /**
1281
+ * Render-time check: should `event` paint with the selection accent?
1282
+ *
1283
+ * `true` when the event's own turn is selected, OR when the selected turn
1284
+ * `owns` the event's turn via {@link turnSelectionOwnership} (the call and
1285
+ * its tool-result rows highlight together). `false` when nothing is
1286
+ * selected or the relationship doesn't apply.
1287
+ *
1288
+ * Pure. Renderer-agnostic — the TUI's `<Transcript>` uses it; a GUI's
1289
+ * equivalent walks the same rule.
1290
+ */
1291
+ function isTurnHighlighted(event, selectedTurnId, ownership) {
1292
+ if (selectedTurnId === null || !event.turnId) return false;
1293
+ if (event.turnId === selectedTurnId) return true;
1294
+ return ownership.get(event.turnId) === selectedTurnId;
1295
+ }
1296
+ /**
1297
+ * Deduplicated, in-order list of **parent-conversation** turn ids that appear
1298
+ * in a rendered transcript — the navigation index for the TUI's select-turn
1299
+ * mode. Three classes of turns are deliberately skipped:
1300
+ *
1301
+ * - **Subagent turns** (`childId` set). Nested execution detail; the
1302
+ * user's mental model of a "message" is the conversational exchange,
1303
+ * not each spawn turn. Also filtered out by `isVisible` under
1304
+ * `hideSubagentOutput: true` — selecting them would highlight nothing.
1305
+ * - **Result-only turns** — see {@link turnSelectionOwnership}. These get
1306
+ * coalesced into the assistant turn that emitted their tool_calls.
1307
+ * - **Settings-hidden turns** (when `settings` is supplied). A turn whose
1308
+ * every event fails {@link isVisible} would render no rows — landing
1309
+ * the cursor there hides it from the user entirely. The check is opt-
1310
+ * in so SDK callers without a Settings object keep the legacy
1311
+ * "everything visible" behavior.
1312
+ *
1313
+ * Synthetic events (separator, spawn-start, spawn-end) have no `turnId` and
1314
+ * are skipped naturally.
1315
+ */
1316
+ function selectableTurnIds(events, settings) {
1317
+ const ownership = turnSelectionOwnership(events);
1318
+ const visibleCount = settings ? /* @__PURE__ */ new Map() : null;
1319
+ if (settings && visibleCount) for (const e of events) {
1320
+ if (!e.turnId || e.childId) continue;
1321
+ if (!isVisible(e, settings)) continue;
1322
+ visibleCount.set(e.turnId, (visibleCount.get(e.turnId) ?? 0) + 1);
1323
+ }
1324
+ const seen = /* @__PURE__ */ new Set();
1325
+ const ordered = [];
1326
+ for (const e of events) {
1327
+ if (!e.turnId) continue;
1328
+ if (e.childId) continue;
1329
+ if (seen.has(e.turnId)) continue;
1330
+ if (ownership.has(e.turnId)) continue;
1331
+ if (visibleCount && (visibleCount.get(e.turnId) ?? 0) === 0) continue;
1332
+ seen.add(e.turnId);
1333
+ ordered.push(e.turnId);
1334
+ }
1335
+ return ordered;
1336
+ }
1337
+ //#endregion
1338
+ //#region src/chat/model-catalog.ts
1339
+ /**
1340
+ * Build the unified catalog from a list of available providers.
1341
+ *
1342
+ * Provider order is preserved (callers typically pass the picker order
1343
+ * — alphabetical, auth-detection order, etc.); model order inside each
1344
+ * provider matches whatever `modelsFor` returns. The current selection
1345
+ * (when set) is bubbled to the top of its provider's section so it
1346
+ * shows first without disturbing relative ordering elsewhere.
1347
+ *
1348
+ * `modelsFor` is injected (not imported from `./providers`) so the same
1349
+ * helper works with hosts that supply their own model resolver via
1350
+ * `ResolvedConfig.modelsFor`.
1351
+ */
1352
+ function buildModelCatalog(opts) {
1353
+ const entries = [];
1354
+ for (const provider of opts.providers) {
1355
+ const models = opts.modelsFor(provider.key);
1356
+ if (models.length === 0) continue;
1357
+ let ordered = models;
1358
+ if (opts.current?.providerKey === provider.key) {
1359
+ const idx = models.findIndex((m) => m.id === opts.current?.modelId);
1360
+ if (idx > 0) {
1361
+ const next = models.slice();
1362
+ const [active] = next.splice(idx, 1);
1363
+ next.unshift(active);
1364
+ ordered = next;
1365
+ }
1366
+ }
1367
+ for (const model of ordered) entries.push({
1368
+ providerKey: provider.key,
1369
+ providerLabel: provider.label,
1370
+ model,
1371
+ searchCorpus: buildSearchCorpus(provider, model)
1372
+ });
1373
+ }
1374
+ return entries;
1375
+ }
1376
+ /**
1377
+ * Filter `catalog` by a user query. Empty / whitespace-only queries
1378
+ * pass everything through unchanged (`O(1)` short-circuit). Multi-term
1379
+ * queries (space-separated) require EVERY term to appear somewhere in
1380
+ * the entry's search corpus — so `"claude opus"` matches `claude-opus-4`
1381
+ * regardless of how the words are interleaved with provider names.
1382
+ *
1383
+ * Match is case-insensitive (the corpus is pre-lowercased; the query
1384
+ * is lowercased once per call).
1385
+ */
1386
+ function filterModelCatalog(catalog, query) {
1387
+ const trimmed = query.trim().toLowerCase();
1388
+ if (!trimmed) return catalog.slice();
1389
+ const terms = trimmed.split(/\s+/);
1390
+ return catalog.filter((entry) => terms.every((t) => entry.searchCorpus.includes(t)));
1391
+ }
1392
+ /**
1393
+ * Find a catalog entry's index by its `{providerKey, modelId}` tuple.
1394
+ * Returns `-1` when not present. Useful when re-rendering the picker
1395
+ * (a query just narrowed the list, where did the selection land?).
1396
+ */
1397
+ function indexOfEntry(catalog, target) {
1398
+ if (!target) return -1;
1399
+ return catalog.findIndex((e) => e.providerKey === target.providerKey && e.model.id === target.modelId);
1400
+ }
1401
+ function buildSearchCorpus(provider, model) {
1402
+ return [
1403
+ provider.key,
1404
+ provider.label,
1405
+ model.id,
1406
+ model.name ?? "",
1407
+ model.provider ?? ""
1408
+ ].join(" ").toLowerCase();
1409
+ }
1410
+ //#endregion
1411
+ //#region src/chat/prompt-segments.ts
1412
+ /**
1413
+ * Split a prompt buffer into word-sized atomic segments suitable for a
1414
+ * flex-row + flex-wrap renderer (TUI) or a `display: inline` flow with
1415
+ * inline-block chips (GUI). Each chip becomes one segment (atomic —
1416
+ * never broken across rows); each plain run is split into "word +
1417
+ * trailing space" units so wraps land at clean word boundaries.
1418
+ *
1419
+ * Robust to:
1420
+ * - Overlapping refs — sorted by start; later refs that overlap are
1421
+ * dropped via the first-wins rule.
1422
+ * - Out-of-bounds refs — dropped entirely when `end > text.length` or
1423
+ * `start >= text.length`. Partial clipping would silently truncate
1424
+ * a chip's label; the caller is in a better position to surface the
1425
+ * mismatch (typically a stale `refs` array referencing a previous text).
1426
+ * - Whitespace-only plain runs — emitted as their own plain segment
1427
+ * so chip-adjacent-to-chip cases keep the original spacing.
1428
+ *
1429
+ * Word splitter rationale: `\S+\s*` keeps trailing whitespace attached
1430
+ * to its preceding word so wrap boundaries land between words (cleanly).
1431
+ * A leading-whitespace-only segment is captured by `\s+` so we don't
1432
+ * drop it entirely when the plain run starts with a space.
1433
+ */
1434
+ function splitPromptSegments(text, refs) {
1435
+ const sorted = [...refs].filter((r) => r.end > r.start && r.start < text.length && r.end <= text.length).sort((a, b) => a.start - b.start);
1436
+ const out = [];
1437
+ let cursor = 0;
1438
+ for (const ref of sorted) {
1439
+ if (ref.start < cursor) continue;
1440
+ if (ref.start > cursor) {
1441
+ const matches = text.slice(cursor, ref.start).match(/\S+\s*|\s+/g) ?? [];
1442
+ for (const m of matches) out.push({
1443
+ kind: "plain",
1444
+ text: m
1445
+ });
1446
+ }
1447
+ out.push({
1448
+ kind: "chip",
1449
+ text: text.slice(ref.start, ref.end),
1450
+ providerId: ref.providerId
1451
+ });
1452
+ cursor = ref.end;
1453
+ }
1454
+ if (cursor < text.length) {
1455
+ const matches = text.slice(cursor).match(/\S+\s*|\s+/g) ?? [];
1456
+ for (const m of matches) out.push({
1457
+ kind: "plain",
1458
+ text: m
1459
+ });
1460
+ }
1461
+ return out;
1462
+ }
1463
+ //#endregion
1464
+ //#region src/chat/streaming-pure.ts
1465
+ const PARENT_OWNER = "parent";
1466
+ function ownerOf(event) {
1467
+ return event.childId ?? PARENT_OWNER;
1468
+ }
1469
+ /** Flip any trailing streaming markdown blocks (any owner) to finalized. */
1470
+ function finalizeStreamingMarkdown(events) {
1471
+ let changed = false;
1472
+ const next = events.map((e) => {
1473
+ if (e.kind === "markdown" && e.streaming) {
1474
+ changed = true;
1475
+ return {
1476
+ ...e,
1477
+ streaming: false
1478
+ };
1479
+ }
1480
+ return e;
1481
+ });
1482
+ return changed ? next : events;
1483
+ }
1484
+ /** Flip the trailing streaming markdown block for one specific owner. */
1485
+ function finalizeStreamingMarkdownForOwner(events, owner) {
1486
+ for (let i = events.length - 1; i >= 0; i--) {
1487
+ const e = events[i];
1488
+ if (e.kind !== "markdown") continue;
1489
+ if (!e.streaming) continue;
1490
+ if (ownerOf(e) !== owner) continue;
1491
+ const next = events.slice();
1492
+ next[i] = {
1493
+ ...e,
1494
+ streaming: false
1495
+ };
1496
+ return next;
1497
+ }
1498
+ return events;
1499
+ }
1500
+ /**
1501
+ * Effective context size for a single turn.
1502
+ *
1503
+ * `usage.input` is misleading on its own when prompt caching is active: providers
1504
+ * (Anthropic, OpenRouter→Anthropic, Gemini) report `input` as the *new uncached*
1505
+ * tokens only — the cached prefix shows up in `cacheRead`, and newly-cached
1506
+ * tokens in `cacheCreation`. The model still saw all three buckets, so the real
1507
+ * context-window utilization is their sum.
1508
+ *
1509
+ * Non-caching providers leave `cacheRead`/`cacheCreation` undefined, so this
1510
+ * collapses to plain `input` for them.
1511
+ */
1512
+ function turnContextSize(usage) {
1513
+ return effectiveInputFromTurn(usage);
1514
+ }
1515
+ //#endregion
1516
+ //#region src/chat/tool-formatters.ts
1517
+ /**
1518
+ * Per-tool display metadata + one-line formatters consumed by any
1519
+ * surface that renders a `tool` event in `'formatted'` mode (see
1520
+ * `Settings.toolCallDisplay`).
1521
+ *
1522
+ * Each native tool gets a curated entry — a `displayName` verb that
1523
+ * reads in sentence case (e.g. "Read", "Shell") and a `format` callback
1524
+ * that pulls the most informative bits out of the model's raw input to
1525
+ * a single scannable line. Unknown tools (MCP servers, host-added
1526
+ * tools, future zidane additions) fall back to {@link formatToolCall}
1527
+ * returning `null` — the renderer then shows a minimal `↳ <name>` line.
1528
+ *
1529
+ * Renderer-agnostic: returns plain data (`{ target, meta }`) so the
1530
+ * TUI's React/OpenTUI surface and any future GUI consumer can paint
1531
+ * the same shape in their own style. Lives in `zidane/chat` because
1532
+ * it has no rendering concerns; the TUI just consumes it.
1533
+ */
1534
+ const TOOL_DISPLAY = {
1535
+ read_file: {
1536
+ displayName: "Read",
1537
+ format: (input) => {
1538
+ const path = stringField(input, "path");
1539
+ if (!path) return null;
1540
+ const meta = [];
1541
+ const offset = numberField(input, "offset");
1542
+ const limit = numberField(input, "limit");
1543
+ if (offset !== void 0 && limit !== void 0 && limit > 0) meta.push(`L${offset}–${offset + limit - 1}`);
1544
+ else if (offset !== void 0) meta.push(`from L${offset}`);
1545
+ else if (limit !== void 0 && limit > 0) meta.push(`${limit} lines`);
1546
+ return {
1547
+ target: path,
1548
+ meta
1549
+ };
1550
+ }
1551
+ },
1552
+ list_files: {
1553
+ displayName: "List",
1554
+ format: (input) => {
1555
+ return { target: stringField(input, "path") ?? "." };
1556
+ }
1557
+ },
1558
+ glob: {
1559
+ displayName: "Glob",
1560
+ format: (input) => {
1561
+ const pattern = stringField(input, "pattern");
1562
+ if (!pattern) return null;
1563
+ const meta = [];
1564
+ const limit = numberField(input, "limit");
1565
+ if (limit !== void 0) meta.push(`limit ${limit}`);
1566
+ return {
1567
+ target: pattern,
1568
+ meta
1569
+ };
1570
+ }
1571
+ },
1572
+ grep: {
1573
+ displayName: "Grep",
1574
+ format: (input) => {
1575
+ const pattern = stringField(input, "pattern");
1576
+ if (!pattern) return null;
1577
+ const target = `/${pattern}/`;
1578
+ const meta = [];
1579
+ const path = stringField(input, "path");
1580
+ if (path && path !== ".") meta.push(`in ${path}`);
1581
+ const glob = stringField(input, "glob");
1582
+ if (glob) meta.push(glob);
1583
+ const type = stringField(input, "type");
1584
+ if (type) meta.push(`type:${type}`);
1585
+ if (input["-i"] === true) meta.push("case-insensitive");
1586
+ const mode = stringField(input, "output_mode");
1587
+ if (mode && mode !== "files_with_matches") meta.push(mode);
1588
+ return {
1589
+ target,
1590
+ meta
1591
+ };
1592
+ }
1593
+ },
1594
+ shell: {
1595
+ displayName: (input) => input?.run_in_background === true ? "Shell (background)" : "Shell",
1596
+ format: (input) => {
1597
+ const command = stringField(input, "command");
1598
+ if (!command) return null;
1599
+ const description = stringField(input, "description");
1600
+ const line = { target: truncate(command, 200) };
1601
+ if (description && description.trim() !== "") line.meta = [truncate(description, 100)];
1602
+ return line;
1603
+ }
1604
+ },
1605
+ shell_kill: {
1606
+ displayName: "Kill task",
1607
+ format: (input) => {
1608
+ const taskId = stringField(input, "task_id");
1609
+ if (!taskId) return null;
1610
+ return { target: taskId };
1611
+ }
1612
+ },
1613
+ edit: {
1614
+ displayName: "Edit",
1615
+ format: (input) => {
1616
+ const path = stringField(input, "path");
1617
+ if (!path) return null;
1618
+ return {
1619
+ target: path,
1620
+ meta: input.replace_all === true ? ["replace all"] : []
1621
+ };
1622
+ }
1623
+ },
1624
+ multi_edit: {
1625
+ displayName: "Multi-edit",
1626
+ format: (input) => {
1627
+ const path = stringField(input, "path");
1628
+ if (!path) return null;
1629
+ const edits = Array.isArray(input.edits) ? input.edits.length : 0;
1630
+ return {
1631
+ target: path,
1632
+ meta: edits > 0 ? [`${edits} hunk${edits === 1 ? "" : "s"}`] : []
1633
+ };
1634
+ }
1635
+ },
1636
+ write_file: {
1637
+ displayName: "Write",
1638
+ format: (input) => {
1639
+ const path = stringField(input, "path");
1640
+ if (!path) return null;
1641
+ const content = stringField(input, "content");
1642
+ const meta = [];
1643
+ if (content !== void 0) {
1644
+ const bytes = utf8ByteLength(content);
1645
+ meta.push(`${formatBytes(bytes)}`);
1646
+ }
1647
+ return {
1648
+ target: path,
1649
+ meta
1650
+ };
1651
+ }
1652
+ },
1653
+ spawn: {
1654
+ displayName: "Agent",
1655
+ format: (input) => {
1656
+ const task = stringField(input, "task");
1657
+ if (!task) return null;
1658
+ return { target: truncate(task, 120) };
1659
+ }
1660
+ },
1661
+ tool_search: {
1662
+ displayName: "Search tools",
1663
+ format: (input) => {
1664
+ const query = stringField(input, "query");
1665
+ const names = Array.isArray(input.names) ? input.names.length : 0;
1666
+ if (query) return { target: `“${query}”` };
1667
+ if (names > 0) return { target: `${names} tool${names === 1 ? "" : "s"}` };
1668
+ return null;
1669
+ }
1670
+ },
1671
+ skills_use: {
1672
+ displayName: (input) => {
1673
+ return (input ? stringField(input, "mode") : void 0) === "deactivate" ? "Disable skill" : "Enable skill";
1674
+ },
1675
+ format: (input) => {
1676
+ const name = stringField(input, "name");
1677
+ if (!name) return null;
1678
+ return { target: name };
1679
+ }
1680
+ },
1681
+ skills_read: {
1682
+ displayName: "Read skill",
1683
+ format: (input) => {
1684
+ const name = stringField(input, "name");
1685
+ const path = stringField(input, "path");
1686
+ if (!name) return null;
1687
+ return { target: path ? `${name}/${path}` : name };
1688
+ }
1689
+ },
1690
+ skills_run_script: {
1691
+ displayName: "Run script",
1692
+ format: (input) => {
1693
+ const name = stringField(input, "name");
1694
+ const script = stringField(input, "script");
1695
+ if (!name || !script) return null;
1696
+ const meta = [`skill ${name}`];
1697
+ const args = Array.isArray(input.args) ? input.args : null;
1698
+ if (args && args.length > 0) meta.push(truncate(args.map(String).join(" "), 80));
1699
+ return {
1700
+ target: script,
1701
+ meta
1702
+ };
1703
+ }
1704
+ },
1705
+ todowrite: {
1706
+ displayName: "Todos",
1707
+ format: (input) => {
1708
+ const todos = Array.isArray(input.todos) ? input.todos : null;
1709
+ if (!todos) return null;
1710
+ const counts = {
1711
+ pending: 0,
1712
+ in_progress: 0,
1713
+ completed: 0,
1714
+ cancelled: 0
1715
+ };
1716
+ for (const t of todos) {
1717
+ if (!t || typeof t !== "object") continue;
1718
+ const status = t.status;
1719
+ if (typeof status === "string" && status in counts) counts[status] += 1;
1720
+ }
1721
+ const meta = [];
1722
+ if (counts.completed) meta.push(`${counts.completed} done`);
1723
+ if (counts.in_progress) meta.push(`${counts.in_progress} in progress`);
1724
+ if (counts.pending) meta.push(`${counts.pending} pending`);
1725
+ if (counts.cancelled) meta.push(`${counts.cancelled} cancelled`);
1726
+ return {
1727
+ target: `${todos.length} item${todos.length === 1 ? "" : "s"}`,
1728
+ meta
1729
+ };
1730
+ }
1731
+ },
1732
+ todoread: {
1733
+ displayName: "Todos",
1734
+ format: () => ({ target: "read" })
1735
+ },
1736
+ ask_user: {
1737
+ displayName: "Ask user",
1738
+ format: (input) => {
1739
+ const questions = Array.isArray(input.questions) ? input.questions.length : 0;
1740
+ if (questions === 0) return null;
1741
+ return { target: `${questions} question${questions === 1 ? "" : "s"}` };
1742
+ }
1743
+ },
1744
+ present_plan: {
1745
+ displayName: "Present plan",
1746
+ format: (input) => {
1747
+ const title = stringField(input, "title");
1748
+ if (!title) return null;
1749
+ return { target: title };
1750
+ }
1751
+ }
1752
+ };
1753
+ /**
1754
+ * Resolve the display verb for a tool. Native tools use their curated
1755
+ * entry from {@link TOOL_DISPLAY}; everything else gets a sentence-case
1756
+ * version of the raw name (`my_host_tool` → `My host tool`) so an MCP /
1757
+ * host tool still reads cleanly in the transcript without shouting
1758
+ * Title Case at every word.
1759
+ *
1760
+ * MCP convention: every tool surfaced by `mcp/connectMcpServers` is
1761
+ * namespaced as `mcp_<server>_<tool>` (see `src/mcp/index.ts`). The
1762
+ * `mcp_` prefix is plumbing — strip it before casing so the label
1763
+ * reads as `Github create issue` instead of `Mcp github create issue`.
1764
+ * The server name leads, which doubles as a free visual grouping
1765
+ * affordance ("everything starting with `Github` came from the github
1766
+ * MCP server").
1767
+ */
1768
+ function displayNameFor(name, input) {
1769
+ const entry = TOOL_DISPLAY[name];
1770
+ if (entry) return typeof entry.displayName === "function" ? entry.displayName(input) : entry.displayName;
1771
+ return sentenceCase(name.startsWith("mcp_") ? name.slice(4) : name);
1772
+ }
1773
+ /**
1774
+ * Run a tool's curated formatter and return the result, or `null` when
1775
+ * no formatter is registered / the input shape doesn't match. Renderer
1776
+ * decides what to do with `null` — typically: show `↳ <displayName>`
1777
+ * with no target / meta tail.
1778
+ */
1779
+ function formatToolCall(name, input) {
1780
+ const entry = TOOL_DISPLAY[name];
1781
+ if (!entry) return null;
1782
+ try {
1783
+ return entry.format(input);
1784
+ } catch {
1785
+ return null;
1786
+ }
1787
+ }
1788
+ function stringField(input, key) {
1789
+ const v = input[key];
1790
+ return typeof v === "string" && v.length > 0 ? v : void 0;
1791
+ }
1792
+ function numberField(input, key) {
1793
+ const v = input[key];
1794
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
1795
+ }
1796
+ /** `snake_case` / `kebab-case` / lowercase → `Sentence case`. */
1797
+ function sentenceCase(s) {
1798
+ const words = s.split(/[-_\s]+/).filter(Boolean).map((w) => w.toLowerCase());
1799
+ if (words.length === 0) return "";
1800
+ words[0] = (words[0][0]?.toUpperCase() ?? "") + words[0].slice(1);
1801
+ return words.join(" ");
1802
+ }
1803
+ /**
1804
+ * Collapse internal whitespace (including newlines) to single spaces
1805
+ * and clip to `max` columns with a trailing `…`. The whitespace
1806
+ * normalisation is the load-bearing bit — tool input strings like a
1807
+ * shell heredoc or a multi-line Python `-c` script otherwise render
1808
+ * across several rows in the transcript even though the `↳ Tool …`
1809
+ * line is meant to be a single-line scannable summary.
1810
+ */
1811
+ function truncate(s, max) {
1812
+ const clean = s.replace(/\s+/g, " ").trim();
1813
+ return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`;
1814
+ }
1815
+ function formatBytes(bytes) {
1816
+ if (bytes < 1024) return `${bytes} B`;
1817
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1818
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1819
+ }
1820
+ //#endregion
1821
+ //#region src/chat/turn-operations.ts
1822
+ /**
1823
+ * Fork — keep every turn up to and including `turnId`, then strip any
1824
+ * `tool_call` blocks left without a matching `tool_result` in the slice.
1825
+ *
1826
+ * Semantics:
1827
+ * - Include the selected turn ("branch from HERE" mental model — the
1828
+ * user wants the selected message to be the latest in the fork).
1829
+ * - If the selected turn is an assistant turn with unresolved
1830
+ * `tool_call` blocks (their `tool_result`s live in turns AFTER the
1831
+ * slice), strip those calls. Otherwise the fork would post an
1832
+ * assistant turn with no matching tool results, breaking the next
1833
+ * provider call.
1834
+ * - Drop turns that become empty (all blocks stripped).
1835
+ *
1836
+ * Returns `null` when `turnId` doesn't exist in `turns` — caller should
1837
+ * surface a "turn not found" error rather than silently no-op.
1838
+ */
1839
+ function truncateTurnsAt(turns, turnId) {
1840
+ const idx = turns.findIndex((t) => t.id === turnId);
1841
+ if (idx === -1) return null;
1842
+ return stripOrphanToolBlocks(turns.slice(0, idx + 1));
1843
+ }
1844
+ /**
1845
+ * Delete — remove the turn with `turnId` and any tool blocks left
1846
+ * orphaned by the removal. Returns `null` when `turnId` doesn't exist.
1847
+ *
1848
+ * Strategy:
1849
+ * 1. Drop the target turn.
1850
+ * 2. Scan the remaining turns for `tool_call`s without a matching
1851
+ * `tool_result` (orphaned by removing the user turn that carried
1852
+ * the result), and `tool_result`s without a matching `tool_call`
1853
+ * (orphaned by removing the assistant turn that issued the call).
1854
+ * Strip both sides.
1855
+ * 3. Drop turns whose content is now empty.
1856
+ *
1857
+ * This guarantees the resulting history is protocol-clean — a follow-up
1858
+ * `agent.run()` against the modified session can post turns without the
1859
+ * provider rejecting the history.
1860
+ */
1861
+ function deleteTurnSafely(turns, turnId) {
1862
+ const idx = turns.findIndex((t) => t.id === turnId);
1863
+ if (idx === -1) return null;
1864
+ return stripOrphanToolBlocks([...turns.slice(0, idx), ...turns.slice(idx + 1)]);
1865
+ }
1866
+ /**
1867
+ * Walk a turn list and remove any tool blocks whose counterpart is
1868
+ * missing. Drops turns left empty. Used by `truncateTurnsAt` (which can
1869
+ * leave `tool_call`s orphaned when their results are past the cut) and
1870
+ * `deleteTurnSafely` (which can orphan either side of a pair).
1871
+ *
1872
+ * Pure / total: returns a new array; never throws.
1873
+ */
1874
+ function stripOrphanToolBlocks(turns) {
1875
+ const callIds = /* @__PURE__ */ new Set();
1876
+ const resultIds = /* @__PURE__ */ new Set();
1877
+ for (const turn of turns) for (const block of turn.content) if (block.type === "tool_call") callIds.add(block.id);
1878
+ else if (block.type === "tool_result") resultIds.add(block.callId);
1879
+ const result = [];
1880
+ for (const turn of turns) {
1881
+ const filtered = [];
1882
+ for (const block of turn.content) {
1883
+ if (block.type === "tool_call") {
1884
+ if (!resultIds.has(block.id)) continue;
1885
+ } else if (block.type === "tool_result") {
1886
+ if (!callIds.has(block.callId)) continue;
1887
+ }
1888
+ filtered.push(block);
1889
+ }
1890
+ if (filtered.length === 0) continue;
1891
+ result.push(filtered.length === turn.content.length ? turn : {
1892
+ ...turn,
1893
+ content: filtered
1894
+ });
1895
+ }
1896
+ return result;
1897
+ }
1898
+ /**
1899
+ * Serialize a turn's content to a clean text representation suited for
1900
+ * the clipboard. Joins text + thinking blocks verbatim; tool calls and
1901
+ * tool results get bracketed labels so the user can paste a readable
1902
+ * record of what happened without losing structure.
1903
+ *
1904
+ * Empty turns return `''`.
1905
+ */
1906
+ function turnAsText(turn) {
1907
+ const parts = [];
1908
+ for (const block of turn.content) if (block.type === "text" && block.text.trim()) parts.push(block.text);
1909
+ else if (block.type === "thinking" && block.text.trim()) parts.push(`[thinking]\n${block.text}`);
1910
+ else if (block.type === "tool_call") parts.push(`[tool call · ${block.name}]\n${stringifyArgs(block.input)}`);
1911
+ else if (block.type === "tool_result") parts.push(`[tool result]\n${typeof block.output === "string" ? block.output : JSON.stringify(block.output, null, 2)}`);
1912
+ else if (block.type === "compact-summary") parts.push(`[compaction summary · ${block.replacesTurnIds.length} turn${block.replacesTurnIds.length === 1 ? "" : "s"}]\n${block.summary}`);
1913
+ return parts.join("\n\n");
1914
+ }
1915
+ function stringifyArgs(input) {
1916
+ try {
1917
+ return JSON.stringify(input, null, 2);
1918
+ } catch {
1919
+ return String(input);
1920
+ }
1921
+ }
1922
+ /**
1923
+ * Count turns before / after the one identified by `turnId` in the
1924
+ * given list. Returns `null` when the id is missing. Used to label the
1925
+ * turn-details modal with `N before · M after`.
1926
+ */
1927
+ function countNeighbors(turnIds, turnId) {
1928
+ const idx = turnIds.indexOf(turnId);
1929
+ if (idx === -1) return null;
1930
+ return {
1931
+ before: idx,
1932
+ after: turnIds.length - 1 - idx
1933
+ };
1934
+ }
1935
+ //#endregion
1936
+ export { splitLines as A, summarizeOutcomes as B, buildContextualDiff as C, extractEditPayload as D, computeLineDiff as E, mergeApprovalAndBodyOutcomes as F, createFilesCompletionProvider as G, createSkillsCompletionProvider as H, parseEditOutcomesFromResult as I, collectReferences as J, uniqueFilesFromReferences as K, resolveApprovalForPayload as L, tokenize as M, buildEditOutcomesAnnotation as N, filetypeFromPath as O, maskToOutcomeKinds as P, buildLinearRamp as Q, rewriteMultiEditHeader as R, applyEditPayload as S, computeInlineDiff as T, uniqueSkillNamesFromReferences as U, SKILLS_TRIGGER as V, FILES_TRIGGER as W, mergeReferences as X, findActiveTrigger as Y, blendHsl as Z, isEditErrorResult as _, TOOL_DISPLAY as a, selectableTurnIds as b, finalizeStreamingMarkdown as c, turnContextSize as d, splitPromptSegments as f, EDIT_TOOL_NAMES as g, indexOfEntry as h, turnAsText as i, summarizeEditPayload as j, previewEditPayload as k, finalizeStreamingMarkdownForOwner as l, filterModelCatalog as m, deleteTurnSafely as n, displayNameFor as o, buildModelCatalog as p, applyInsert as q, truncateTurnsAt as r, formatToolCall as s, countNeighbors as t, ownerOf as u, isTurnHighlighted as v, buildUnifiedDiff as w, turnSelectionOwnership as x, isVisible as y, stripEditOutcomesAnnotation as z };
1937
+
1938
+ //# sourceMappingURL=turn-operations-CCHfR9eC.js.map