agent-sh 0.8.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 (106) hide show
  1. package/README.md +27 -43
  2. package/dist/agent/agent-loop.d.ts +69 -6
  3. package/dist/agent/agent-loop.js +954 -153
  4. package/dist/agent/conversation-state.d.ts +74 -21
  5. package/dist/agent/conversation-state.js +361 -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 +88 -6
  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 +37 -5
  15. package/dist/agent/system-prompt.js +100 -67
  16. package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +5 -4
  17. package/dist/{token-budget.js → agent/token-budget.js} +15 -20
  18. package/dist/agent/tool-protocol.d.ts +105 -0
  19. package/dist/agent/tool-protocol.js +551 -0
  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 +22 -2
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.d.ts +7 -7
  29. package/dist/core.js +99 -196
  30. package/dist/event-bus.d.ts +85 -2
  31. package/dist/event-bus.js +20 -1
  32. package/dist/executor.d.ts +4 -3
  33. package/dist/executor.js +18 -15
  34. package/dist/extension-loader.d.ts +5 -0
  35. package/dist/extension-loader.js +143 -19
  36. package/dist/extensions/agent-backend.d.ts +14 -0
  37. package/dist/extensions/agent-backend.js +188 -0
  38. package/dist/extensions/command-suggest.d.ts +3 -3
  39. package/dist/extensions/command-suggest.js +4 -3
  40. package/dist/extensions/index.d.ts +19 -0
  41. package/dist/extensions/index.js +24 -0
  42. package/dist/extensions/slash-commands.d.ts +1 -1
  43. package/dist/extensions/slash-commands.js +30 -10
  44. package/dist/extensions/tui-renderer.js +117 -113
  45. package/dist/index.js +39 -26
  46. package/dist/settings.d.ts +40 -3
  47. package/dist/settings.js +57 -10
  48. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +3 -2
  49. package/dist/{input-handler.js → shell/input-handler.js} +111 -85
  50. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  51. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  52. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  53. package/dist/{shell.js → shell/shell.js} +39 -8
  54. package/dist/types.d.ts +61 -10
  55. package/dist/utils/ansi.d.ts +5 -0
  56. package/dist/utils/ansi.js +1 -1
  57. package/dist/utils/compositor.d.ts +67 -0
  58. package/dist/utils/compositor.js +116 -0
  59. package/dist/utils/diff-renderer.d.ts +9 -0
  60. package/dist/utils/diff-renderer.js +312 -146
  61. package/dist/utils/diff.d.ts +21 -2
  62. package/dist/utils/diff.js +165 -89
  63. package/dist/utils/floating-panel.d.ts +2 -0
  64. package/dist/utils/floating-panel.js +30 -14
  65. package/dist/utils/handler-registry.d.ts +31 -10
  66. package/dist/utils/handler-registry.js +58 -16
  67. package/dist/utils/line-editor.d.ts +33 -3
  68. package/dist/utils/line-editor.js +221 -44
  69. package/dist/utils/markdown.d.ts +1 -0
  70. package/dist/utils/markdown.js +1 -1
  71. package/dist/utils/message-utils.d.ts +35 -0
  72. package/dist/utils/message-utils.js +75 -0
  73. package/dist/utils/terminal-buffer.d.ts +5 -1
  74. package/dist/utils/terminal-buffer.js +18 -2
  75. package/dist/utils/tool-display.d.ts +1 -1
  76. package/dist/utils/tool-display.js +4 -4
  77. package/dist/utils/tool-interactive.d.ts +12 -0
  78. package/dist/utils/tool-interactive.js +53 -0
  79. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  80. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  81. package/examples/extensions/ash-acp-bridge/src/index.ts +574 -0
  82. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  83. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  84. package/examples/extensions/ash-mcp-bridge/index.ts +164 -0
  85. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  86. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  87. package/examples/extensions/claude-code-bridge/package.json +1 -0
  88. package/examples/extensions/interactive-prompts.ts +98 -112
  89. package/examples/extensions/overlay-agent.ts +84 -38
  90. package/examples/extensions/peer-mesh.ts +565 -0
  91. package/examples/extensions/pi-bridge/index.ts +2 -2
  92. package/examples/extensions/questionnaire.ts +260 -0
  93. package/examples/extensions/subagents.ts +19 -4
  94. package/examples/extensions/terminal-buffer.ts +32 -53
  95. package/examples/extensions/tmux-pane.ts +307 -0
  96. package/examples/extensions/user-shell.ts +136 -0
  97. package/examples/extensions/web-access.ts +335 -0
  98. package/package.json +44 -2
  99. package/dist/agent/tools/display.d.ts +0 -13
  100. package/dist/agent/tools/display.js +0 -70
  101. package/dist/agent/tools/user-shell.d.ts +0 -13
  102. package/dist/agent/tools/user-shell.js +0 -87
  103. package/dist/extensions/overlay-agent.d.ts +0 -14
  104. package/dist/extensions/overlay-agent.js +0 -147
  105. package/dist/extensions/terminal-buffer.d.ts +0 -14
  106. package/dist/extensions/terminal-buffer.js +0 -125
