mini-coder 0.5.2 → 0.5.3
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/package.json +1 -1
- package/src/agent.ts +38 -15
- package/src/git.ts +57 -7
- package/src/tools.ts +668 -4
- package/src/ui/conversation.test.ts +49 -14
- package/src/ui/conversation.ts +1 -1
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -525,19 +525,26 @@ async function executeToolCall(
|
|
|
525
525
|
|
|
526
526
|
let result: ToolExecResult;
|
|
527
527
|
try {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
528
|
+
if (opts.signal?.aborted) {
|
|
529
|
+
result = toolErrorResult(
|
|
530
|
+
toolCall.name,
|
|
531
|
+
new Error("This operation was aborted"),
|
|
532
|
+
);
|
|
533
|
+
} else {
|
|
534
|
+
result = await handler(
|
|
535
|
+
toolCall.arguments,
|
|
536
|
+
opts.cwd,
|
|
537
|
+
opts.signal,
|
|
538
|
+
(partial) => {
|
|
539
|
+
opts.onEvent?.({
|
|
540
|
+
type: "tool_delta",
|
|
541
|
+
toolCallId: toolCall.id,
|
|
542
|
+
name: toolCall.name,
|
|
543
|
+
result: partial,
|
|
544
|
+
});
|
|
545
|
+
},
|
|
546
|
+
);
|
|
547
|
+
}
|
|
541
548
|
} catch (error) {
|
|
542
549
|
result = toolErrorResult(toolCall.name, error);
|
|
543
550
|
}
|
|
@@ -592,8 +599,17 @@ function appendToolResultMessage(
|
|
|
592
599
|
export async function runAgentLoop(
|
|
593
600
|
opts: RunAgentOpts,
|
|
594
601
|
): Promise<AgentLoopResult> {
|
|
595
|
-
const {
|
|
596
|
-
|
|
602
|
+
const {
|
|
603
|
+
db,
|
|
604
|
+
sessionId,
|
|
605
|
+
turn,
|
|
606
|
+
model,
|
|
607
|
+
messages,
|
|
608
|
+
signal,
|
|
609
|
+
onEvent,
|
|
610
|
+
toolHandlers,
|
|
611
|
+
cwd,
|
|
612
|
+
} = opts;
|
|
597
613
|
|
|
598
614
|
while (true) {
|
|
599
615
|
const assistantMessage = await streamAssistantMessage(opts);
|
|
@@ -633,6 +649,13 @@ export async function runAgentLoop(
|
|
|
633
649
|
);
|
|
634
650
|
|
|
635
651
|
if (signal?.aborted) {
|
|
652
|
+
onEvent?.({
|
|
653
|
+
type: "aborted",
|
|
654
|
+
message: buildIncompleteAssistantMessage(
|
|
655
|
+
{ model, signal },
|
|
656
|
+
assistantMessage,
|
|
657
|
+
),
|
|
658
|
+
});
|
|
636
659
|
return { messages, stopReason: "aborted" };
|
|
637
660
|
}
|
|
638
661
|
}
|
package/src/git.ts
CHANGED
|
@@ -61,6 +61,51 @@ async function run(
|
|
|
61
61
|
return trim ? out.trim() : out;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
function getErrorStringProperty(
|
|
65
|
+
error: unknown,
|
|
66
|
+
key: string,
|
|
67
|
+
): string | undefined {
|
|
68
|
+
if (typeof error !== "object" || error === null) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const value = Reflect.get(error, key);
|
|
73
|
+
return typeof value === "string" ? value : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isMissingGitError(error: unknown): boolean {
|
|
77
|
+
const code = getErrorStringProperty(error, "code");
|
|
78
|
+
if (code !== "ENOENT") {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const message = getErrorStringProperty(error, "message");
|
|
83
|
+
return (
|
|
84
|
+
typeof message === "string" &&
|
|
85
|
+
message.includes('Executable not found in $PATH: "git"')
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function safeRun(
|
|
90
|
+
runGit: (
|
|
91
|
+
args: string[],
|
|
92
|
+
cwd: string,
|
|
93
|
+
trim?: boolean,
|
|
94
|
+
) => Promise<string | null>,
|
|
95
|
+
args: string[],
|
|
96
|
+
cwd: string,
|
|
97
|
+
trim = true,
|
|
98
|
+
): Promise<string | null> {
|
|
99
|
+
try {
|
|
100
|
+
return await runGit(args, cwd, trim);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (isMissingGitError(error)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
64
109
|
function isUntrackedStatus(
|
|
65
110
|
indexStatus: string,
|
|
66
111
|
workingTreeStatus: string,
|
|
@@ -145,8 +190,8 @@ export function parseGitAheadBehind(output: string): {
|
|
|
145
190
|
* Gather the current git state for a directory.
|
|
146
191
|
*
|
|
147
192
|
* Runs several fast git commands in parallel to collect branch, working
|
|
148
|
-
* tree status, and ahead/behind counts. Returns `null` if
|
|
149
|
-
* is not inside a git repository.
|
|
193
|
+
* tree status, and ahead/behind counts. Returns `null` if git is not
|
|
194
|
+
* installed or the directory is not inside a git repository.
|
|
150
195
|
*
|
|
151
196
|
* @param cwd - The directory to query (can be a subdirectory of the repo).
|
|
152
197
|
* @param opts - Optional runtime overrides used by tests.
|
|
@@ -165,18 +210,23 @@ export async function getGitState(
|
|
|
165
210
|
const exec = opts?.run ?? run;
|
|
166
211
|
|
|
167
212
|
// Check if we're in a repo and get the root
|
|
168
|
-
const root = await exec
|
|
213
|
+
const root = await safeRun(exec, ["rev-parse", "--show-toplevel"], cwd);
|
|
169
214
|
if (root === null) return null;
|
|
170
215
|
|
|
171
216
|
// Run remaining commands in parallel
|
|
172
217
|
const [branch, upstream, status, revList] = await Promise.all([
|
|
173
|
-
exec
|
|
174
|
-
|
|
218
|
+
safeRun(exec, ["branch", "--show-current"], cwd),
|
|
219
|
+
safeRun(
|
|
220
|
+
exec,
|
|
175
221
|
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
|
176
222
|
cwd,
|
|
177
223
|
),
|
|
178
|
-
exec
|
|
179
|
-
|
|
224
|
+
safeRun(exec, ["status", "--porcelain"], cwd, false),
|
|
225
|
+
safeRun(
|
|
226
|
+
exec,
|
|
227
|
+
["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
|
|
228
|
+
cwd,
|
|
229
|
+
),
|
|
180
230
|
]);
|
|
181
231
|
|
|
182
232
|
const { staged, modified, untracked } = parseGitStatus(status ?? "");
|
package/src/tools.ts
CHANGED
|
@@ -57,6 +57,320 @@ function normalizeLineEndings(
|
|
|
57
57
|
return content.replace(/\r\n/g, "\n");
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
const MAX_EDIT_ERROR_MATCHES = 3;
|
|
61
|
+
const MAX_EDIT_ERROR_SNIPPET_LINES = 8;
|
|
62
|
+
const MAX_EDIT_ERROR_SNIPPET_LINE_CHARS = 160;
|
|
63
|
+
const MIN_EDIT_SIMILARITY_SCORE = 0.45;
|
|
64
|
+
|
|
65
|
+
interface EditSnippet {
|
|
66
|
+
startLine: number;
|
|
67
|
+
endLine: number;
|
|
68
|
+
lines: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function splitDisplayLines(content: string): string[] {
|
|
72
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
73
|
+
if (lines.at(-1) === "") {
|
|
74
|
+
lines.pop();
|
|
75
|
+
}
|
|
76
|
+
return lines;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function countDisplayLines(content: string): number {
|
|
80
|
+
return Math.max(splitDisplayLines(content).length, 1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatLineRange(startLine: number, endLine: number): string {
|
|
84
|
+
return startLine === endLine
|
|
85
|
+
? `line ${startLine}`
|
|
86
|
+
: `lines ${startLine}-${endLine}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function truncateSnippetLine(line: string): string {
|
|
90
|
+
if (line.length <= MAX_EDIT_ERROR_SNIPPET_LINE_CHARS) {
|
|
91
|
+
return line;
|
|
92
|
+
}
|
|
93
|
+
return `${line.slice(0, MAX_EDIT_ERROR_SNIPPET_LINE_CHARS - 1)}…`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatSnippetLines(lines: readonly string[]): string {
|
|
97
|
+
const visibleLines = lines.slice(0, MAX_EDIT_ERROR_SNIPPET_LINES);
|
|
98
|
+
const formatted = visibleLines
|
|
99
|
+
.map((line) => ` ${truncateSnippetLine(line)}`)
|
|
100
|
+
.join("\n");
|
|
101
|
+
const hiddenLineCount = lines.length - visibleLines.length;
|
|
102
|
+
if (hiddenLineCount <= 0) {
|
|
103
|
+
return formatted;
|
|
104
|
+
}
|
|
105
|
+
return `${formatted}\n … ${hiddenLineCount} more lines`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function commonPrefixLength(a: string, b: string): number {
|
|
109
|
+
let index = 0;
|
|
110
|
+
const maxLength = Math.min(a.length, b.length);
|
|
111
|
+
while (index < maxLength && a[index] === b[index]) {
|
|
112
|
+
index++;
|
|
113
|
+
}
|
|
114
|
+
return index;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function commonSuffixLength(
|
|
118
|
+
a: string,
|
|
119
|
+
b: string,
|
|
120
|
+
prefixLength: number,
|
|
121
|
+
): number {
|
|
122
|
+
let index = 0;
|
|
123
|
+
const maxLength = Math.min(a.length, b.length) - prefixLength;
|
|
124
|
+
while (
|
|
125
|
+
index < maxLength &&
|
|
126
|
+
a[a.length - 1 - index] === b[b.length - 1 - index]
|
|
127
|
+
) {
|
|
128
|
+
index++;
|
|
129
|
+
}
|
|
130
|
+
return index;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function scoreSimilarLine(oldLine: string, candidateLine: string): number {
|
|
134
|
+
if (oldLine === candidateLine) {
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const normalizedOldLine = oldLine.trim();
|
|
139
|
+
const normalizedCandidateLine = candidateLine.trim();
|
|
140
|
+
if (normalizedOldLine === normalizedCandidateLine) {
|
|
141
|
+
return normalizedOldLine === "" ? 1 : 0.98;
|
|
142
|
+
}
|
|
143
|
+
if (normalizedOldLine === "" || normalizedCandidateLine === "") {
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const prefixLength = commonPrefixLength(
|
|
148
|
+
normalizedOldLine,
|
|
149
|
+
normalizedCandidateLine,
|
|
150
|
+
);
|
|
151
|
+
const suffixLength = commonSuffixLength(
|
|
152
|
+
normalizedOldLine,
|
|
153
|
+
normalizedCandidateLine,
|
|
154
|
+
prefixLength,
|
|
155
|
+
);
|
|
156
|
+
const overlapLength = Math.min(
|
|
157
|
+
normalizedOldLine.length,
|
|
158
|
+
prefixLength + suffixLength,
|
|
159
|
+
);
|
|
160
|
+
const maxLength = Math.max(
|
|
161
|
+
normalizedOldLine.length,
|
|
162
|
+
normalizedCandidateLine.length,
|
|
163
|
+
);
|
|
164
|
+
const structuralScore = overlapLength / maxLength;
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
normalizedOldLine.includes(normalizedCandidateLine) ||
|
|
168
|
+
normalizedCandidateLine.includes(normalizedOldLine)
|
|
169
|
+
) {
|
|
170
|
+
const sharedLength = Math.min(
|
|
171
|
+
normalizedOldLine.length,
|
|
172
|
+
normalizedCandidateLine.length,
|
|
173
|
+
);
|
|
174
|
+
return Math.max(structuralScore, sharedLength / maxLength);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return structuralScore;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function scoreLineWindow(
|
|
181
|
+
oldLines: readonly string[],
|
|
182
|
+
candidateLines: readonly string[],
|
|
183
|
+
): number {
|
|
184
|
+
const maxLineCount = Math.max(oldLines.length, candidateLines.length);
|
|
185
|
+
let weightedScore = 0;
|
|
186
|
+
let totalWeight = 0;
|
|
187
|
+
|
|
188
|
+
for (let index = 0; index < maxLineCount; index++) {
|
|
189
|
+
const oldLine = oldLines[index] ?? "";
|
|
190
|
+
const candidateLine = candidateLines[index] ?? "";
|
|
191
|
+
const weight = Math.max(
|
|
192
|
+
oldLine.trim().length,
|
|
193
|
+
candidateLine.trim().length,
|
|
194
|
+
1,
|
|
195
|
+
);
|
|
196
|
+
weightedScore += scoreSimilarLine(oldLine, candidateLine) * weight;
|
|
197
|
+
totalWeight += weight;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return totalWeight === 0 ? 0 : weightedScore / totalWeight;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function findClosestEditSnippets(
|
|
204
|
+
oldText: string,
|
|
205
|
+
content: string,
|
|
206
|
+
): EditSnippet[] {
|
|
207
|
+
const oldLines = splitDisplayLines(oldText);
|
|
208
|
+
const fileLines = splitDisplayLines(content);
|
|
209
|
+
if (fileLines.length === 0) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const windowSizes = Array.from(
|
|
214
|
+
new Set([
|
|
215
|
+
Math.max(1, oldLines.length - 1),
|
|
216
|
+
Math.max(1, oldLines.length),
|
|
217
|
+
Math.min(fileLines.length, oldLines.length + 1),
|
|
218
|
+
]),
|
|
219
|
+
);
|
|
220
|
+
const candidates: (EditSnippet & { score: number })[] = [];
|
|
221
|
+
|
|
222
|
+
for (const windowSize of windowSizes) {
|
|
223
|
+
if (windowSize > fileLines.length) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (
|
|
228
|
+
let startLineIndex = 0;
|
|
229
|
+
startLineIndex <= fileLines.length - windowSize;
|
|
230
|
+
startLineIndex++
|
|
231
|
+
) {
|
|
232
|
+
const lines = fileLines.slice(
|
|
233
|
+
startLineIndex,
|
|
234
|
+
startLineIndex + windowSize,
|
|
235
|
+
);
|
|
236
|
+
candidates.push({
|
|
237
|
+
startLine: startLineIndex + 1,
|
|
238
|
+
endLine: startLineIndex + windowSize,
|
|
239
|
+
lines,
|
|
240
|
+
score: scoreLineWindow(oldLines, lines),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
candidates.sort((a, b) => {
|
|
246
|
+
const scoreDelta = b.score - a.score;
|
|
247
|
+
if (scoreDelta !== 0) {
|
|
248
|
+
return scoreDelta;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const lineSpanDelta =
|
|
252
|
+
Math.abs(a.lines.length - oldLines.length) -
|
|
253
|
+
Math.abs(b.lines.length - oldLines.length);
|
|
254
|
+
if (lineSpanDelta !== 0) {
|
|
255
|
+
return lineSpanDelta;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return a.startLine - b.startLine;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const snippets: EditSnippet[] = [];
|
|
262
|
+
const seen = new Set<string>();
|
|
263
|
+
for (const candidate of candidates) {
|
|
264
|
+
if (candidate.score < MIN_EDIT_SIMILARITY_SCORE) {
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const key = `${candidate.startLine}:${candidate.endLine}`;
|
|
269
|
+
if (seen.has(key)) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
snippets.push({
|
|
274
|
+
startLine: candidate.startLine,
|
|
275
|
+
endLine: candidate.endLine,
|
|
276
|
+
lines: candidate.lines,
|
|
277
|
+
});
|
|
278
|
+
seen.add(key);
|
|
279
|
+
|
|
280
|
+
if (snippets.length === MAX_EDIT_ERROR_MATCHES) {
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return snippets;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function buildLineStarts(content: string): number[] {
|
|
289
|
+
const lineStarts = [0];
|
|
290
|
+
for (let index = 0; index < content.length; index++) {
|
|
291
|
+
if (content[index] === "\n") {
|
|
292
|
+
lineStarts.push(index + 1);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return lineStarts;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function findLineNumber(lineStarts: readonly number[], index: number): number {
|
|
299
|
+
let low = 0;
|
|
300
|
+
let high = lineStarts.length - 1;
|
|
301
|
+
|
|
302
|
+
while (low <= high) {
|
|
303
|
+
const mid = Math.floor((low + high) / 2);
|
|
304
|
+
const lineStart = lineStarts[mid];
|
|
305
|
+
if (lineStart === undefined) {
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
if (lineStart <= index) {
|
|
309
|
+
low = mid + 1;
|
|
310
|
+
} else {
|
|
311
|
+
high = mid - 1;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return high + 1;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function formatEditNotFoundError(
|
|
319
|
+
path: string,
|
|
320
|
+
oldText: string,
|
|
321
|
+
content: string,
|
|
322
|
+
): string {
|
|
323
|
+
const snippets = findClosestEditSnippets(oldText, content);
|
|
324
|
+
if (snippets.length === 0) {
|
|
325
|
+
return `Old text not found in ${path}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return [
|
|
329
|
+
`Old text not found in ${path}`,
|
|
330
|
+
"Closest matches:",
|
|
331
|
+
...snippets.map(
|
|
332
|
+
(snippet) =>
|
|
333
|
+
`- ${formatLineRange(snippet.startLine, snippet.endLine)}\n${formatSnippetLines(snippet.lines)}`,
|
|
334
|
+
),
|
|
335
|
+
].join("\n");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function formatEditMultipleMatchesError(
|
|
339
|
+
path: string,
|
|
340
|
+
oldText: string,
|
|
341
|
+
content: string,
|
|
342
|
+
matchIndices: readonly number[],
|
|
343
|
+
totalMatches: number,
|
|
344
|
+
): string {
|
|
345
|
+
const lineStarts = buildLineStarts(content);
|
|
346
|
+
const fileLines = splitDisplayLines(content);
|
|
347
|
+
const matchLineCount = countDisplayLines(oldText);
|
|
348
|
+
const snippets = matchIndices.map((matchIndex) => {
|
|
349
|
+
const startLine = findLineNumber(lineStarts, matchIndex);
|
|
350
|
+
const endLine = startLine + matchLineCount - 1;
|
|
351
|
+
return {
|
|
352
|
+
startLine,
|
|
353
|
+
endLine,
|
|
354
|
+
lines: fileLines.slice(startLine - 1, endLine),
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const lines = [
|
|
359
|
+
`Old text matches multiple locations (${totalMatches}) in ${path}`,
|
|
360
|
+
"Matches:",
|
|
361
|
+
...snippets.map(
|
|
362
|
+
(snippet) =>
|
|
363
|
+
`- ${formatLineRange(snippet.startLine, snippet.endLine)}\n${formatSnippetLines(snippet.lines)}`,
|
|
364
|
+
),
|
|
365
|
+
];
|
|
366
|
+
const hiddenMatchCount = totalMatches - matchIndices.length;
|
|
367
|
+
if (hiddenMatchCount > 0) {
|
|
368
|
+
lines.push(`- … ${hiddenMatchCount} more matches`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return lines.join("\n");
|
|
372
|
+
}
|
|
373
|
+
|
|
60
374
|
// ---------------------------------------------------------------------------
|
|
61
375
|
// edit
|
|
62
376
|
// ---------------------------------------------------------------------------
|
|
@@ -106,20 +420,33 @@ export function executeEdit(args: EditArgs, cwd: string): ToolExecResult {
|
|
|
106
420
|
|
|
107
421
|
// Count occurrences
|
|
108
422
|
let count = 0;
|
|
423
|
+
const matchIndices: number[] = [];
|
|
109
424
|
let idx = 0;
|
|
110
425
|
while (true) {
|
|
111
426
|
idx = content.indexOf(args.oldText, idx);
|
|
112
427
|
if (idx === -1) break;
|
|
113
428
|
count++;
|
|
429
|
+
if (matchIndices.length < MAX_EDIT_ERROR_MATCHES) {
|
|
430
|
+
matchIndices.push(idx);
|
|
431
|
+
}
|
|
114
432
|
idx += args.oldText.length;
|
|
115
433
|
}
|
|
116
434
|
|
|
117
435
|
if (count === 0) {
|
|
118
|
-
return textResult(
|
|
436
|
+
return textResult(
|
|
437
|
+
formatEditNotFoundError(args.path, args.oldText, content),
|
|
438
|
+
true,
|
|
439
|
+
);
|
|
119
440
|
}
|
|
120
441
|
if (count > 1) {
|
|
121
442
|
return textResult(
|
|
122
|
-
|
|
443
|
+
formatEditMultipleMatchesError(
|
|
444
|
+
args.path,
|
|
445
|
+
args.oldText,
|
|
446
|
+
content,
|
|
447
|
+
matchIndices,
|
|
448
|
+
count,
|
|
449
|
+
),
|
|
123
450
|
true,
|
|
124
451
|
);
|
|
125
452
|
}
|
|
@@ -129,7 +456,14 @@ export function executeEdit(args: EditArgs, cwd: string): ToolExecResult {
|
|
|
129
456
|
const newText = lineEnding
|
|
130
457
|
? normalizeLineEndings(args.newText, lineEnding)
|
|
131
458
|
: args.newText;
|
|
132
|
-
const
|
|
459
|
+
const matchIndex = matchIndices[0];
|
|
460
|
+
if (matchIndex === undefined) {
|
|
461
|
+
return textResult(`Old text not found in ${args.path}`, true);
|
|
462
|
+
}
|
|
463
|
+
const updated =
|
|
464
|
+
content.slice(0, matchIndex) +
|
|
465
|
+
newText +
|
|
466
|
+
content.slice(matchIndex + args.oldText.length);
|
|
133
467
|
writeFileSync(filePath, updated, "utf-8");
|
|
134
468
|
return textResult(`Edited ${args.path}`, false);
|
|
135
469
|
}
|
|
@@ -174,6 +508,335 @@ function formatShellOutput(stdout: string, stderr: string): string {
|
|
|
174
508
|
return "";
|
|
175
509
|
}
|
|
176
510
|
|
|
511
|
+
interface ShellCommandLines {
|
|
512
|
+
lines: string[];
|
|
513
|
+
lineEnding: "\n" | "\r\n";
|
|
514
|
+
hasTrailingLineEnding: boolean;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
interface PendingHeredoc {
|
|
518
|
+
startLineIndex: number;
|
|
519
|
+
delimiter: string;
|
|
520
|
+
stripLeadingTabs: boolean;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
interface ShellQuoteState {
|
|
524
|
+
quote: "'" | '"' | null;
|
|
525
|
+
escaped: boolean;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function splitShellCommandLines(command: string): ShellCommandLines {
|
|
529
|
+
const lineEnding = detectLineEnding(command) ?? "\n";
|
|
530
|
+
const normalized = normalizeLineEndings(command, "\n");
|
|
531
|
+
const hasTrailingLineEnding = normalized.endsWith("\n");
|
|
532
|
+
const lines = normalized.split("\n");
|
|
533
|
+
if (hasTrailingLineEnding) {
|
|
534
|
+
lines.pop();
|
|
535
|
+
}
|
|
536
|
+
return { lines, lineEnding, hasTrailingLineEnding };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function joinShellCommandLines(parts: ShellCommandLines): string {
|
|
540
|
+
const joined = parts.lines.join(parts.lineEnding);
|
|
541
|
+
if (parts.hasTrailingLineEnding) {
|
|
542
|
+
return joined + parts.lineEnding;
|
|
543
|
+
}
|
|
544
|
+
return joined;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function advanceShellQuoteState(char: string, state: ShellQuoteState): boolean {
|
|
548
|
+
if (state.quote === "'") {
|
|
549
|
+
if (char === "'") {
|
|
550
|
+
state.quote = null;
|
|
551
|
+
}
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (state.quote === '"') {
|
|
556
|
+
if (state.escaped) {
|
|
557
|
+
state.escaped = false;
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
if (char === "\\") {
|
|
561
|
+
state.escaped = true;
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
if (char === '"') {
|
|
565
|
+
state.quote = null;
|
|
566
|
+
}
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (char === "'") {
|
|
571
|
+
state.quote = "'";
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
if (char === '"') {
|
|
575
|
+
state.quote = '"';
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function isHeredocPrefixCharacter(char: string): boolean {
|
|
583
|
+
return (
|
|
584
|
+
char === "" ||
|
|
585
|
+
char === " " ||
|
|
586
|
+
char === "\t" ||
|
|
587
|
+
char === ";" ||
|
|
588
|
+
char === "(" ||
|
|
589
|
+
char === "&" ||
|
|
590
|
+
char === "|"
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function getHeredocStartAt(
|
|
595
|
+
line: string,
|
|
596
|
+
index: number,
|
|
597
|
+
): { index: number; stripLeadingTabs: boolean } | null {
|
|
598
|
+
if (line[index] !== "<" || line[index + 1] !== "<") {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const previousChar = index === 0 ? "" : (line[index - 1] ?? "");
|
|
603
|
+
if (!isHeredocPrefixCharacter(previousChar)) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
index,
|
|
609
|
+
stripLeadingTabs: line[index + 2] === "-",
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function findUnquotedHeredocStart(
|
|
614
|
+
line: string,
|
|
615
|
+
): { index: number; stripLeadingTabs: boolean } | null {
|
|
616
|
+
const quoteState: ShellQuoteState = { quote: null, escaped: false };
|
|
617
|
+
let heredocStart: { index: number; stripLeadingTabs: boolean } | null = null;
|
|
618
|
+
|
|
619
|
+
for (let index = 0; index < line.length - 1; index++) {
|
|
620
|
+
const char = line[index];
|
|
621
|
+
if (char === undefined || advanceShellQuoteState(char, quoteState)) {
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const nextHeredocStart = getHeredocStartAt(line, index);
|
|
626
|
+
if (!nextHeredocStart) {
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (heredocStart) {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
heredocStart = nextHeredocStart;
|
|
634
|
+
index += heredocStart.stripLeadingTabs ? 2 : 1;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return heredocStart;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function skipHeredocDelimiterWhitespace(line: string, cursor: number): number {
|
|
641
|
+
let nextCursor = cursor;
|
|
642
|
+
while (line[nextCursor] === " " || line[nextCursor] === "\t") {
|
|
643
|
+
nextCursor++;
|
|
644
|
+
}
|
|
645
|
+
return nextCursor;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function readQuotedHeredocDelimiter(
|
|
649
|
+
line: string,
|
|
650
|
+
cursor: number,
|
|
651
|
+
): string | null {
|
|
652
|
+
const quote = line[cursor];
|
|
653
|
+
if (quote !== "'" && quote !== '"') {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const endQuoteIndex = line.indexOf(quote, cursor + 1);
|
|
658
|
+
if (endQuoteIndex === -1) {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
return line.slice(cursor + 1, endQuoteIndex);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function isHeredocDelimiterStopCharacter(char: string): boolean {
|
|
665
|
+
return (
|
|
666
|
+
char === " " ||
|
|
667
|
+
char === "\t" ||
|
|
668
|
+
char === "<" ||
|
|
669
|
+
char === ">" ||
|
|
670
|
+
char === "&" ||
|
|
671
|
+
char === "|" ||
|
|
672
|
+
char === ";" ||
|
|
673
|
+
char === "(" ||
|
|
674
|
+
char === ")"
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function readBareHeredocDelimiter(line: string, cursor: number): string | null {
|
|
679
|
+
const startChar = line[cursor];
|
|
680
|
+
if (startChar === undefined || !/[A-Za-z_]/.test(startChar)) {
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
let endIndex = cursor;
|
|
685
|
+
while (endIndex < line.length) {
|
|
686
|
+
const currentChar = line[endIndex];
|
|
687
|
+
if (
|
|
688
|
+
currentChar === undefined ||
|
|
689
|
+
isHeredocDelimiterStopCharacter(currentChar)
|
|
690
|
+
) {
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
endIndex++;
|
|
694
|
+
}
|
|
695
|
+
return line.slice(cursor, endIndex);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function findUnquotedHeredoc(
|
|
699
|
+
line: string,
|
|
700
|
+
startLineIndex: number,
|
|
701
|
+
): PendingHeredoc | null {
|
|
702
|
+
const heredocStart = findUnquotedHeredocStart(line);
|
|
703
|
+
if (!heredocStart) {
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const cursor = skipHeredocDelimiterWhitespace(
|
|
708
|
+
line,
|
|
709
|
+
heredocStart.index + 2 + (heredocStart.stripLeadingTabs ? 1 : 0),
|
|
710
|
+
);
|
|
711
|
+
const delimiter =
|
|
712
|
+
readQuotedHeredocDelimiter(line, cursor) ??
|
|
713
|
+
readBareHeredocDelimiter(line, cursor);
|
|
714
|
+
if (!delimiter) {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return {
|
|
719
|
+
startLineIndex,
|
|
720
|
+
delimiter,
|
|
721
|
+
stripLeadingTabs: heredocStart.stripLeadingTabs,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function getHeredocLineBody(line: string, stripLeadingTabs: boolean): string {
|
|
726
|
+
if (!stripLeadingTabs) {
|
|
727
|
+
return line;
|
|
728
|
+
}
|
|
729
|
+
return line.replace(/^\t+/, "");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function getSupportedHeredocTrailer(rest: string): string | null {
|
|
733
|
+
const trimmedRest = rest.trimStart();
|
|
734
|
+
if (!trimmedRest) {
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
if (trimmedRest.startsWith("&&")) {
|
|
738
|
+
return trimmedRest.slice(2).trim() ? rest : null;
|
|
739
|
+
}
|
|
740
|
+
if (trimmedRest.startsWith("||")) {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
if (trimmedRest.startsWith("|")) {
|
|
744
|
+
return trimmedRest.slice(1).trim() ? rest : null;
|
|
745
|
+
}
|
|
746
|
+
if (trimmedRest.startsWith(">")) {
|
|
747
|
+
return trimmedRest.slice(1).trim() ? rest : null;
|
|
748
|
+
}
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function rewritePendingHeredocTrailer(
|
|
753
|
+
parts: ShellCommandLines,
|
|
754
|
+
line: string,
|
|
755
|
+
lineIndex: number,
|
|
756
|
+
pendingHeredoc: PendingHeredoc,
|
|
757
|
+
): PendingHeredoc | null {
|
|
758
|
+
const body = getHeredocLineBody(line, pendingHeredoc.stripLeadingTabs);
|
|
759
|
+
if (body === pendingHeredoc.delimiter) {
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
if (!body.startsWith(pendingHeredoc.delimiter)) {
|
|
763
|
+
return pendingHeredoc;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const trailer = getSupportedHeredocTrailer(
|
|
767
|
+
body.slice(pendingHeredoc.delimiter.length),
|
|
768
|
+
);
|
|
769
|
+
if (!trailer) {
|
|
770
|
+
return pendingHeredoc;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const startLine = parts.lines[pendingHeredoc.startLineIndex];
|
|
774
|
+
if (startLine === undefined) {
|
|
775
|
+
return pendingHeredoc;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
parts.lines[pendingHeredoc.startLineIndex] = startLine + trailer;
|
|
779
|
+
const leadingTabs = pendingHeredoc.stripLeadingTabs
|
|
780
|
+
? (line.match(/^\t*/) ?? [""])[0]
|
|
781
|
+
: "";
|
|
782
|
+
parts.lines[lineIndex] = `${leadingTabs}${pendingHeredoc.delimiter}`;
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function normalizeHeredocTrailingContinuations(command: string): string {
|
|
787
|
+
const parts = splitShellCommandLines(command);
|
|
788
|
+
let pendingHeredoc: PendingHeredoc | null = null;
|
|
789
|
+
|
|
790
|
+
for (const [index, line] of parts.lines.entries()) {
|
|
791
|
+
if (pendingHeredoc) {
|
|
792
|
+
pendingHeredoc = rewritePendingHeredocTrailer(
|
|
793
|
+
parts,
|
|
794
|
+
line,
|
|
795
|
+
index,
|
|
796
|
+
pendingHeredoc,
|
|
797
|
+
);
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
pendingHeredoc = findUnquotedHeredoc(line, index);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return joinShellCommandLines(parts);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function normalizeLeadingDashPrintf(command: string): string {
|
|
808
|
+
const parts = splitShellCommandLines(command);
|
|
809
|
+
let pendingHeredoc: PendingHeredoc | null = null;
|
|
810
|
+
|
|
811
|
+
for (const [index, line] of parts.lines.entries()) {
|
|
812
|
+
if (pendingHeredoc) {
|
|
813
|
+
const body = getHeredocLineBody(line, pendingHeredoc.stripLeadingTabs);
|
|
814
|
+
if (body === pendingHeredoc.delimiter) {
|
|
815
|
+
pendingHeredoc = null;
|
|
816
|
+
}
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
parts.lines[index] = line.replace(
|
|
821
|
+
/^(\s*)printf(\s+)(['"])-/,
|
|
822
|
+
"$1printf$2-- $3-",
|
|
823
|
+
);
|
|
824
|
+
pendingHeredoc = findUnquotedHeredoc(parts.lines[index] || "", index);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return joinShellCommandLines(parts);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function normalizeShellCommand(command: string): string {
|
|
831
|
+
try {
|
|
832
|
+
return normalizeLeadingDashPrintf(
|
|
833
|
+
normalizeHeredocTrailingContinuations(command),
|
|
834
|
+
);
|
|
835
|
+
} catch {
|
|
836
|
+
return command;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
177
840
|
/** Read a spawned shell stream into a string, reporting progressive updates. */
|
|
178
841
|
async function consumeShellStream(
|
|
179
842
|
stream: ReadableStream<Uint8Array>,
|
|
@@ -225,7 +888,8 @@ export async function executeShell(
|
|
|
225
888
|
stderr: "pipe",
|
|
226
889
|
};
|
|
227
890
|
if (opts?.signal) spawnOpts.signal = opts.signal;
|
|
228
|
-
const
|
|
891
|
+
const command = normalizeShellCommand(args.command);
|
|
892
|
+
const proc = Bun.spawn([shell, "-c", command], spawnOpts);
|
|
229
893
|
|
|
230
894
|
let stdoutBuf = "";
|
|
231
895
|
let stderrBuf = "";
|
|
@@ -250,9 +250,9 @@ describe("ui/conversation", () => {
|
|
|
250
250
|
|
|
251
251
|
// Assert
|
|
252
252
|
expect(text).toContain("Working...");
|
|
253
|
-
expect(text.filter((line) => line === "
|
|
253
|
+
expect(text.filter((line) => line === "shell ->")).toHaveLength(1);
|
|
254
254
|
expect(text).toContain("echo hi");
|
|
255
|
-
expect(text).toContain("
|
|
255
|
+
expect(text).toContain("shell <-");
|
|
256
256
|
expect(text).toContain("partial output");
|
|
257
257
|
expect(text).not.toContain("Exit code: 0");
|
|
258
258
|
});
|
|
@@ -301,7 +301,7 @@ describe("ui/conversation", () => {
|
|
|
301
301
|
});
|
|
302
302
|
|
|
303
303
|
// Assert
|
|
304
|
-
expect(text).toContain("
|
|
304
|
+
expect(text).toContain("edit <-");
|
|
305
305
|
expect(text).toContain("~ src/app.ts");
|
|
306
306
|
expect(text).not.toContain("before");
|
|
307
307
|
expect(text).not.toContain("after");
|
|
@@ -513,7 +513,7 @@ describe("ui/conversation", () => {
|
|
|
513
513
|
expect(text).toContain("Thinking... 1 line.");
|
|
514
514
|
});
|
|
515
515
|
|
|
516
|
-
test("renderAssistantMessage for a shell tool call
|
|
516
|
+
test("renderAssistantMessage for a shell tool call renders an unbracketed header inside the pill", () => {
|
|
517
517
|
// Arrange
|
|
518
518
|
const assistant = {
|
|
519
519
|
content: [
|
|
@@ -522,12 +522,47 @@ describe("ui/conversation", () => {
|
|
|
522
522
|
};
|
|
523
523
|
|
|
524
524
|
// Act
|
|
525
|
-
const
|
|
525
|
+
const node = renderAssistantMessage(assistant, RENDER_OPTS);
|
|
526
|
+
const text = collectText(node);
|
|
526
527
|
|
|
527
528
|
// Assert
|
|
528
|
-
expect(text).toContain("
|
|
529
|
+
expect(text).toContain("shell ->");
|
|
530
|
+
expect(text).not.toContain("[shell ->]");
|
|
529
531
|
expect(text).toContain("echo hi");
|
|
530
532
|
expect(text).not.toContain('"command": "echo hi"');
|
|
533
|
+
|
|
534
|
+
expect(node?.type).toBe("vstack");
|
|
535
|
+
if (!node || node.type !== "vstack") {
|
|
536
|
+
throw new Error("Expected the assistant node to be a vstack");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const toolBlock = node.children[0];
|
|
540
|
+
expect(toolBlock?.type).toBe("hstack");
|
|
541
|
+
if (!toolBlock || toolBlock.type !== "hstack") {
|
|
542
|
+
throw new Error("Expected the tool block to be an hstack");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const contentColumn = toolBlock.children[1];
|
|
546
|
+
expect(contentColumn?.type).toBe("vstack");
|
|
547
|
+
if (!contentColumn || contentColumn.type !== "vstack") {
|
|
548
|
+
throw new Error("Expected the tool content column to be a vstack");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const headerRow = contentColumn.children[0];
|
|
552
|
+
expect(headerRow?.type).toBe("hstack");
|
|
553
|
+
if (!headerRow || headerRow.type !== "hstack") {
|
|
554
|
+
throw new Error("Expected the tool header row to be an hstack");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const headerPill = headerRow.children[0];
|
|
558
|
+
expect(headerPill?.type).toBe("hstack");
|
|
559
|
+
if (!headerPill || headerPill.type !== "hstack") {
|
|
560
|
+
throw new Error("Expected the tool header pill to be an hstack");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
expect(headerPill.props.bgColor).toBe(DEFAULT_THEME.toolBorder);
|
|
564
|
+
expect(headerPill.props.padding).toEqual({ x: 1 });
|
|
565
|
+
expect(collectText(headerPill)).toEqual(["shell ->"]);
|
|
531
566
|
});
|
|
532
567
|
|
|
533
568
|
test("renderAssistantMessage for a shell tool call syntax-highlights bash tokens", async () => {
|
|
@@ -795,7 +830,7 @@ describe("ui/conversation", () => {
|
|
|
795
830
|
const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
|
|
796
831
|
|
|
797
832
|
// Assert
|
|
798
|
-
expect(text).toContain("
|
|
833
|
+
expect(text).toContain("read image ->");
|
|
799
834
|
expect(text).toContain("assets/preview.png");
|
|
800
835
|
expect(text).not.toContain("{");
|
|
801
836
|
});
|
|
@@ -820,7 +855,7 @@ describe("ui/conversation", () => {
|
|
|
820
855
|
const text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
|
|
821
856
|
|
|
822
857
|
// Assert
|
|
823
|
-
expect(text).toContain("
|
|
858
|
+
expect(text).toContain("edit ->");
|
|
824
859
|
expect(text).toContain("src/file.ts");
|
|
825
860
|
expect(text).toContain("old line");
|
|
826
861
|
expect(text).toContain("new line");
|
|
@@ -883,7 +918,7 @@ describe("ui/conversation", () => {
|
|
|
883
918
|
);
|
|
884
919
|
|
|
885
920
|
// Assert
|
|
886
|
-
expect(text).toContain("
|
|
921
|
+
expect(text).toContain("shell <-");
|
|
887
922
|
expect(text).toContain("line 18");
|
|
888
923
|
expect(text).toContain("line 25");
|
|
889
924
|
expect(text).toContain("And 17 lines more");
|
|
@@ -927,7 +962,7 @@ describe("ui/conversation", () => {
|
|
|
927
962
|
);
|
|
928
963
|
|
|
929
964
|
// Assert
|
|
930
|
-
expect(text).toContain("
|
|
965
|
+
expect(text).toContain("shell <-");
|
|
931
966
|
expect(text).toContain("exit 42");
|
|
932
967
|
expect(text).toContain("boom");
|
|
933
968
|
expect(text).not.toContain("Exit code: 42");
|
|
@@ -944,7 +979,7 @@ describe("ui/conversation", () => {
|
|
|
944
979
|
);
|
|
945
980
|
|
|
946
981
|
// Assert
|
|
947
|
-
expect(text).toContain("
|
|
982
|
+
expect(text).toContain("read image <-");
|
|
948
983
|
expect(text).toContain("diagram.png");
|
|
949
984
|
expect(text).not.toContain("Read image.");
|
|
950
985
|
});
|
|
@@ -993,7 +1028,7 @@ describe("ui/conversation", () => {
|
|
|
993
1028
|
);
|
|
994
1029
|
|
|
995
1030
|
// Assert
|
|
996
|
-
expect(previewText).toContain("
|
|
1031
|
+
expect(previewText).toContain("edit <-");
|
|
997
1032
|
expect(previewText).toContain("~ src/file.ts");
|
|
998
1033
|
expect(previewText).not.toContain("before");
|
|
999
1034
|
expect(previewText).not.toContain("after");
|
|
@@ -1026,7 +1061,7 @@ describe("ui/conversation", () => {
|
|
|
1026
1061
|
);
|
|
1027
1062
|
|
|
1028
1063
|
// Assert
|
|
1029
|
-
expect(text).toContain("
|
|
1064
|
+
expect(text).toContain("edit <-");
|
|
1030
1065
|
expect(text).toContain("error 18");
|
|
1031
1066
|
expect(text).toContain("error 25");
|
|
1032
1067
|
expect(text).toContain("And 17 lines more");
|
|
@@ -1049,7 +1084,7 @@ describe("ui/conversation", () => {
|
|
|
1049
1084
|
);
|
|
1050
1085
|
|
|
1051
1086
|
// Assert
|
|
1052
|
-
expect(text).toContain("
|
|
1087
|
+
expect(text).toContain("mcp/search <-");
|
|
1053
1088
|
expect(text).toContain("session persistence sqlite turn numbering");
|
|
1054
1089
|
expect(text).not.toContain('"query"');
|
|
1055
1090
|
});
|
package/src/ui/conversation.ts
CHANGED
|
@@ -606,7 +606,7 @@ function renderToolHeaderPill(
|
|
|
606
606
|
theme: Theme,
|
|
607
607
|
): Node {
|
|
608
608
|
return HStack({ bgColor: theme.toolBorder, padding: { x: 1 } }, [
|
|
609
|
-
Text(
|
|
609
|
+
Text(`${getToolHeaderName(toolName)} ${direction}`, {
|
|
610
610
|
fgColor: getToolHeaderColor(toolName, theme),
|
|
611
611
|
bold: true,
|
|
612
612
|
}),
|