agent-sh 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +14 -21
  2. package/dist/agent/agent-loop.d.ts +43 -3
  3. package/dist/agent/agent-loop.js +811 -128
  4. package/dist/agent/conversation-state.d.ts +72 -21
  5. package/dist/agent/conversation-state.js +357 -150
  6. package/dist/agent/history-file.d.ts +13 -4
  7. package/dist/agent/history-file.js +110 -36
  8. package/dist/agent/nuclear-form.d.ts +28 -3
  9. package/dist/agent/nuclear-form.js +84 -3
  10. package/dist/agent/skills.d.ts +2 -4
  11. package/dist/agent/skills.js +10 -4
  12. package/dist/agent/subagent.d.ts +23 -0
  13. package/dist/agent/subagent.js +53 -11
  14. package/dist/agent/system-prompt.d.ts +34 -1
  15. package/dist/agent/system-prompt.js +96 -47
  16. package/dist/agent/token-budget.d.ts +5 -4
  17. package/dist/agent/token-budget.js +14 -19
  18. package/dist/agent/tool-protocol.d.ts +23 -1
  19. package/dist/agent/tool-protocol.js +169 -4
  20. package/dist/agent/tools/bash.js +3 -3
  21. package/dist/agent/tools/edit-file.js +9 -6
  22. package/dist/agent/tools/glob.js +4 -2
  23. package/dist/agent/tools/grep.js +27 -3
  24. package/dist/agent/tools/ls.js +5 -6
  25. package/dist/agent/types.d.ts +1 -1
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.js +27 -6
  29. package/dist/event-bus.d.ts +59 -2
  30. package/dist/executor.d.ts +4 -3
  31. package/dist/executor.js +18 -15
  32. package/dist/extension-loader.js +50 -13
  33. package/dist/extensions/agent-backend.d.ts +8 -7
  34. package/dist/extensions/agent-backend.js +69 -48
  35. package/dist/extensions/index.js +0 -1
  36. package/dist/extensions/slash-commands.js +14 -9
  37. package/dist/extensions/tui-renderer.js +62 -78
  38. package/dist/index.js +25 -6
  39. package/dist/settings.d.ts +36 -5
  40. package/dist/settings.js +53 -9
  41. package/dist/shell/input-handler.d.ts +2 -1
  42. package/dist/shell/input-handler.js +82 -73
  43. package/dist/shell/shell.js +19 -2
  44. package/dist/types.d.ts +12 -0
  45. package/dist/utils/ansi.d.ts +5 -0
  46. package/dist/utils/ansi.js +1 -1
  47. package/dist/utils/compositor.d.ts +5 -0
  48. package/dist/utils/compositor.js +31 -3
  49. package/dist/utils/diff-renderer.d.ts +9 -0
  50. package/dist/utils/diff-renderer.js +221 -143
  51. package/dist/utils/diff.d.ts +21 -2
  52. package/dist/utils/diff.js +165 -89
  53. package/dist/utils/handler-registry.d.ts +5 -0
  54. package/dist/utils/handler-registry.js +6 -0
  55. package/dist/utils/line-editor.d.ts +11 -1
  56. package/dist/utils/line-editor.js +44 -5
  57. package/dist/utils/tool-display.d.ts +1 -1
  58. package/dist/utils/tool-display.js +4 -4
  59. package/examples/extensions/ash-acp-bridge/src/index.ts +4 -1
  60. package/examples/extensions/ash-mcp-bridge/index.ts +13 -3
  61. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  62. package/examples/extensions/claude-code-bridge/package.json +1 -0
  63. package/examples/extensions/interactive-prompts.ts +39 -25
  64. package/examples/extensions/overlay-agent.ts +3 -3
  65. package/examples/extensions/peer-mesh.ts +115 -0
  66. package/examples/extensions/pi-bridge/index.ts +2 -2
  67. package/examples/extensions/questionnaire.ts +16 -5
  68. package/examples/extensions/subagents.ts +19 -4
  69. package/examples/extensions/terminal-buffer.ts +163 -0
  70. package/examples/extensions/user-shell.ts +136 -0
  71. package/examples/extensions/web-access.ts +8 -0
  72. package/package.json +36 -2
  73. package/dist/agent/tools/display.d.ts +0 -13
  74. package/dist/agent/tools/display.js +0 -70
  75. package/dist/agent/tools/user-shell.d.ts +0 -13
  76. package/dist/agent/tools/user-shell.js +0 -87
  77. package/dist/extensions/terminal-buffer.d.ts +0 -14
  78. package/dist/extensions/terminal-buffer.js +0 -134