@@ -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.oldNo ?? line.newNo ?? "").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);
374
+ leftCol = padToWidth(`${p.error}${leftNo} │ ${leftText}${p.reset}`, colWidth);
389
375
  }
390
- if (!row.right || row.right.type === "context") {
391
- rightCol = padToWidth(`${p.dim}${rightNo} │${p.reset} ${p.dim}${rightText}${p.reset}`, colWidth);
392
- }
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,11 +497,100 @@ 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
  }
509
+ // ── Truncation ──────────────────────────────────────────────────
510
+ /**
511
+ * Trim context lines from hunks so the rendered output fits within a budget.
512
+ * Change lines are never removed — only the surrounding context shrinks.
513
+ */
514
+ function trimHunksToFit(hunks, maxLines) {
515
+ // Count change lines across all hunks
516
+ let changeCount = 0;
517
+ for (const hunk of hunks) {
518
+ for (const line of hunk.lines) {
519
+ if (line.type !== "context")
520
+ changeCount++;
521
+ }
522
+ }
523
+ // Separators between hunks
524
+ const separators = Math.max(0, hunks.length - 1);
525
+ // How many context lines can we afford?
526
+ const contextBudget = Math.max(0, maxLines - changeCount - separators);
527
+ // Count total context to see if trimming is needed
528
+ let totalContext = 0;
529
+ for (const hunk of hunks) {
530
+ for (const line of hunk.lines) {
531
+ if (line.type === "context")
532
+ totalContext++;
533
+ }
534
+ }
535
+ if (totalContext <= contextBudget)
536
+ return hunks;
537
+ // Determine how many context lines to keep per side of each change.
538
+ // Binary-search for the largest per-side context that fits.
539
+ let lo = 0;
540
+ let hi = 3; // original context size from groupHunks
541
+ while (lo < hi) {
542
+ const mid = Math.ceil((lo + hi) / 2);
543
+ if (countContextWithLimit(hunks, mid) <= contextBudget)
544
+ lo = mid;
545
+ else
546
+ hi = mid - 1;
547
+ }
548
+ return rebuildHunks(hunks, lo);
549
+ }
550
+ /** Count how many context lines remain if we keep at most `ctx` per side of each change. */
551
+ function countContextWithLimit(hunks, ctx) {
552
+ let count = 0;
553
+ for (const hunk of hunks) {
554
+ const lines = hunk.lines;
555
+ for (let i = 0; i < lines.length; i++) {
556
+ if (lines[i].type !== "context")
557
+ continue;
558
+ // Keep this context line if it's within `ctx` of any change
559
+ let nearChange = false;
560
+ for (let d = 1; d <= ctx; d++) {
561
+ if ((i - d >= 0 && lines[i - d].type !== "context") ||
562
+ (i + d < lines.length && lines[i + d].type !== "context")) {
563
+ nearChange = true;
564
+ break;
565
+ }
566
+ }
567
+ if (nearChange)
568
+ count++;
569
+ }
570
+ }
571
+ return count;
572
+ }
573
+ /** Rebuild hunks keeping only context lines within `ctx` distance of a change. */
574
+ function rebuildHunks(hunks, ctx) {
575
+ return hunks.map((hunk) => {
576
+ const lines = hunk.lines;
577
+ const kept = [];
578
+ for (let i = 0; i < lines.length; i++) {
579
+ if (lines[i].type !== "context") {
580
+ kept.push(lines[i]);
581
+ continue;
582
+ }
583
+ for (let d = 1; d <= ctx; d++) {
584
+ if ((i - d >= 0 && lines[i - d].type !== "context") ||
585
+ (i + d < lines.length && lines[i + d].type !== "context")) {
586
+ kept.push(lines[i]);
587
+ break;
588
+ }
589
+ }
590
+ }
591
+ return { lines: kept };
592
+ });
593
+ }
471
594
  // ── Public API ───────────────────────────────────────────────────
472
595
  /** Select display mode based on available terminal width. */
473
596
  export function selectMode(width) {
@@ -487,16 +610,19 @@ export function renderDiff(diff, opts) {
487
610
  if (mode === "summary") {
488
611
  return [header, ...renderSummary(diff)];
489
612
  }
613
+ // Trim context lines from hunks if the diff would exceed the budget,
614
+ // so that actual changes are always visible.
615
+ const trimmed = { ...diff, hunks: trimHunksToFit(diff.hunks, maxLines) };
490
616
  let bodyLines;
491
617
  switch (mode) {
492
618
  case "split":
493
- bodyLines = renderSplit(diff, opts);
619
+ bodyLines = renderSplit(trimmed, opts);
494
620
  break;
495
621
  case "unified":
496
- bodyLines = renderUnified(diff, opts);
622
+ bodyLines = renderUnified(trimmed, opts);
497
623
  break;
498
624
  }
499
- // Truncation
625
+ // Final safety net — if still over budget, simple tail truncation.
500
626
  if (bodyLines.length > maxLines) {
501
627
  const overflow = bodyLines.length - maxLines;
502
628
  bodyLines = bodyLines.slice(0, maxLines);
@@ -504,3 +630,43 @@ export function renderDiff(diff, opts) {
504
630
  }
505
631
  return [header, ...bodyLines];
506
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;