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.
- package/README.md +3 -1
- package/dist/{agent-ClkpElCZ.d.ts → agent-BNS2nx_T.d.ts} +535 -15
- package/dist/agent-BNS2nx_T.d.ts.map +1 -0
- package/dist/chat/pure.d.ts +4 -0
- package/dist/chat/pure.js +3 -0
- package/dist/chat.d.ts +31 -661
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +5 -3
- package/dist/chat.js.map +1 -1
- package/dist/contexts/docker.d.ts +1 -1
- package/dist/contexts/docker.d.ts.map +1 -1
- package/dist/contexts/docker.js.map +1 -1
- package/dist/{contexts-BOtMvzli.js → contexts-BD2U_xpi.js} +2 -2
- package/dist/{contexts-BOtMvzli.js.map → contexts-BD2U_xpi.js.map} +1 -1
- package/dist/contexts.d.ts +3 -3
- package/dist/contexts.js +1 -1
- package/dist/edit-utils-DnfNoj16.js +574 -0
- package/dist/edit-utils-DnfNoj16.js.map +1 -0
- package/dist/{errors-DdZXnyXE.js → errors-CoQnKRf1.js} +32 -2
- package/dist/{errors-DdZXnyXE.js.map → errors-CoQnKRf1.js.map} +1 -1
- package/dist/fetch-url-CPxfiXDa.js +518 -0
- package/dist/fetch-url-CPxfiXDa.js.map +1 -0
- package/dist/image-sniff-B7uFSNO1.js +90 -0
- package/dist/image-sniff-B7uFSNO1.js.map +1 -0
- package/dist/{index-CbS75MD3.d.ts → index-CZOwAJIX.d.ts} +2 -2
- package/dist/index-CZOwAJIX.d.ts.map +1 -0
- package/dist/{index-CTDMMdIy.d.ts → index-Ck_AWt8P.d.ts} +3 -4
- package/dist/index-Ck_AWt8P.d.ts.map +1 -0
- package/dist/{index-v3Tzobqr.d.ts → index-KiS7w0dC.d.ts} +3 -3
- package/dist/index-KiS7w0dC.d.ts.map +1 -0
- package/dist/index.d.ts +6 -6
- package/dist/index.js +13 -12
- package/dist/index.js.map +1 -1
- package/dist/{interpolate-DM1UcKeQ.js → interpolate-TySiqKzc.js} +23 -23
- package/dist/{interpolate-DM1UcKeQ.js.map → interpolate-TySiqKzc.js.map} +1 -1
- package/dist/{login-7tHcckmX.js → login-BDeqENSe.js} +7 -58
- package/dist/login-BDeqENSe.js.map +1 -0
- package/dist/{mcp-DGeB7-3D.js → mcp-Kqzz-Rs_.js} +8 -6
- package/dist/mcp-Kqzz-Rs_.js.map +1 -0
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +1 -1
- package/dist/{messages-Dym8S_YH.js → messages-CvRQTdbR.js} +118 -39
- package/dist/messages-CvRQTdbR.js.map +1 -0
- package/dist/{presets-w9Px_aAm.js → presets-JuOnSI-i.js} +2 -2
- package/dist/{presets-w9Px_aAm.js.map → presets-JuOnSI-i.js.map} +1 -1
- package/dist/presets.d.ts +3 -3
- package/dist/presets.js +1 -1
- package/dist/{providers-beXyD9W9.js → providers-h4HJPbbv.js} +485 -31
- package/dist/providers-h4HJPbbv.js.map +1 -0
- package/dist/providers.d.ts +2 -2
- package/dist/providers.js +3 -3
- package/dist/restate.d.ts +1 -1
- package/dist/restate.d.ts.map +1 -1
- package/dist/restate.js.map +1 -1
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/session/sqlite.d.ts.map +1 -1
- package/dist/session/sqlite.js +1 -1
- package/dist/session/sqlite.js.map +1 -1
- package/dist/{session-BRIsmBSY.js → session-BzLou2_-.js} +2 -2
- package/dist/{session-BRIsmBSY.js.map → session-BzLou2_-.js.map} +1 -1
- package/dist/session.d.ts +2 -2
- package/dist/session.js +2 -2
- package/dist/skills.d.ts +3 -3
- package/dist/skills.js +1 -1
- package/dist/skills.js.map +1 -1
- package/dist/{stats-Lc3zL3RM.js → stats-DAKBEKjc.js} +12 -2
- package/dist/stats-DAKBEKjc.js.map +1 -0
- package/dist/{stdio-loader-EVAF5KlU.js → stdio-loader-Ce68wUmM.js} +4 -4
- package/dist/stdio-loader-Ce68wUmM.js.map +1 -0
- package/dist/tool-formatters-CU-j3a3e.d.ts +1471 -0
- package/dist/tool-formatters-CU-j3a3e.d.ts.map +1 -0
- package/dist/tools/fetch-url.d.ts +70 -0
- package/dist/tools/fetch-url.d.ts.map +1 -0
- package/dist/tools/fetch-url.js +2 -0
- package/dist/tools/web-search.d.ts +7 -0
- package/dist/tools/web-search.d.ts.map +1 -0
- package/dist/tools/web-search.js +190 -0
- package/dist/tools/web-search.js.map +1 -0
- package/dist/{tools-DhrLrOEr.js → tools-BGtJK0vo.js} +1368 -421
- package/dist/tools-BGtJK0vo.js.map +1 -0
- package/dist/tools.d.ts +3 -3
- package/dist/tools.js +1 -1
- package/dist/{turn-operations-UAkOjO-u.js → transcript-anchors-BTSZAPVc.js} +147 -2713
- package/dist/transcript-anchors-BTSZAPVc.js.map +1 -0
- package/dist/{transcript-anchors-D0TR6djV.d.ts → transcript-anchors-DX90kXc4.d.ts} +13 -1299
- package/dist/transcript-anchors-DX90kXc4.d.ts.map +1 -0
- package/dist/tui.d.ts +58 -28
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +1349 -422
- package/dist/tui.js.map +1 -1
- package/dist/turn-operations-CCHfR9eC.js +1938 -0
- package/dist/turn-operations-CCHfR9eC.js.map +1 -0
- package/dist/turn-operations-DDIl4YVk.d.ts +658 -0
- package/dist/turn-operations-DDIl4YVk.d.ts.map +1 -0
- package/dist/{types-oKPBdCmL.js → types-BPw_i5vb.js} +1 -1
- package/dist/types-BPw_i5vb.js.map +1 -0
- package/dist/{types-KukEp-mi.d.ts → types-CEAMIUXw.d.ts} +1 -1
- package/dist/types-CEAMIUXw.d.ts.map +1 -0
- package/dist/types.d.ts +4 -4
- package/dist/types.js +3 -3
- package/docs/CHAT.md +53 -6
- package/docs/SKILL.md +3 -0
- package/docs/TUI.md +7 -0
- package/package.json +18 -2
- package/dist/agent-ClkpElCZ.d.ts.map +0 -1
- package/dist/index-CTDMMdIy.d.ts.map +0 -1
- package/dist/index-CbS75MD3.d.ts.map +0 -1
- package/dist/index-v3Tzobqr.d.ts.map +0 -1
- package/dist/login-7tHcckmX.js.map +0 -1
- package/dist/mcp-DGeB7-3D.js.map +0 -1
- package/dist/messages-Dym8S_YH.js.map +0 -1
- package/dist/providers-beXyD9W9.js.map +0 -1
- package/dist/stats-Lc3zL3RM.js.map +0 -1
- package/dist/stdio-loader-EVAF5KlU.js.map +0 -1
- package/dist/tools-DhrLrOEr.js.map +0 -1
- package/dist/transcript-anchors-D0TR6djV.d.ts.map +0 -1
- package/dist/turn-operations-UAkOjO-u.js.map +0 -1
- package/dist/types-KukEp-mi.d.ts.map +0 -1
- 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
|