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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "A small, fast CLI coding agent",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
package/src/agent.ts CHANGED
@@ -525,19 +525,26 @@ async function executeToolCall(
525
525
 
526
526
  let result: ToolExecResult;
527
527
  try {
528
- result = await handler(
529
- toolCall.arguments,
530
- opts.cwd,
531
- opts.signal,
532
- (partial) => {
533
- opts.onEvent?.({
534
- type: "tool_delta",
535
- toolCallId: toolCall.id,
536
- name: toolCall.name,
537
- result: partial,
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 { db, sessionId, turn, messages, signal, onEvent, toolHandlers, cwd } =
596
- opts;
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 the directory
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(["rev-parse", "--show-toplevel"], cwd);
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(["branch", "--show-current"], cwd),
174
- exec(
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(["status", "--porcelain"], cwd, false),
179
- exec(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], cwd),
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(`Old text not found in ${args.path}`, true);
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
- `Old text matches multiple locations (${count}) in ${args.path}`,
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 updated = content.replace(args.oldText, newText);
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 proc = Bun.spawn([shell, "-c", args.command], spawnOpts);
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 === "[shell ->]")).toHaveLength(1);
253
+ expect(text.filter((line) => line === "shell ->")).toHaveLength(1);
254
254
  expect(text).toContain("echo hi");
255
- expect(text).toContain("[shell <-]");
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("[edit <-]");
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 shows the command under the new header pill", () => {
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 text = collectText(renderAssistantMessage(assistant, RENDER_OPTS));
525
+ const node = renderAssistantMessage(assistant, RENDER_OPTS);
526
+ const text = collectText(node);
526
527
 
527
528
  // Assert
528
- expect(text).toContain("[shell ->]");
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("[read image ->]");
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("[edit ->]");
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("[shell <-]");
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("[shell <-]");
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("[read image <-]");
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("[edit <-]");
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("[edit <-]");
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("[mcp/search <-]");
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
  });
@@ -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(`[${getToolHeaderName(toolName)} ${direction}]`, {
609
+ Text(`${getToolHeaderName(toolName)} ${direction}`, {
610
610
  fgColor: getToolHeaderColor(toolName, theme),
611
611
  bold: true,
612
612
  }),