@bubblebrain-ai/bubble 0.0.59 → 0.0.60
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/dist/agent/subagent/runtime.d.ts +28 -0
- package/dist/agent/subagent/runtime.js +4 -24
- package/dist/agent.d.ts +7 -6
- package/dist/agent.js +18 -1
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +25 -2
- package/dist/config.d.ts +3 -0
- package/dist/config.js +7 -0
- package/dist/context/compact.js +6 -2
- package/dist/context/tool-images.d.ts +3 -0
- package/dist/context/tool-images.js +35 -0
- package/dist/context/usage.js +22 -5
- package/dist/feishu/agent-host/run-driver.js +3 -0
- package/dist/lsp/service.js +3 -2
- package/dist/main.js +11 -1
- package/dist/permissions/shell-command.js +35 -3
- package/dist/prompt/compose.d.ts +3 -0
- package/dist/prompt/compose.js +17 -3
- package/dist/provider-openai-codex.d.ts +4 -0
- package/dist/provider-openai-codex.js +28 -9
- package/dist/provider-registry.js +4 -2
- package/dist/sdk/index.d.ts +21 -2
- package/dist/sdk/index.js +92 -11
- package/dist/session.d.ts +5 -1
- package/dist/session.js +13 -5
- package/dist/skills/format.d.ts +31 -1
- package/dist/skills/format.js +45 -24
- package/dist/slash-commands/commands.d.ts +3 -0
- package/dist/slash-commands/commands.js +6 -4
- package/dist/system-prompt.d.ts +3 -1
- package/dist/tools/edit-apply.d.ts +0 -3
- package/dist/tools/edit-apply.js +45 -249
- package/dist/tools/edit.js +3 -3
- package/dist/tools/grep.js +20 -1
- package/dist/tools/skill.d.ts +2 -0
- package/dist/tools/skill.js +34 -2
- package/dist/tui/app.js +6 -0
- package/dist/tui/components/skills-panel.d.ts +4 -0
- package/dist/tui/components/skills-panel.js +15 -1
- package/dist/tui/components/tasks-pane.d.ts +38 -0
- package/dist/tui/components/tasks-pane.js +182 -32
- package/dist/tui/controller/controller.d.ts +7 -0
- package/dist/tui/controller/controller.js +13 -0
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
- package/dist/prompt/skills.d.ts +0 -2
- package/dist/prompt/skills.js +0 -4
package/dist/tools/edit-apply.js
CHANGED
|
@@ -21,38 +21,36 @@ function stripBom(content) {
|
|
|
21
21
|
function normalizeToLF(text) {
|
|
22
22
|
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
23
23
|
}
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
function restoreLineEndings(text, lineEnding) {
|
|
55
|
-
return lineEnding === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
24
|
+
function buildOriginalOffsets(text) {
|
|
25
|
+
const offsets = [];
|
|
26
|
+
for (let i = 0; i < text.length; i++) {
|
|
27
|
+
offsets.push(i);
|
|
28
|
+
if (text[i] === "\r" && text[i + 1] === "\n")
|
|
29
|
+
i++;
|
|
30
|
+
}
|
|
31
|
+
offsets.push(text.length);
|
|
32
|
+
return offsets;
|
|
33
|
+
}
|
|
34
|
+
function lineEndingAt(text, offset, fallback) {
|
|
35
|
+
const after = text.slice(offset).match(/\r\n|\r|\n/);
|
|
36
|
+
if (after)
|
|
37
|
+
return after[0];
|
|
38
|
+
const before = text.slice(0, offset).match(/(\r\n|\r|\n)[^\r\n]*$/);
|
|
39
|
+
return before ? before[1] : fallback;
|
|
40
|
+
}
|
|
41
|
+
// Splices replacements into the original text so untouched lines keep their own
|
|
42
|
+
// terminators; inserted text uses the terminator of the line being edited.
|
|
43
|
+
function spliceOriginal(text, matches, edits, fallbackEnding) {
|
|
44
|
+
const offsets = buildOriginalOffsets(text);
|
|
45
|
+
let out = "";
|
|
46
|
+
let cursor = 0;
|
|
47
|
+
for (const match of [...matches].sort((a, b) => a.start - b.start)) {
|
|
48
|
+
const start = offsets[match.start];
|
|
49
|
+
const ending = lineEndingAt(text, start, fallbackEnding);
|
|
50
|
+
out += text.slice(cursor, start) + edits[match.editIndex].newText.replace(/\n/g, ending);
|
|
51
|
+
cursor = offsets[match.end];
|
|
52
|
+
}
|
|
53
|
+
return out + text.slice(cursor);
|
|
56
54
|
}
|
|
57
55
|
function normalizeLineForMatch(line) {
|
|
58
56
|
return line
|
|
@@ -63,9 +61,6 @@ function normalizeLineForMatch(line) {
|
|
|
63
61
|
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
|
|
64
62
|
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
|
|
65
63
|
}
|
|
66
|
-
function normalizeLeadingWhitespaceForMatch(line) {
|
|
67
|
-
return normalizeLineForMatch(line).replace(/^\s+/, " ");
|
|
68
|
-
}
|
|
69
64
|
function findAllOccurrences(content, needle) {
|
|
70
65
|
const indexes = [];
|
|
71
66
|
if (needle.length === 0)
|
|
@@ -104,130 +99,6 @@ function normalizedOldNonBlankLines(oldText) {
|
|
|
104
99
|
.map((line) => normalizeLineForMatch(line.text))
|
|
105
100
|
.filter((line) => line.trim().length > 0);
|
|
106
101
|
}
|
|
107
|
-
function singleNonBlankOldLine(oldText) {
|
|
108
|
-
const lines = normalizedOldNonBlankLines(oldText);
|
|
109
|
-
return lines.length === 1 ? lines[0] : undefined;
|
|
110
|
-
}
|
|
111
|
-
function findNormalizedLineMatches(content, oldText) {
|
|
112
|
-
const contentLines = splitLines(content);
|
|
113
|
-
const searchable = nonBlankLines(contentLines);
|
|
114
|
-
const oldLines = normalizedOldNonBlankLines(oldText);
|
|
115
|
-
if (oldLines.length === 0)
|
|
116
|
-
return [];
|
|
117
|
-
const matches = [];
|
|
118
|
-
for (let i = 0; i <= searchable.length - oldLines.length; i++) {
|
|
119
|
-
let matched = true;
|
|
120
|
-
for (let j = 0; j < oldLines.length; j++) {
|
|
121
|
-
if (searchable[i + j].normalized !== oldLines[j]) {
|
|
122
|
-
matched = false;
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
if (matched) {
|
|
127
|
-
const first = contentLines[searchable[i].lineIndex];
|
|
128
|
-
const last = contentLines[searchable[i + oldLines.length - 1].lineIndex];
|
|
129
|
-
matches.push({ start: first.start, end: last.endNoNewline });
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return matches;
|
|
133
|
-
}
|
|
134
|
-
function findSmartLineMatches(content, oldText) {
|
|
135
|
-
const contentLines = splitLines(content);
|
|
136
|
-
const oldLines = splitLines(oldText).map((line) => normalizeLeadingWhitespaceForMatch(line.text));
|
|
137
|
-
if (oldLines.length === 0 || oldLines.every((line) => line.length === 0))
|
|
138
|
-
return [];
|
|
139
|
-
const matches = [];
|
|
140
|
-
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
|
|
141
|
-
let matched = true;
|
|
142
|
-
for (let j = 0; j < oldLines.length; j++) {
|
|
143
|
-
const actual = normalizeLeadingWhitespaceForMatch(contentLines[i + j].text);
|
|
144
|
-
if (actual !== oldLines[j]) {
|
|
145
|
-
matched = false;
|
|
146
|
-
break;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
if (matched) {
|
|
150
|
-
const first = contentLines[i];
|
|
151
|
-
const last = contentLines[i + oldLines.length - 1];
|
|
152
|
-
matches.push({ start: first.start, end: last.endNoNewline });
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return matches;
|
|
156
|
-
}
|
|
157
|
-
function splitMarkdownTableCells(line) {
|
|
158
|
-
const normalized = normalizeLineForMatch(line).trim();
|
|
159
|
-
if (!normalized.startsWith("|") || !normalized.endsWith("|"))
|
|
160
|
-
return undefined;
|
|
161
|
-
const parts = [];
|
|
162
|
-
let current = "";
|
|
163
|
-
let escaped = false;
|
|
164
|
-
for (const char of normalized) {
|
|
165
|
-
if (escaped) {
|
|
166
|
-
current += char;
|
|
167
|
-
escaped = false;
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
if (char === "\\") {
|
|
171
|
-
current += char;
|
|
172
|
-
escaped = true;
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
if (char === "|") {
|
|
176
|
-
parts.push(current);
|
|
177
|
-
current = "";
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
current += char;
|
|
181
|
-
}
|
|
182
|
-
parts.push(current);
|
|
183
|
-
if (parts.length < 4 || parts[0] !== "" || parts[parts.length - 1] !== "")
|
|
184
|
-
return undefined;
|
|
185
|
-
const cells = parts.slice(1, -1).map((cell) => cell.trim());
|
|
186
|
-
return cells.length >= 2 ? cells : undefined;
|
|
187
|
-
}
|
|
188
|
-
function sameCells(a, b) {
|
|
189
|
-
return a.length === b.length && a.every((cell, index) => cell === b[index]);
|
|
190
|
-
}
|
|
191
|
-
function findMarkdownTableMatches(content, oldText) {
|
|
192
|
-
const oldLine = singleNonBlankOldLine(oldText);
|
|
193
|
-
if (!oldLine)
|
|
194
|
-
return [];
|
|
195
|
-
const oldCells = splitMarkdownTableCells(oldLine);
|
|
196
|
-
if (!oldCells)
|
|
197
|
-
return [];
|
|
198
|
-
const matches = [];
|
|
199
|
-
for (const line of splitLines(content)) {
|
|
200
|
-
const cells = splitMarkdownTableCells(line.text);
|
|
201
|
-
if (cells && sameCells(cells, oldCells)) {
|
|
202
|
-
matches.push({ start: line.start, end: line.endNoNewline });
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return matches;
|
|
206
|
-
}
|
|
207
|
-
function collapseInlineWhitespace(text) {
|
|
208
|
-
return normalizeLineForMatch(text).trim().replace(/[ \t]+/g, " ");
|
|
209
|
-
}
|
|
210
|
-
function isDocumentLikePath(path) {
|
|
211
|
-
return !!path && /\.(?:md|mdx|markdown|txt|rst|adoc)$/i.test(path);
|
|
212
|
-
}
|
|
213
|
-
function findSingleLineWhitespaceMatches(content, oldText, options) {
|
|
214
|
-
if (!isDocumentLikePath(options?.path))
|
|
215
|
-
return [];
|
|
216
|
-
const oldLine = singleNonBlankOldLine(oldText);
|
|
217
|
-
if (!oldLine)
|
|
218
|
-
return [];
|
|
219
|
-
const normalizedOld = collapseInlineWhitespace(oldLine);
|
|
220
|
-
if (normalizedOld.length === 0)
|
|
221
|
-
return [];
|
|
222
|
-
const matches = [];
|
|
223
|
-
for (const line of splitLines(content)) {
|
|
224
|
-
const collapsed = collapseInlineWhitespace(line.text);
|
|
225
|
-
if (collapsed === normalizedOld && line.text !== oldLine) {
|
|
226
|
-
matches.push({ start: line.start, end: line.endNoNewline });
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return matches;
|
|
230
|
-
}
|
|
231
102
|
function summarizeOldText(oldText) {
|
|
232
103
|
const firstLine = normalizeToLF(oldText).split("\n").find((line) => line.trim().length > 0) ?? oldText;
|
|
233
104
|
return firstLine.length > 80 ? `${firstLine.slice(0, 80)}...` : firstLine;
|
|
@@ -326,88 +197,18 @@ function matchEdit(content, edit, index, total, options) {
|
|
|
326
197
|
].join("\n"));
|
|
327
198
|
}
|
|
328
199
|
const oldText = normalizeToLF(edit.oldText);
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (exact.length === 1) {
|
|
333
|
-
return { editIndex: index, mode: candidate.mode, start: exact[0], end: exact[0] + candidate.text.length };
|
|
334
|
-
}
|
|
335
|
-
if (exact.length > 1) {
|
|
336
|
-
const recovery = [
|
|
337
|
-
"",
|
|
338
|
-
"Extend oldText with more surrounding context (the lines immediately before/after) until it uniquely identifies the intended span.",
|
|
339
|
-
].join("\n");
|
|
340
|
-
const duplicateReason = candidate.mode === "exact"
|
|
341
|
-
? `appears ${exact.length} times in file`
|
|
342
|
-
: `matched ${exact.length} times after ${candidate.mode} matching`;
|
|
343
|
-
throw new EditApplyError(total === 1
|
|
344
|
-
? `Error: oldText ${duplicateReason}. Must be unique: "${summarizeOldText(oldText)}"${recovery}`
|
|
345
|
-
: `Error: edits[${index}].oldText ${duplicateReason}. Must be unique: "${summarizeOldText(oldText)}"${recovery}`);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
for (const candidate of candidates) {
|
|
349
|
-
const normalizedLineMatches = findNormalizedLineMatches(content, candidate.text);
|
|
350
|
-
if (normalizedLineMatches.length === 1) {
|
|
351
|
-
return {
|
|
352
|
-
editIndex: index,
|
|
353
|
-
mode: candidate.mode === "exact" ? "normalized-line" : candidate.mode,
|
|
354
|
-
start: normalizedLineMatches[0].start,
|
|
355
|
-
end: normalizedLineMatches[0].end,
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
if (normalizedLineMatches.length > 1) {
|
|
359
|
-
throw new EditApplyError(total === 1
|
|
360
|
-
? `Error: oldText matched ${normalizedLineMatches.length} normalized line blocks in file. Provide more surrounding context.`
|
|
361
|
-
: `Error: edits[${index}].oldText matched ${normalizedLineMatches.length} normalized line blocks in file. Provide more surrounding context.`);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
for (const candidate of candidates) {
|
|
365
|
-
const markdownTableMatches = findMarkdownTableMatches(content, candidate.text);
|
|
366
|
-
if (markdownTableMatches.length === 1) {
|
|
367
|
-
return {
|
|
368
|
-
editIndex: index,
|
|
369
|
-
mode: "markdown-table",
|
|
370
|
-
start: markdownTableMatches[0].start,
|
|
371
|
-
end: markdownTableMatches[0].end,
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
if (markdownTableMatches.length > 1) {
|
|
375
|
-
throw new EditApplyError(total === 1
|
|
376
|
-
? `Error: oldText matched ${markdownTableMatches.length} markdown table rows in file. Provide more surrounding context.`
|
|
377
|
-
: `Error: edits[${index}].oldText matched ${markdownTableMatches.length} markdown table rows in file. Provide more surrounding context.`);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
for (const candidate of candidates) {
|
|
381
|
-
const whitespaceMatches = findSingleLineWhitespaceMatches(content, candidate.text, options);
|
|
382
|
-
if (whitespaceMatches.length === 1) {
|
|
383
|
-
return {
|
|
384
|
-
editIndex: index,
|
|
385
|
-
mode: "single-line-whitespace",
|
|
386
|
-
start: whitespaceMatches[0].start,
|
|
387
|
-
end: whitespaceMatches[0].end,
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
if (whitespaceMatches.length > 1) {
|
|
391
|
-
throw new EditApplyError(total === 1
|
|
392
|
-
? `Error: oldText matched ${whitespaceMatches.length} whitespace-normalized lines in file. Provide more surrounding context.`
|
|
393
|
-
: `Error: edits[${index}].oldText matched ${whitespaceMatches.length} whitespace-normalized lines in file. Provide more surrounding context.`);
|
|
394
|
-
}
|
|
200
|
+
const occurrences = findAllOccurrences(content, oldText);
|
|
201
|
+
if (occurrences.length === 1) {
|
|
202
|
+
return { editIndex: index, start: occurrences[0], end: occurrences[0] + oldText.length };
|
|
395
203
|
}
|
|
396
|
-
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
if (smartLineMatches.length > 1) {
|
|
407
|
-
throw new EditApplyError(total === 1
|
|
408
|
-
? `Error: oldText matched ${smartLineMatches.length} indentation-normalized line blocks in file. Provide more surrounding context.`
|
|
409
|
-
: `Error: edits[${index}].oldText matched ${smartLineMatches.length} indentation-normalized line blocks in file. Provide more surrounding context.`);
|
|
410
|
-
}
|
|
204
|
+
if (occurrences.length > 1) {
|
|
205
|
+
const recovery = [
|
|
206
|
+
"",
|
|
207
|
+
"Extend oldText with more surrounding context (the lines immediately before/after) until it uniquely identifies the intended span.",
|
|
208
|
+
].join("\n");
|
|
209
|
+
throw new EditApplyError(total === 1
|
|
210
|
+
? `Error: oldText appears ${occurrences.length} times in file. Must be unique: "${summarizeOldText(oldText)}"${recovery}`
|
|
211
|
+
: `Error: edits[${index}].oldText appears ${occurrences.length} times in file. Must be unique: "${summarizeOldText(oldText)}"${recovery}`);
|
|
411
212
|
}
|
|
412
213
|
const hint = findBestLineHint(content, oldText);
|
|
413
214
|
const hintSuffix = hint ? `\n${formatBestLineHint(content, hint, options)}` : "";
|
|
@@ -439,9 +240,10 @@ export function applyEditsToContent(rawContent, edits, options) {
|
|
|
439
240
|
const { bom, text } = stripBom(rawContent);
|
|
440
241
|
const lineEnding = detectLineEnding(text);
|
|
441
242
|
const normalizedOriginal = normalizeToLF(text);
|
|
243
|
+
const dropCopiedBom = (value) => (bom ? stripBom(value).text : value);
|
|
442
244
|
const normalizedEdits = edits.map((edit) => ({
|
|
443
|
-
oldText: normalizeToLF(edit.oldText),
|
|
444
|
-
newText: normalizeToLF(edit.newText),
|
|
245
|
+
oldText: normalizeToLF(dropCopiedBom(edit.oldText)),
|
|
246
|
+
newText: normalizeToLF(dropCopiedBom(edit.newText)),
|
|
445
247
|
}));
|
|
446
248
|
const matches = normalizedEdits.map((edit, index) => matchEdit(normalizedOriginal, edit, index, normalizedEdits.length, options));
|
|
447
249
|
assertNoOverlaps(matches);
|
|
@@ -462,7 +264,7 @@ export function applyEditsToContent(rawContent, edits, options) {
|
|
|
462
264
|
].join("\n"));
|
|
463
265
|
}
|
|
464
266
|
return {
|
|
465
|
-
content: bom +
|
|
267
|
+
content: bom + spliceOriginal(text, matches, normalizedEdits, lineEnding),
|
|
466
268
|
normalizedOriginal,
|
|
467
269
|
normalizedNext,
|
|
468
270
|
bom,
|
|
@@ -470,9 +272,3 @@ export function applyEditsToContent(rawContent, edits, options) {
|
|
|
470
272
|
matches,
|
|
471
273
|
};
|
|
472
274
|
}
|
|
473
|
-
export function formatEditMatchNotes(matches) {
|
|
474
|
-
const normalizedCount = matches.filter((match) => match.mode !== "exact").length;
|
|
475
|
-
if (normalizedCount === 0)
|
|
476
|
-
return "";
|
|
477
|
-
return `\n\nNote: ${normalizedCount} edit${normalizedCount === 1 ? "" : "s"} applied using normalized line matching for whitespace/formatting differences.`;
|
|
478
|
-
}
|
package/dist/tools/edit.js
CHANGED
|
@@ -9,7 +9,7 @@ import { createTwoFilesPatch } from "diff";
|
|
|
9
9
|
import { gateToolAction } from "../approval/tool-helper.js";
|
|
10
10
|
import { countUnifiedDiffChanges } from "../diff-stats.js";
|
|
11
11
|
import { formatDiagnosticBlocks } from "../lsp/index.js";
|
|
12
|
-
import { applyEditsToContent, EditApplyError
|
|
12
|
+
import { applyEditsToContent, EditApplyError } from "./edit-apply.js";
|
|
13
13
|
import { withFileMutationQueue } from "./file-mutation-queue.js";
|
|
14
14
|
import { isWithinWorkspace } from "./file-state.js";
|
|
15
15
|
import { resolveToolPath } from "./path-utils.js";
|
|
@@ -65,7 +65,7 @@ export function createEditTool(cwd, approval, lsp, fileState, checkpoints) {
|
|
|
65
65
|
name: "edit",
|
|
66
66
|
effect: "write_direct",
|
|
67
67
|
requiresApproval: true,
|
|
68
|
-
description: "Edit a single file using targeted text replacements. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes overlap or one replacement is nested inside another, merge them into one edit. Do not include large unchanged regions just to connect distant changes.",
|
|
68
|
+
description: "Edit a single file using targeted text replacements. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes overlap or one replacement is nested inside another, merge them into one edit. Do not include large unchanged regions just to connect distant changes. Matching is exact; a UTF-8 BOM and CRLF line endings are handled automatically and preserved, so write oldText/newText with plain \\n line breaks.",
|
|
69
69
|
promptSnippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
|
|
70
70
|
promptGuidelines: [
|
|
71
71
|
"Use edit for precise changes; each edits[].oldText must be copied verbatim from a fresh read of the current exact target block and must identify a unique target. Do not reconstruct oldText from memory, stale reads, or similar code elsewhere.",
|
|
@@ -173,7 +173,7 @@ export function createEditTool(cwd, approval, lsp, fileState, checkpoints) {
|
|
|
173
173
|
await checkpoints?.()?.captureBefore(filePath, original);
|
|
174
174
|
await writeFile(filePath, applied.content, "utf-8");
|
|
175
175
|
await fileState?.observe(filePath, "edit", applied.content).catch(() => undefined);
|
|
176
|
-
let output = `Edited ${filePath}
|
|
176
|
+
let output = `Edited ${filePath}\n\nDiff:\n${diff}`;
|
|
177
177
|
if (lsp) {
|
|
178
178
|
try {
|
|
179
179
|
await lsp.touchFile(filePath, "document");
|
package/dist/tools/grep.js
CHANGED
|
@@ -113,6 +113,18 @@ function executeBoundedGrep(options) {
|
|
|
113
113
|
});
|
|
114
114
|
const decoder = new StringDecoder("utf8");
|
|
115
115
|
const matches = [];
|
|
116
|
+
// The root check alone is not enough: ripgrep searches inside an explicit
|
|
117
|
+
// hidden root, so `path: ~/.aws` would reach ~/.aws/credentials. Every
|
|
118
|
+
// matched file gets the same check the read tool applies (cached per file).
|
|
119
|
+
const sensitiveByPath = new Map();
|
|
120
|
+
const isSensitiveMatch = (absolutePath) => {
|
|
121
|
+
let sensitive = sensitiveByPath.get(absolutePath);
|
|
122
|
+
if (sensitive === undefined) {
|
|
123
|
+
sensitive = isSensitivePath(absolutePath);
|
|
124
|
+
sensitiveByPath.set(absolutePath, sensitive);
|
|
125
|
+
}
|
|
126
|
+
return sensitive;
|
|
127
|
+
};
|
|
116
128
|
let stdoutBuffer = "";
|
|
117
129
|
let stdoutBytes = 0;
|
|
118
130
|
let stderrBytes = 0;
|
|
@@ -142,6 +154,13 @@ function executeBoundedGrep(options) {
|
|
|
142
154
|
if (event.type !== "match")
|
|
143
155
|
return;
|
|
144
156
|
const rawPath = decodeRgField(event.data?.path);
|
|
157
|
+
// A path that is not valid UTF-8 cannot be checked (or read back by
|
|
158
|
+
// the model), so its matches are dropped rather than shown unchecked.
|
|
159
|
+
if (event.data?.path && !rawPath)
|
|
160
|
+
return;
|
|
161
|
+
const absolutePath = rawPath ? resolvePath(options.cwd, rawPath) : undefined;
|
|
162
|
+
if (absolutePath && isSensitiveMatch(absolutePath))
|
|
163
|
+
return;
|
|
145
164
|
const lineNumber = event.data?.line_number;
|
|
146
165
|
const rawText = decodeRgField(event.data?.lines, "[non-UTF-8 match bytes omitted]") ?? "";
|
|
147
166
|
const displayPath = rawPath || "<non-UTF-8 path>";
|
|
@@ -149,7 +168,7 @@ function executeBoundedGrep(options) {
|
|
|
149
168
|
const rendered = truncateCodePoints(`${displayPath}:${typeof lineNumber === "number" ? lineNumber : "?"}: ${normalizedText}`, MAX_RENDERED_LINE_CODE_POINTS);
|
|
150
169
|
matches.push({
|
|
151
170
|
rendered,
|
|
152
|
-
...(
|
|
171
|
+
...(absolutePath ? { absolutePath } : {}),
|
|
153
172
|
});
|
|
154
173
|
// We stop at N rather than pretending we proved there are N+1 matches.
|
|
155
174
|
// The notice says "limit reached / may be incomplete", which is honest
|
package/dist/tools/skill.d.ts
CHANGED
|
@@ -3,3 +3,5 @@ import type { SkillRegistry } from "../skills/registry.js";
|
|
|
3
3
|
import type { ToolRegistryEntry } from "../types.js";
|
|
4
4
|
export declare function formatLoadedSkill(skill: SkillRecord): string;
|
|
5
5
|
export declare function createSkillTool(registry: SkillRegistry): ToolRegistryEntry;
|
|
6
|
+
/** Up to three known names closest to a mistyped one (substring first, then edit distance). */
|
|
7
|
+
export declare function closestSkillNames(name: string, names: string[]): string[];
|
package/dist/tools/skill.js
CHANGED
|
@@ -22,7 +22,7 @@ export function createSkillTool(registry) {
|
|
|
22
22
|
name: "skill",
|
|
23
23
|
readOnly: true,
|
|
24
24
|
effect: "read",
|
|
25
|
-
description: "Load a
|
|
25
|
+
description: "Load a skill by its exact name, as listed in the Skills section of the system prompt. Returns the skill's instructions; follow them for the task.",
|
|
26
26
|
parameters: {
|
|
27
27
|
type: "object",
|
|
28
28
|
properties: {
|
|
@@ -38,8 +38,11 @@ export function createSkillTool(registry) {
|
|
|
38
38
|
}
|
|
39
39
|
const skill = registry.get(name);
|
|
40
40
|
if (!skill) {
|
|
41
|
+
const close = closestSkillNames(name, registry.summaries().map((s) => s.name));
|
|
41
42
|
return {
|
|
42
|
-
content: `Error: Unknown skill "${name}"
|
|
43
|
+
content: `Error: Unknown skill "${name}".` +
|
|
44
|
+
(close.length ? ` Did you mean: ${close.join(", ")}?` : "") +
|
|
45
|
+
" Use the exact name from the Skills section.",
|
|
43
46
|
isError: true,
|
|
44
47
|
};
|
|
45
48
|
}
|
|
@@ -47,3 +50,32 @@ export function createSkillTool(registry) {
|
|
|
47
50
|
},
|
|
48
51
|
};
|
|
49
52
|
}
|
|
53
|
+
/** Up to three known names closest to a mistyped one (substring first, then edit distance). */
|
|
54
|
+
export function closestSkillNames(name, names) {
|
|
55
|
+
const target = name.toLowerCase();
|
|
56
|
+
const scored = names.map((candidate) => {
|
|
57
|
+
const lower = candidate.toLowerCase();
|
|
58
|
+
// Substring matches only for real fragments; a 1–2 letter typo would match everything.
|
|
59
|
+
const contains = target.length >= 3 && (lower.includes(target) || target.includes(lower));
|
|
60
|
+
return { candidate, score: contains ? 0 : editDistance(target, lower) };
|
|
61
|
+
});
|
|
62
|
+
const limit = Math.max(2, Math.floor(target.length / 3));
|
|
63
|
+
return scored
|
|
64
|
+
.filter((s) => s.score <= limit)
|
|
65
|
+
.sort((a, b) => a.score - b.score || a.candidate.localeCompare(b.candidate))
|
|
66
|
+
.slice(0, 3)
|
|
67
|
+
.map((s) => s.candidate);
|
|
68
|
+
}
|
|
69
|
+
function editDistance(a, b) {
|
|
70
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
71
|
+
for (let i = 1; i <= a.length; i++) {
|
|
72
|
+
let prev = row[0];
|
|
73
|
+
row[0] = i;
|
|
74
|
+
for (let j = 1; j <= b.length; j++) {
|
|
75
|
+
const cur = row[j];
|
|
76
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
77
|
+
prev = cur;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return row[b.length];
|
|
81
|
+
}
|
package/dist/tui/app.js
CHANGED
|
@@ -33,6 +33,7 @@ import { SkillsPanelComponent } from "./components/skills-panel.js";
|
|
|
33
33
|
import { SessionPickerComponent } from "./components/session-picker.js";
|
|
34
34
|
import { ComposerImagePreviewComponent, ImageViewerComponent } from "./components/image-preview.js";
|
|
35
35
|
import { registry as slashRegistry } from "../slash-commands/index.js";
|
|
36
|
+
import { syncSystemPrompt } from "../slash-commands/commands.js";
|
|
36
37
|
import { collectUsageStatsBundle } from "../stats/usage.js";
|
|
37
38
|
import { ComposerController, } from "./controller/composer-controller.js";
|
|
38
39
|
import { OverlayRequestController } from "./controller/overlay-controller.js";
|
|
@@ -208,6 +209,7 @@ export class PiTuiApp {
|
|
|
208
209
|
groups: this.options.controller.getSubagentGroups?.() ?? [],
|
|
209
210
|
workflows: this.options.controller.getWorkflows?.() ?? [],
|
|
210
211
|
tasks: this.options.controller.getBackgroundTasks?.() ?? [],
|
|
212
|
+
turnStartedAt: this.options.controller.getTurnStartedAt?.(),
|
|
211
213
|
}), () => this.tui.terminal.rows, {
|
|
212
214
|
// Pane navigation only changes local presentation state. Reprojecting
|
|
213
215
|
// the live trace/Markdown here is both unnecessary and expensive.
|
|
@@ -853,8 +855,12 @@ export class PiTuiApp {
|
|
|
853
855
|
getTerminalRows: () => this.tui.terminal.rows,
|
|
854
856
|
onClose: () => handle?.hide(),
|
|
855
857
|
onRender: () => this.renderSnapshot(),
|
|
858
|
+
getCatalogChars: () => this.options.agent.getSkillCatalogChars?.(),
|
|
856
859
|
onSkillsChanged: () => {
|
|
857
860
|
this.options.agent.setSkillSummaries(skillRegistry.summaries());
|
|
861
|
+
// The Skills catalog lives in the system prompt; rebuild it so the next turn sees the change.
|
|
862
|
+
const ctx = this.buildSlashContext();
|
|
863
|
+
syncSystemPrompt(ctx, ctx.agent.model ?? "");
|
|
858
864
|
this.editor.refreshAutocomplete();
|
|
859
865
|
this.renderSnapshot();
|
|
860
866
|
},
|
|
@@ -6,6 +6,8 @@ export interface SkillsPanelComponentOptions {
|
|
|
6
6
|
onClose(): void;
|
|
7
7
|
onRender(): void;
|
|
8
8
|
onSkillsChanged(): void;
|
|
9
|
+
/** Catalog budget in characters (the agent's setting); undefined uses the default. */
|
|
10
|
+
getCatalogChars?(): number | undefined;
|
|
9
11
|
theme?: Theme;
|
|
10
12
|
}
|
|
11
13
|
/** Grok-style centered Skill browser/manager opened by /skills. */
|
|
@@ -37,6 +39,8 @@ export declare class SkillsPanelComponent implements Component, Focusable {
|
|
|
37
39
|
handleMouse(event: TuiMouseEvent): boolean;
|
|
38
40
|
invalidate(): void;
|
|
39
41
|
private frameLine;
|
|
42
|
+
/** How much of the always-present catalog the enabled skills use, so users know when to prune. */
|
|
43
|
+
private catalogSummary;
|
|
40
44
|
private renderSearch;
|
|
41
45
|
private renderFooter;
|
|
42
46
|
private buildRows;
|
|
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { Input, decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@bubblebrain-ai/pi-tui";
|
|
5
|
+
import { buildSkillCatalog } from "../../skills/format.js";
|
|
5
6
|
import { parseTerminalMouseWheel } from "../model/terminal-mouse.js";
|
|
6
7
|
import { darkTheme } from "../model/theme.js";
|
|
7
8
|
import { themeBackground, themeDim, themeForeground } from "../model/theme-style.js";
|
|
@@ -116,7 +117,7 @@ export class SkillsPanelComponent {
|
|
|
116
117
|
const top = themeForeground(theme.border, `┌${"─".repeat(topDashCount)}`) + ` ${close} ` + themeForeground(theme.border, "┐");
|
|
117
118
|
const bottom = themeForeground(theme.border, `└${horizontal}┘`);
|
|
118
119
|
const separator = themeForeground(theme.border, `├${horizontal}┤`);
|
|
119
|
-
const tab = this.frameLine(` ${chalk.bold(themeForeground(theme.inputText, themeBackground(theme.traceHoverBg, " Skills ")))}`, innerWidth);
|
|
120
|
+
const tab = this.frameLine(` ${chalk.bold(themeForeground(theme.inputText, themeBackground(theme.traceHoverBg, " Skills ")))} ${themeForeground(theme.muted, this.catalogSummary())}`, innerWidth);
|
|
120
121
|
const search = this.frameLine(this.renderSearch(innerWidth), innerWidth);
|
|
121
122
|
const footerRows = this.searchActive ? 0 : 2;
|
|
122
123
|
this.bodyRows = Math.max(1, frameHeight - 5 - footerRows);
|
|
@@ -284,6 +285,19 @@ export class SkillsPanelComponent {
|
|
|
284
285
|
const theme = this.options.theme ?? darkTheme;
|
|
285
286
|
return `${themeForeground(theme.border, "│")}${themeBackground(theme.backgroundPanel, fit(content, width))}${themeForeground(theme.border, "│")}`;
|
|
286
287
|
}
|
|
288
|
+
/** How much of the always-present catalog the enabled skills use, so users know when to prune. */
|
|
289
|
+
catalogSummary() {
|
|
290
|
+
const catalog = buildSkillCatalog(this.registry.summaries(), { budgetChars: this.options.getCatalogChars?.() });
|
|
291
|
+
if (catalog.budgetChars === 0)
|
|
292
|
+
return "catalog off · skill_search only";
|
|
293
|
+
const duplicates = this.registry.getDiagnostics().filter((d) => d.message.startsWith("Duplicate skill name")).length;
|
|
294
|
+
return [
|
|
295
|
+
`catalog ${catalog.chars.toLocaleString("en-US")}/${catalog.budgetChars.toLocaleString("en-US")} chars`,
|
|
296
|
+
`${catalog.listed} listed`,
|
|
297
|
+
catalog.unlisted ? `${catalog.unlisted} not listed` : "",
|
|
298
|
+
duplicates ? `${duplicates} duplicate${duplicates === 1 ? "" : "s"} ignored` : "",
|
|
299
|
+
].filter(Boolean).join(" · ");
|
|
300
|
+
}
|
|
287
301
|
renderSearch(innerWidth) {
|
|
288
302
|
const theme = this.options.theme ?? darkTheme;
|
|
289
303
|
const filter = filterLabel(this.filter);
|
|
@@ -7,6 +7,8 @@ export interface TasksPaneSnapshot {
|
|
|
7
7
|
groups: SubagentGroup[];
|
|
8
8
|
workflows: WorkflowRunSnapshot[];
|
|
9
9
|
tasks: BackgroundTaskInfo[];
|
|
10
|
+
/** Start of the current agent run; undefined keeps finished work hidden. */
|
|
11
|
+
turnStartedAt?: number;
|
|
10
12
|
}
|
|
11
13
|
export interface WorkflowPaneItem {
|
|
12
14
|
kind: "workflow";
|
|
@@ -51,10 +53,25 @@ export declare class TasksPaneComponent implements Component {
|
|
|
51
53
|
focused: boolean;
|
|
52
54
|
private open;
|
|
53
55
|
private manuallyClosed;
|
|
56
|
+
/**
|
|
57
|
+
* History is a view the user opts into for one look, never a mode: it is
|
|
58
|
+
* dropped whenever the pane closes and when a new round of activity starts.
|
|
59
|
+
* Otherwise one Ctrl+G after a finished turn left every earlier subagent
|
|
60
|
+
* listed above the composer for the rest of the session, next to a header
|
|
61
|
+
* that counts only the running ones.
|
|
62
|
+
*/
|
|
54
63
|
private showHistory;
|
|
55
64
|
private selectedId?;
|
|
65
|
+
/**
|
|
66
|
+
* Whether the user picked the selection (keys or click). The pane always
|
|
67
|
+
* needs one, so it defaults to the first row — but a default nobody chose
|
|
68
|
+
* must not steer the window: a task that was once the only row stayed
|
|
69
|
+
* "selected" and scrolled later subagents out of an untouched pane.
|
|
70
|
+
*/
|
|
71
|
+
private selectionIsUsers;
|
|
56
72
|
private hoveredId?;
|
|
57
73
|
private lastActiveCount;
|
|
74
|
+
private lastTurnStartedAt;
|
|
58
75
|
private lastRows;
|
|
59
76
|
private allRows;
|
|
60
77
|
private frame;
|
|
@@ -67,11 +84,32 @@ export declare class TasksPaneComponent implements Component {
|
|
|
67
84
|
isOpen(): boolean;
|
|
68
85
|
isAvailable(): boolean;
|
|
69
86
|
activeCount(): number;
|
|
87
|
+
/** Rows the default view lists: running work plus what this turn launched. */
|
|
88
|
+
defaultViewCount(): number;
|
|
89
|
+
visibleCount(): number;
|
|
90
|
+
/**
|
|
91
|
+
* Finished rows the pane lists next to the running ones, by outcome. Counted
|
|
92
|
+
* in the pane's current view, history included when it is showing, so the
|
|
93
|
+
* header never reports fewer rows than the list holds.
|
|
94
|
+
*/
|
|
95
|
+
settledCounts(): {
|
|
96
|
+
done: number;
|
|
97
|
+
failed: number;
|
|
98
|
+
stopped: number;
|
|
99
|
+
};
|
|
70
100
|
totalCount(): number;
|
|
71
101
|
activityGlyph(): string;
|
|
72
102
|
theme(): Theme;
|
|
73
103
|
toggle(forceFocus?: boolean): void;
|
|
74
104
|
close(): void;
|
|
105
|
+
/**
|
|
106
|
+
* Advance open/history state from the latest snapshot. Both the status bar
|
|
107
|
+
* and the pane call this before rendering: the bar is laid out first, so a
|
|
108
|
+
* transition made only inside the pane's render would leave the bar one
|
|
109
|
+
* frame behind — forever, when nothing is animating to trigger a redraw.
|
|
110
|
+
* Idempotent per snapshot (it compares against the last values it saw).
|
|
111
|
+
*/
|
|
112
|
+
syncState(): void;
|
|
75
113
|
render(width: number): string[];
|
|
76
114
|
private renderItem;
|
|
77
115
|
handleInput(data: string): void;
|