@@ -6,7 +6,7 @@
6
6
  * Uses token-level LCS for word-level inline diff highlighting.
7
7
  */
8
8
  import { highlight } from "cli-highlight";
9
- import { visibleLen } from "./ansi.js";
9
+ import { visibleLen, charWidth } from "./ansi.js";
10
10
  import { palette as p } from "./palette.js";
11
11
  // ── Constants ────────────────────────────────────────────────────
12
12
  const SPLIT_MIN_WIDTH = 120;
@@ -223,14 +223,8 @@ function renderSummary(diff) {
223
223
  return [`${p.success}+${diff.added} lines${p.reset} ${p.dim}(new file)${p.reset}`];
224
224
  return [`${p.success}+${diff.added}${p.reset} ${p.error}-${diff.removed}${p.reset}`];
225
225
  }
226
- // ── Unified mode ─────────────────────────────────────────────────
227
- function renderUnified(diff, opts) {
228
- const useTrueColor = opts.trueColor !== false;
229
- const useSyntax = opts.syntaxHighlight !== false;
230
- const lang = useSyntax ? detectLanguage(opts.filePath) : undefined;
226
+ function unifiedLayout(diff, opts) {
231
227
  const textWidth = opts.width;
232
- const output = [];
233
- // Compute max line number width across all hunks
234
228
  let maxNo = 0;
235
229
  for (const hunk of diff.hunks) {
236
230
  for (const line of hunk.lines) {
@@ -240,88 +234,90 @@ function renderUnified(diff, opts) {
240
234
  }
241
235
  }
242
236
  const noW = Math.max(String(maxNo).length, 1);
243
- // sign(1) + space(1) + lineNo(noW) + space(1) + bar(1) + space(1) = noW + 5
244
- const gutterW = noW + 5;
245
- const lineTextW = Math.max(1, textWidth - gutterW);
246
- const removedPalette = { rowBg: p.errorBg, emphBg: p.errorBgEmph };
247
- const addedPalette = { rowBg: p.successBg, emphBg: p.successBgEmph };
248
- for (let hunkIdx = 0; hunkIdx < diff.hunks.length; hunkIdx++) {
249
- const hunk = diff.hunks[hunkIdx];
250
- if (hunkIdx > 0) {
251
- output.push(` ${p.dim}⋯${p.reset}`);
237
+ return {
238
+ noW,
239
+ lineTextW: Math.max(1, textWidth - noW - 5),
240
+ textWidth,
241
+ useTrueColor: opts.trueColor !== false,
242
+ lang: opts.syntaxHighlight !== false ? detectLanguage(opts.filePath) : undefined,
243
+ removedPalette: { rowBg: p.errorBg, emphBg: p.errorBgEmph },
244
+ addedPalette: { rowBg: p.successBg, emphBg: p.successBgEmph },
245
+ };
246
+ }
247
+ function renderUnifiedHunk(hunk, layout) {
248
+ const { noW, lineTextW, textWidth, useTrueColor, lang, removedPalette, addedPalette } = layout;
249
+ const out = [];
250
+ const pairs = findChangePairs(hunk);
251
+ const renderedAsPartOfPair = new Set();
252
+ for (let i = 0; i < hunk.lines.length; i++) {
253
+ const line = hunk.lines[i];
254
+ const no = String(line.type === "removed" ? (line.oldNo ?? "") : (line.newNo ?? line.oldNo ?? "")).padStart(noW);
255
+ if (line.type === "context") {
256
+ const raw = truncateText(line.text, lineTextW);
257
+ const text = lang ? highlightLine(raw, lang) : raw;
258
+ out.push(` ${p.dim}${no} │${p.reset} ${p.dim}${text}${p.reset}`);
259
+ continue;
252
260
  }
253
- const pairs = findChangePairs(hunk);
254
- const renderedAsPartOfPair = new Set();
255
- for (let i = 0; i < hunk.lines.length; i++) {
256
- const line = hunk.lines[i];
257
- const no = String(line.type === "removed" ? (line.oldNo ?? "") : (line.newNo ?? line.oldNo ?? "")).padStart(noW);
258
- if (line.type === "context") {
261
+ if (line.type === "removed") {
262
+ const pair = pairs.get(i);
263
+ let removedText;
264
+ let addedText = null;
265
+ let addedNo = null;
266
+ if (pair && pair.removedIdx === i) {
267
+ const highlighted = highlightInlineChanges(line.text, pair.added.text, removedPalette, addedPalette, useTrueColor, lang);
268
+ removedText = truncateText(highlighted.old, lineTextW);
269
+ addedText = truncateText(highlighted.new, lineTextW);
270
+ addedNo = String(pair.added.newNo ?? "").padStart(noW);
271
+ renderedAsPartOfPair.add(pair.addedIdx);
272
+ }
273
+ else {
259
274
  const raw = truncateText(line.text, lineTextW);
260
- const text = lang ? highlightLine(raw, lang) : raw;
261
- output.push(` ${p.dim}${no} │${p.reset} ${p.dim}${text}${p.reset}`);
262
- continue;
275
+ removedText = lang ? highlightLine(raw, lang) : raw;
263
276
  }
264
- if (line.type === "removed") {
265
- const pair = pairs.get(i);
266
- let removedText;
267
- let addedText = null;
268
- let addedNo = null;
269
- if (pair && pair.removedIdx === i) {
270
- const highlighted = highlightInlineChanges(line.text, pair.added.text, removedPalette, addedPalette, useTrueColor, lang);
271
- removedText = truncateText(highlighted.old, lineTextW);
272
- addedText = truncateText(highlighted.new, lineTextW);
273
- addedNo = String(pair.added.newNo ?? "").padStart(noW);
274
- renderedAsPartOfPair.add(pair.addedIdx);
275
- }
276
- else {
277
- // Unpaired removed line — syntax highlight the whole line
278
- const raw = truncateText(line.text, lineTextW);
279
- removedText = lang ? highlightLine(raw, lang) : raw;
280
- }
277
+ if (useTrueColor) {
278
+ out.push(padToWidth(`${p.errorBg}${p.error}- ${no} ${preserveBg(removedText, p.errorBg)}${p.reset}`, textWidth));
279
+ }
280
+ else {
281
+ out.push(`${p.error}- ${no} ${removedText}${p.reset}`);
282
+ }
283
+ if (addedText !== null && addedNo !== null) {
281
284
  if (useTrueColor) {
282
- const rowContent = `${p.errorBg}${p.error}- ${no} │ ${preserveBg(removedText, p.errorBg)}${p.reset}`;
283
- output.push(padToWidth(rowContent, textWidth));
285
+ out.push(padToWidth(`${p.successBg}${p.success}+ ${addedNo} │ ${preserveBg(addedText, p.successBg)}${p.reset}`, textWidth));
284
286
  }
285
287
  else {
286
- output.push(`${p.error}- ${no} │ ${removedText}${p.reset}`);
287
- }
288
- if (addedText !== null && addedNo !== null) {
289
- if (useTrueColor) {
290
- const rowContent = `${p.successBg}${p.success}+ ${addedNo} │ ${preserveBg(addedText, p.successBg)}${p.reset}`;
291
- output.push(padToWidth(rowContent, textWidth));
292
- }
293
- else {
294
- output.push(`${p.success}+ ${addedNo} │ ${addedText}${p.reset}`);
295
- }
288
+ out.push(`${p.success}+ ${addedNo} │ ${addedText}${p.reset}`);
296
289
  }
290
+ }
291
+ continue;
292
+ }
293
+ if (line.type === "added") {
294
+ if (renderedAsPartOfPair.has(i))
297
295
  continue;
296
+ const raw = truncateText(line.text, lineTextW);
297
+ const text = lang ? highlightLine(raw, lang) : raw;
298
+ if (useTrueColor) {
299
+ out.push(padToWidth(`${p.successBg}${p.success}+ ${no} │ ${preserveBg(text, p.successBg)}${p.reset}`, textWidth));
298
300
  }
299
- if (line.type === "added") {
300
- if (renderedAsPartOfPair.has(i))
301
- continue;
302
- const raw = truncateText(line.text, lineTextW);
303
- const text = lang ? highlightLine(raw, lang) : raw;
304
- if (useTrueColor) {
305
- const rowContent = `${p.successBg}${p.success}+ ${no} │ ${preserveBg(text, p.successBg)}${p.reset}`;
306
- output.push(padToWidth(rowContent, textWidth));
307
- }
308
- else {
309
- output.push(`${p.success}+ ${no} │ ${text}${p.reset}`);
310
- }
301
+ else {
302
+ out.push(`${p.success}+ ${no} │ ${text}${p.reset}`);
311
303
  }
312
304
  }
313
305
  }
306
+ return out;
307
+ }
308
+ function renderUnified(diff, opts) {
309
+ const layout = unifiedLayout(diff, opts);
310
+ const output = [];
311
+ for (let hunkIdx = 0; hunkIdx < diff.hunks.length; hunkIdx++) {
312
+ if (hunkIdx > 0)
313
+ output.push(` ${p.dim}⋯${p.reset}`);
314
+ output.push(...renderUnifiedHunk(diff.hunks[hunkIdx], layout));
315
+ }
314
316
  return output;
315
317
  }
316
- // ── Split (side-by-side) mode ────────────────────────────────────
317
- function renderSplit(diff, opts) {
318
- const useTrueColor = opts.trueColor !== false;
319
- const useSyntax = opts.syntaxHighlight !== false;
320
- const lang = useSyntax ? detectLanguage(opts.filePath) : undefined;
318
+ function splitLayout(diff, opts) {
321
319
  const totalWidth = opts.width;
322
- // 3 chars for " │ " separator
323
320
  const colWidth = Math.max(1, Math.floor((totalWidth - 3) / 2));
324
- // Compute max line number width
325
321
  let maxNo = 0;
326
322
  for (const hunk of diff.hunks) {
327
323
  for (const line of hunk.lines) {
@@ -331,78 +327,87 @@ function renderSplit(diff, opts) {
331
327
  }
332
328
  }
333
329
  const noW = Math.max(String(maxNo).length, 1);
334
- // lineNo(noW) + space(1) + bar(1) + space(1) = noW + 3
335
- const textW = Math.max(1, colWidth - noW - 3);
336
- const removedPalette = { rowBg: p.errorBg, emphBg: p.errorBgEmph };
337
- const addedPalette = { rowBg: p.successBg, emphBg: p.successBgEmph };
338
- const output = [];
339
- // Column header
340
- const leftHeader = padToWidth(`${p.dim}${"─".repeat(colWidth)}${p.reset}`, colWidth);
341
- const rightHeader = padToWidth(`${p.dim}${"─".repeat(colWidth)}${p.reset}`, colWidth);
342
- output.push(`${leftHeader} ${p.dim}│${p.reset} ${rightHeader}`);
343
- for (let hunkIdx = 0; hunkIdx < diff.hunks.length; hunkIdx++) {
344
- const hunk = diff.hunks[hunkIdx];
345
- if (hunkIdx > 0) {
346
- output.push(`${p.dim}${" ".repeat(colWidth)} ${" ".repeat(colWidth)}${p.reset}`);
347
- output.push(`${p.dim}${"·".repeat(colWidth)} ${"·".repeat(colWidth)}${p.reset}`);
348
- }
349
- const rows = buildSplitRows(hunk);
350
- for (const row of rows) {
351
- const leftNo = row.left
352
- ? String(row.left.oldNo ?? row.left.newNo ?? "").padStart(noW)
353
- : " ".repeat(noW);
354
- const rightNo = row.right
355
- ? String(row.right.newNo ?? row.right.oldNo ?? "").padStart(noW)
356
- : " ".repeat(noW);
357
- let leftText = row.left ? truncateText(row.left.text, textW) : "";
358
- let rightText = row.right ? truncateText(row.right.text, textW) : "";
359
- // Apply inline highlighting for change pairs
360
- if (row.left && row.right && row.left.type === "removed" && row.right.type === "added") {
361
- const highlighted = highlightInlineChanges(row.left.text, row.right.text, removedPalette, addedPalette, useTrueColor, lang);
362
- leftText = truncateText(highlighted.old, textW);
363
- rightText = truncateText(highlighted.new, textW);
364
- }
365
- else {
366
- // Non-pair lines: apply syntax highlighting
367
- if (lang) {
368
- if (leftText)
369
- leftText = highlightLine(leftText, lang);
370
- if (rightText)
371
- rightText = highlightLine(rightText, lang);
372
- }
373
- }
374
- let leftCol;
375
- let rightCol;
376
- if (!row.left || row.left.type === "context") {
377
- leftCol = padToWidth(`${p.dim}${leftNo} │${p.reset} ${p.dim}${leftText}${p.reset}`, colWidth);
378
- }
379
- else if (row.left.type === "removed") {
380
- if (useTrueColor) {
381
- leftCol = padToWidth(`${p.errorBg}${p.error}${leftNo} │ ${preserveBg(leftText, p.errorBg)}${p.reset}`, colWidth);
382
- }
383
- else {
384
- leftCol = padToWidth(`${p.error}${leftNo} │ ${leftText}${p.reset}`, colWidth);
385
- }
330
+ return {
331
+ colWidth,
332
+ noW,
333
+ textW: Math.max(1, colWidth - noW - 3),
334
+ useTrueColor: opts.trueColor !== false,
335
+ lang: opts.syntaxHighlight !== false ? detectLanguage(opts.filePath) : undefined,
336
+ removedPalette: { rowBg: p.errorBg, emphBg: p.errorBgEmph },
337
+ addedPalette: { rowBg: p.successBg, emphBg: p.successBgEmph },
338
+ };
339
+ }
340
+ function renderSplitHunk(hunk, layout) {
341
+ const { colWidth, noW, textW, useTrueColor, lang, removedPalette, addedPalette } = layout;
342
+ const out = [];
343
+ const rows = buildSplitRows(hunk);
344
+ for (const row of rows) {
345
+ const leftNo = row.left
346
+ ? String(row.left.oldNo ?? row.left.newNo ?? "").padStart(noW)
347
+ : " ".repeat(noW);
348
+ const rightNo = row.right
349
+ ? String(row.right.newNo ?? row.right.oldNo ?? "").padStart(noW)
350
+ : " ".repeat(noW);
351
+ let leftText = row.left ? truncateText(row.left.text, textW) : "";
352
+ let rightText = row.right ? truncateText(row.right.text, textW) : "";
353
+ if (row.left && row.right && row.left.type === "removed" && row.right.type === "added") {
354
+ const highlighted = highlightInlineChanges(row.left.text, row.right.text, removedPalette, addedPalette, useTrueColor, lang);
355
+ leftText = truncateText(highlighted.old, textW);
356
+ rightText = truncateText(highlighted.new, textW);
357
+ }
358
+ else if (lang) {
359
+ if (leftText)
360
+ leftText = highlightLine(leftText, lang);
361
+ if (rightText)
362
+ rightText = highlightLine(rightText, lang);
363
+ }
364
+ let leftCol;
365
+ let rightCol;
366
+ if (!row.left || row.left.type === "context") {
367
+ leftCol = padToWidth(`${p.dim}${leftNo} │${p.reset} ${p.dim}${leftText}${p.reset}`, colWidth);
368
+ }
369
+ else if (row.left.type === "removed") {
370
+ if (useTrueColor) {
371
+ leftCol = padToWidth(`${p.errorBg}${p.error}${leftNo} │ ${preserveBg(leftText, p.errorBg)}${p.reset}`, colWidth);
386
372
  }
387
373
  else {
388
- leftCol = padToWidth(`${p.dim}${leftNo} │${p.reset} ${leftText}`, colWidth);
389
- }
390
- if (!row.right || row.right.type === "context") {
391
- rightCol = padToWidth(`${p.dim}${rightNo} │${p.reset} ${p.dim}${rightText}${p.reset}`, colWidth);
374
+ leftCol = padToWidth(`${p.error}${leftNo} │ ${leftText}${p.reset}`, colWidth);
392
375
  }
393
- else if (row.right.type === "added") {
394
- if (useTrueColor) {
395
- rightCol = padToWidth(`${p.successBg}${p.success}${rightNo} │ ${preserveBg(rightText, p.successBg)}${p.reset}`, colWidth);
396
- }
397
- else {
398
- rightCol = padToWidth(`${p.success}${rightNo} ${rightText}${p.reset}`, colWidth);
399
- }
376
+ }
377
+ else {
378
+ leftCol = padToWidth(`${p.dim}${leftNo} │${p.reset} ${leftText}`, colWidth);
379
+ }
380
+ if (!row.right || row.right.type === "context") {
381
+ rightCol = padToWidth(`${p.dim}${rightNo} │${p.reset} ${p.dim}${rightText}${p.reset}`, colWidth);
382
+ }
383
+ else if (row.right.type === "added") {
384
+ if (useTrueColor) {
385
+ rightCol = padToWidth(`${p.successBg}${p.success}${rightNo} │ ${preserveBg(rightText, p.successBg)}${p.reset}`, colWidth);
400
386
  }
401
387
  else {
402
- rightCol = padToWidth(`${p.dim}${rightNo} │${p.reset} ${rightText}`, colWidth);
388
+ rightCol = padToWidth(`${p.success}${rightNo} │ ${rightText}${p.reset}`, colWidth);
403
389
  }
404
- output.push(`${leftCol} ${p.dim}│${p.reset} ${rightCol}`);
405
390
  }
391
+ else {
392
+ rightCol = padToWidth(`${p.dim}${rightNo} │${p.reset} ${rightText}`, colWidth);
393
+ }
394
+ out.push(`${leftCol} ${p.dim}│${p.reset} ${rightCol}`);
395
+ }
396
+ return out;
397
+ }
398
+ function renderSplit(diff, opts) {
399
+ const layout = splitLayout(diff, opts);
400
+ const output = [];
401
+ // Column header
402
+ const leftHeader = padToWidth(`${p.dim}${"─".repeat(layout.colWidth)}${p.reset}`, layout.colWidth);
403
+ const rightHeader = padToWidth(`${p.dim}${"─".repeat(layout.colWidth)}${p.reset}`, layout.colWidth);
404
+ output.push(`${leftHeader} ${p.dim}│${p.reset} ${rightHeader}`);
405
+ for (let hunkIdx = 0; hunkIdx < diff.hunks.length; hunkIdx++) {
406
+ if (hunkIdx > 0) {
407
+ output.push(`${p.dim}${" ".repeat(layout.colWidth)} │ ${" ".repeat(layout.colWidth)}${p.reset}`);
408
+ output.push(`${p.dim}${"·".repeat(layout.colWidth)} │ ${"·".repeat(layout.colWidth)}${p.reset}`);
409
+ }
410
+ output.push(...renderSplitHunk(diff.hunks[hunkIdx], layout));
406
411
  }
407
412
  return output;
408
413
  }
@@ -439,6 +444,35 @@ function buildSplitRows(hunk) {
439
444
  }
440
445
  return rows;
441
446
  }
447
+ async function renderUnifiedAsync(diff, opts, yieldFn) {
448
+ const layout = unifiedLayout(diff, opts);
449
+ const output = [];
450
+ for (let hunkIdx = 0; hunkIdx < diff.hunks.length; hunkIdx++) {
451
+ if (hunkIdx > 0)
452
+ await yieldFn();
453
+ if (hunkIdx > 0)
454
+ output.push(` ${p.dim}⋯${p.reset}`);
455
+ output.push(...renderUnifiedHunk(diff.hunks[hunkIdx], layout));
456
+ }
457
+ return output;
458
+ }
459
+ async function renderSplitAsync(diff, opts, yieldFn) {
460
+ const layout = splitLayout(diff, opts);
461
+ const output = [];
462
+ const leftHeader = padToWidth(`${p.dim}${"─".repeat(layout.colWidth)}${p.reset}`, layout.colWidth);
463
+ const rightHeader = padToWidth(`${p.dim}${"─".repeat(layout.colWidth)}${p.reset}`, layout.colWidth);
464
+ output.push(`${leftHeader} ${p.dim}│${p.reset} ${rightHeader}`);
465
+ for (let hunkIdx = 0; hunkIdx < diff.hunks.length; hunkIdx++) {
466
+ if (hunkIdx > 0)
467
+ await yieldFn();
468
+ if (hunkIdx > 0) {
469
+ output.push(`${p.dim}${" ".repeat(layout.colWidth)} │ ${" ".repeat(layout.colWidth)}${p.reset}`);
470
+ output.push(`${p.dim}${"·".repeat(layout.colWidth)} │ ${"·".repeat(layout.colWidth)}${p.reset}`);
471
+ }
472
+ output.push(...renderSplitHunk(diff.hunks[hunkIdx], layout));
473
+ }
474
+ return output;
475
+ }
442
476
  // ── Utilities ────────────────────────────────────────────────────
443
477
  /**
444
478
  * Truncate text to fit within maxWidth visible characters.
@@ -451,11 +485,11 @@ function truncateText(text, maxWidth) {
451
485
  return text;
452
486
  if (maxWidth <= 1)
453
487
  return "…";
454
- // Walk through the string, tracking visible characters
488
+ // Advance by visible width (CJK = 2), skipping ANSI sequences.
489
+ // Reserve one column for the trailing ellipsis.
455
490
  let visible = 0;
456
491
  let i = 0;
457
492
  while (i < text.length && visible < maxWidth - 1) {
458
- // Check for ANSI escape sequence
459
493
  if (text[i] === "\x1b" && text[i + 1] === "[") {
460
494
  const end = text.indexOf("m", i);
461
495
  if (end !== -1) {
@@ -463,8 +497,12 @@ function truncateText(text, maxWidth) {
463
497
  continue;
464
498
  }
465
499
  }
466
- visible++;
467
- i++;
500
+ const cp = text.codePointAt(i) ?? 0;
501
+ const cw = charWidth(cp);
502
+ if (visible + cw > maxWidth - 1)
503
+ break;
504
+ visible += cw;
505
+ i += cp > 0xffff ? 2 : 1;
468
506
  }
469
507
  return text.slice(0, i) + p.reset + "…";
470
508
  }
@@ -592,3 +630,43 @@ export function renderDiff(diff, opts) {
592
630
  }
593
631
  return [header, ...bodyLines];
594
632
  }
633
+ /**
634
+ * Async variant of renderDiff that yields to the event loop between hunks.
635
+ * Use when rendering in a context where a spinner or other UI needs to stay
636
+ * responsive (e.g. showing a large diff during a permission prompt).
637
+ *
638
+ * @param onLines - Callback invoked with each batch of rendered lines as they
639
+ * are produced. Allows progressive/streaming display.
640
+ */
641
+ export async function renderDiffAsync(diff, opts, onLines) {
642
+ if (diff.isIdentical) {
643
+ onLines([`${p.dim}(no changes)${p.reset}`]);
644
+ return;
645
+ }
646
+ const mode = opts.mode ?? selectMode(opts.width);
647
+ const maxLines = opts.maxLines ?? 50;
648
+ const header = buildHeader(diff, opts.filePath);
649
+ if (mode === "summary") {
650
+ onLines([header, ...renderSummary(diff)]);
651
+ return;
652
+ }
653
+ // Trim context lines from hunks if the diff would exceed the budget
654
+ const trimmed = { ...diff, hunks: trimHunksToFit(diff.hunks, maxLines) };
655
+ const yieldFn = () => new Promise(r => setImmediate(r));
656
+ let bodyLines;
657
+ switch (mode) {
658
+ case "split":
659
+ bodyLines = await renderSplitAsync(trimmed, opts, yieldFn);
660
+ break;
661
+ case "unified":
662
+ bodyLines = await renderUnifiedAsync(trimmed, opts, yieldFn);
663
+ break;
664
+ }
665
+ // Final safety net — if still over budget, simple tail truncation.
666
+ if (bodyLines.length > maxLines) {
667
+ const overflow = bodyLines.length - maxLines;
668
+ bodyLines = bodyLines.slice(0, maxLines);
669
+ bodyLines.push(`${p.dim}… ${overflow} more lines${p.reset}`);
670
+ }
671
+ onLines([header, ...bodyLines]);
672
+ }
@@ -1,5 +1,12 @@
1
1
  /**
2
- * Lightweight LCS-based line diff for file modification previews.
2
+ * Line-level diff computation, powered by the `diff` npm package.
3
+ *
4
+ * Exposes a unified `DiffResult` interface consumed by the diff renderer.
5
+ * Three entry points cover the main use cases:
6
+ *
7
+ * computeDiff — full-file diff (write_file, or when edit region can't be located)
8
+ * computeEditDiff — edit_file: locates the edit region, builds the new file, full diff
9
+ * computeInputDiff — fast preview: diffs only old_text vs new_text, no file I/O
3
10
  */
4
11
  export interface DiffLine {
5
12
  type: "context" | "added" | "removed";
@@ -19,6 +26,18 @@ export interface DiffResult {
19
26
  }
20
27
  /**
21
28
  * Compute a line-level diff between old and new file content.
22
- * Returns grouped hunks with 3 lines of context around each change.
23
29
  */
24
30
  export declare function computeDiff(oldText: string | null, newText: string): DiffResult;
31
+ /**
32
+ * Compute a diff for an edit operation where we know the old/new text.
33
+ * Locates the edit region(s) in the file, constructs the full new file,
34
+ * then diffs the whole thing so line numbers are file-relative.
35
+ */
36
+ export declare function computeEditDiff(oldFileText: string, editOld: string, editNew: string, replaceAll?: boolean): DiffResult;
37
+ /**
38
+ * Diff two edit strings directly — no file read needed.
39
+ * Line numbers are relative to the edit region, not the file.
40
+ * Use for permission prompt previews where speed matters more than
41
+ * exact file-relative line numbers.
42
+ */
43
+ export declare function computeInputDiff(oldText: string, newText: string): DiffResult;