agent-sh 0.1.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 (50) hide show
  1. package/README.md +659 -0
  2. package/dist/acp-client.d.ts +76 -0
  3. package/dist/acp-client.js +507 -0
  4. package/dist/context-manager.d.ts +45 -0
  5. package/dist/context-manager.js +405 -0
  6. package/dist/core.d.ts +41 -0
  7. package/dist/core.js +76 -0
  8. package/dist/event-bus.d.ts +140 -0
  9. package/dist/event-bus.js +79 -0
  10. package/dist/executor.d.ts +31 -0
  11. package/dist/executor.js +116 -0
  12. package/dist/extension-loader.d.ts +16 -0
  13. package/dist/extension-loader.js +164 -0
  14. package/dist/extensions/file-autocomplete.d.ts +2 -0
  15. package/dist/extensions/file-autocomplete.js +63 -0
  16. package/dist/extensions/shell-recall.d.ts +9 -0
  17. package/dist/extensions/shell-recall.js +8 -0
  18. package/dist/extensions/slash-commands.d.ts +2 -0
  19. package/dist/extensions/slash-commands.js +105 -0
  20. package/dist/extensions/tui-renderer.d.ts +2 -0
  21. package/dist/extensions/tui-renderer.js +354 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +159 -0
  24. package/dist/input-handler.d.ts +48 -0
  25. package/dist/input-handler.js +302 -0
  26. package/dist/output-parser.d.ts +55 -0
  27. package/dist/output-parser.js +166 -0
  28. package/dist/shell.d.ts +54 -0
  29. package/dist/shell.js +219 -0
  30. package/dist/types.d.ts +71 -0
  31. package/dist/types.js +1 -0
  32. package/dist/utils/ansi.d.ts +12 -0
  33. package/dist/utils/ansi.js +23 -0
  34. package/dist/utils/box-frame.d.ts +21 -0
  35. package/dist/utils/box-frame.js +60 -0
  36. package/dist/utils/diff-renderer.d.ts +20 -0
  37. package/dist/utils/diff-renderer.js +506 -0
  38. package/dist/utils/diff.d.ts +24 -0
  39. package/dist/utils/diff.js +122 -0
  40. package/dist/utils/file-watcher.d.ts +31 -0
  41. package/dist/utils/file-watcher.js +101 -0
  42. package/dist/utils/markdown.d.ts +39 -0
  43. package/dist/utils/markdown.js +248 -0
  44. package/dist/utils/palette.d.ts +32 -0
  45. package/dist/utils/palette.js +36 -0
  46. package/dist/utils/tool-display.d.ts +33 -0
  47. package/dist/utils/tool-display.js +141 -0
  48. package/examples/extensions/interactive-prompts.ts +161 -0
  49. package/examples/extensions/solarized-theme.ts +27 -0
  50. package/package.json +72 -0
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Diff renderer with width-adaptive presentation modes and inline highlighting.
3
+ *
4
+ * Returns string[] (one per terminal line) — never writes to stdout.
5
+ * Supports unified, split (side-by-side), and summary modes.
6
+ * Uses token-level LCS for word-level inline diff highlighting.
7
+ */
8
+ import { highlight } from "cli-highlight";
9
+ import { visibleLen } from "./ansi.js";
10
+ import { palette as p } from "./palette.js";
11
+ // ── Constants ────────────────────────────────────────────────────
12
+ const SPLIT_MIN_WIDTH = 120;
13
+ const UNIFIED_MIN_WIDTH = 40;
14
+ // ── Syntax highlighting ──────────────────────────────────────────
15
+ const EXT_TO_LANG = {
16
+ ".ts": "typescript", ".tsx": "typescript", ".js": "javascript", ".jsx": "javascript",
17
+ ".py": "python", ".rb": "ruby", ".rs": "rust", ".go": "go", ".java": "java",
18
+ ".c": "c", ".h": "c", ".cpp": "cpp", ".hpp": "cpp", ".cs": "csharp",
19
+ ".swift": "swift", ".kt": "kotlin", ".scala": "scala",
20
+ ".sh": "bash", ".bash": "bash", ".zsh": "bash", ".fish": "bash",
21
+ ".json": "json", ".yaml": "yaml", ".yml": "yaml", ".toml": "ini",
22
+ ".xml": "xml", ".html": "html", ".htm": "html", ".css": "css", ".scss": "scss",
23
+ ".sql": "sql", ".md": "markdown", ".lua": "lua", ".php": "php",
24
+ ".ex": "elixir", ".exs": "elixir", ".erl": "erlang",
25
+ ".hs": "haskell", ".ml": "ocaml", ".clj": "clojure",
26
+ ".vim": "vim", ".dockerfile": "dockerfile",
27
+ };
28
+ function detectLanguage(filePath) {
29
+ if (!filePath)
30
+ return undefined;
31
+ const dot = filePath.lastIndexOf(".");
32
+ if (dot === -1) {
33
+ // Handle extensionless files like Dockerfile, Makefile
34
+ const base = filePath.split("/").pop()?.toLowerCase();
35
+ if (base === "dockerfile")
36
+ return "dockerfile";
37
+ if (base === "makefile")
38
+ return "makefile";
39
+ return undefined;
40
+ }
41
+ return EXT_TO_LANG[filePath.slice(dot).toLowerCase()];
42
+ }
43
+ /**
44
+ * Syntax-highlight a single line of code.
45
+ * Returns the original text if highlighting fails or no language detected.
46
+ */
47
+ function highlightLine(text, language) {
48
+ if (!language || text.trim() === "")
49
+ return text;
50
+ try {
51
+ // cli-highlight adds a trailing newline; strip it
52
+ return highlight(text, { language }).replace(/\n$/, "");
53
+ }
54
+ catch {
55
+ return text;
56
+ }
57
+ }
58
+ function tokenize(line) {
59
+ const tokens = [];
60
+ const re = /(\s+)|([A-Za-z0-9_]+)|([^\s\w])/g;
61
+ let m;
62
+ while ((m = re.exec(line)) !== null) {
63
+ if (m[1])
64
+ tokens.push({ text: m[1], kind: "space" });
65
+ else if (m[2])
66
+ tokens.push({ text: m[2], kind: "word" });
67
+ else if (m[3])
68
+ tokens.push({ text: m[3], kind: "punct" });
69
+ }
70
+ return tokens;
71
+ }
72
+ function tokenLcs(a, b) {
73
+ const m = a.length;
74
+ const n = b.length;
75
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
76
+ for (let i = 1; i <= m; i++) {
77
+ for (let j = 1; j <= n; j++) {
78
+ dp[i][j] =
79
+ a[i - 1].text === b[j - 1].text
80
+ ? dp[i - 1][j - 1] + 1
81
+ : Math.max(dp[i - 1][j], dp[i][j - 1]);
82
+ }
83
+ }
84
+ // Backtrack to mark matched tokens
85
+ const oldMatch = new Array(m).fill(false);
86
+ const newMatch = new Array(n).fill(false);
87
+ let i = m;
88
+ let j = n;
89
+ while (i > 0 && j > 0) {
90
+ if (a[i - 1].text === b[j - 1].text) {
91
+ oldMatch[i - 1] = true;
92
+ newMatch[j - 1] = true;
93
+ i--;
94
+ j--;
95
+ }
96
+ else if (dp[i - 1][j] >= dp[i][j - 1]) {
97
+ i--;
98
+ }
99
+ else {
100
+ j--;
101
+ }
102
+ }
103
+ return { oldMatch, newMatch };
104
+ }
105
+ /**
106
+ * Rewrite full ANSI resets (\x1b[0m) to foreground-only resets,
107
+ * preserving the given background color across the line.
108
+ */
109
+ function preserveBg(text, bg) {
110
+ return text.replace(/\x1b\[0m/g, `\x1b[39m${bg}`);
111
+ }
112
+ /**
113
+ * Pad a rendered line with spaces to fill the given visible width,
114
+ * ensuring background color spans the full column.
115
+ */
116
+ function padToWidth(text, targetWidth) {
117
+ const vis = visibleLen(text);
118
+ if (vis >= targetWidth)
119
+ return text;
120
+ return text + " ".repeat(targetWidth - vis);
121
+ }
122
+ function highlightInlineChanges(oldLine, newLine, oldPalette, newPalette, useTrueColor, language) {
123
+ if (!useTrueColor) {
124
+ // Still apply syntax highlighting even without true-color backgrounds
125
+ if (language) {
126
+ return {
127
+ old: highlightLine(oldLine, language),
128
+ new: highlightLine(newLine, language),
129
+ };
130
+ }
131
+ return { old: oldLine, new: newLine };
132
+ }
133
+ const oldTokens = tokenize(oldLine);
134
+ const newTokens = tokenize(newLine);
135
+ // Skip if either side is trivially small
136
+ if (oldTokens.length === 0 || newTokens.length === 0) {
137
+ return {
138
+ old: language ? highlightLine(oldLine, language) : oldLine,
139
+ new: language ? highlightLine(newLine, language) : newLine,
140
+ };
141
+ }
142
+ // Safety guard: skip if LCS matrix would be too large
143
+ if (oldTokens.length * newTokens.length > 50000) {
144
+ return {
145
+ old: language ? highlightLine(oldLine, language) : oldLine,
146
+ new: language ? highlightLine(newLine, language) : newLine,
147
+ };
148
+ }
149
+ const { oldMatch, newMatch } = tokenLcs(oldTokens, newTokens);
150
+ const buildHighlighted = (tokens, matched, palette) => {
151
+ let result = "";
152
+ for (let i = 0; i < tokens.length; i++) {
153
+ if (matched[i]) {
154
+ // Matched (unchanged) tokens: syntax highlight + row background
155
+ const text = language ? highlightLine(tokens[i].text, language) : tokens[i].text;
156
+ result += palette.rowBg + preserveBg(text, palette.rowBg);
157
+ }
158
+ else {
159
+ // Changed tokens: emphasis background, no syntax highlighting (emphasis stands out)
160
+ result += palette.emphBg + p.bold + tokens[i].text + p.reset;
161
+ }
162
+ }
163
+ return result;
164
+ };
165
+ return {
166
+ old: buildHighlighted(oldTokens, oldMatch, oldPalette),
167
+ new: buildHighlighted(newTokens, newMatch, newPalette),
168
+ };
169
+ }
170
+ /**
171
+ * Scan a hunk for adjacent removed/added runs and pair them 1:1.
172
+ * Returns a set of line indices that are part of a change pair.
173
+ */
174
+ function findChangePairs(hunk) {
175
+ const pairs = new Map();
176
+ const lines = hunk.lines;
177
+ let i = 0;
178
+ while (i < lines.length) {
179
+ // Find a run of removed lines
180
+ const removedStart = i;
181
+ while (i < lines.length && lines[i].type === "removed")
182
+ i++;
183
+ const removedEnd = i;
184
+ // Find a run of added lines immediately after
185
+ const addedStart = i;
186
+ while (i < lines.length && lines[i].type === "added")
187
+ i++;
188
+ const addedEnd = i;
189
+ // Pair them 1:1
190
+ const removedCount = removedEnd - removedStart;
191
+ const addedCount = addedEnd - addedStart;
192
+ const pairCount = Math.min(removedCount, addedCount);
193
+ for (let k = 0; k < pairCount; k++) {
194
+ const pair = {
195
+ removed: lines[removedStart + k],
196
+ added: lines[addedStart + k],
197
+ removedIdx: removedStart + k,
198
+ addedIdx: addedStart + k,
199
+ };
200
+ pairs.set(removedStart + k, pair);
201
+ pairs.set(addedStart + k, pair);
202
+ }
203
+ // If no removed/added run was found, advance past context lines
204
+ if (removedCount === 0 && addedCount === 0) {
205
+ i++;
206
+ }
207
+ }
208
+ return pairs;
209
+ }
210
+ // ── Header ───────────────────────────────────────────────────────
211
+ function buildHeader(diff, filePath) {
212
+ const path = filePath ?? "";
213
+ if (diff.isNewFile) {
214
+ return `${p.bold}new: ${path}${p.reset} ${p.dim}(+${diff.added} lines)${p.reset}`;
215
+ }
216
+ return `${p.bold}${path}${p.reset} ${p.dim}(+${diff.added} / -${diff.removed})${p.reset}`;
217
+ }
218
+ // ── Summary mode ─────────────────────────────────────────────────
219
+ function renderSummary(diff) {
220
+ if (diff.isIdentical)
221
+ return [`${p.dim}(no changes)${p.reset}`];
222
+ if (diff.isNewFile)
223
+ return [`${p.success}+${diff.added} lines${p.reset} ${p.dim}(new file)${p.reset}`];
224
+ return [`${p.success}+${diff.added}${p.reset} ${p.error}-${diff.removed}${p.reset}`];
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;
231
+ const textWidth = opts.width;
232
+ const output = [];
233
+ // Compute max line number width across all hunks
234
+ let maxNo = 0;
235
+ for (const hunk of diff.hunks) {
236
+ for (const line of hunk.lines) {
237
+ const n = line.oldNo ?? line.newNo ?? 0;
238
+ if (n > maxNo)
239
+ maxNo = n;
240
+ }
241
+ }
242
+ 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}`);
252
+ }
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") {
259
+ 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;
263
+ }
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
+ }
281
+ if (useTrueColor) {
282
+ const rowContent = `${p.errorBg}${p.error}- ${no} │ ${preserveBg(removedText, p.errorBg)}${p.reset}`;
283
+ output.push(padToWidth(rowContent, textWidth));
284
+ }
285
+ 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
+ }
296
+ }
297
+ continue;
298
+ }
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
+ }
311
+ }
312
+ }
313
+ }
314
+ return output;
315
+ }
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;
321
+ const totalWidth = opts.width;
322
+ // 3 chars for " │ " separator
323
+ const colWidth = Math.floor((totalWidth - 3) / 2);
324
+ // Compute max line number width
325
+ let maxNo = 0;
326
+ for (const hunk of diff.hunks) {
327
+ for (const line of hunk.lines) {
328
+ const n = line.oldNo ?? line.newNo ?? 0;
329
+ if (n > maxNo)
330
+ maxNo = n;
331
+ }
332
+ }
333
+ 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
+ }
386
+ }
387
+ 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);
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
+ }
400
+ }
401
+ else {
402
+ rightCol = padToWidth(`${p.dim}${rightNo} │${p.reset} ${rightText}`, colWidth);
403
+ }
404
+ output.push(`${leftCol} ${p.dim}│${p.reset} ${rightCol}`);
405
+ }
406
+ }
407
+ return output;
408
+ }
409
+ function buildSplitRows(hunk) {
410
+ const rows = [];
411
+ const lines = hunk.lines;
412
+ let i = 0;
413
+ while (i < lines.length) {
414
+ if (lines[i].type === "context") {
415
+ rows.push({ left: lines[i], right: lines[i] });
416
+ i++;
417
+ continue;
418
+ }
419
+ // Collect a run of removed lines
420
+ const removed = [];
421
+ while (i < lines.length && lines[i].type === "removed") {
422
+ removed.push(lines[i]);
423
+ i++;
424
+ }
425
+ // Collect a run of added lines
426
+ const added = [];
427
+ while (i < lines.length && lines[i].type === "added") {
428
+ added.push(lines[i]);
429
+ i++;
430
+ }
431
+ // Pair them side by side
432
+ const maxLen = Math.max(removed.length, added.length);
433
+ for (let k = 0; k < maxLen; k++) {
434
+ rows.push({
435
+ left: k < removed.length ? removed[k] : null,
436
+ right: k < added.length ? added[k] : null,
437
+ });
438
+ }
439
+ }
440
+ return rows;
441
+ }
442
+ // ── Utilities ────────────────────────────────────────────────────
443
+ /**
444
+ * Truncate text to fit within maxWidth visible characters.
445
+ * ANSI-aware: measures visible length and preserves escape codes.
446
+ */
447
+ function truncateText(text, maxWidth) {
448
+ if (maxWidth <= 0)
449
+ return "";
450
+ if (visibleLen(text) <= maxWidth)
451
+ return text;
452
+ if (maxWidth <= 1)
453
+ return "…";
454
+ // Walk through the string, tracking visible characters
455
+ let visible = 0;
456
+ let i = 0;
457
+ while (i < text.length && visible < maxWidth - 1) {
458
+ // Check for ANSI escape sequence
459
+ if (text[i] === "\x1b" && text[i + 1] === "[") {
460
+ const end = text.indexOf("m", i);
461
+ if (end !== -1) {
462
+ i = end + 1;
463
+ continue;
464
+ }
465
+ }
466
+ visible++;
467
+ i++;
468
+ }
469
+ return text.slice(0, i) + p.reset + "…";
470
+ }
471
+ // ── Public API ───────────────────────────────────────────────────
472
+ /** Select display mode based on available terminal width. */
473
+ export function selectMode(width) {
474
+ if (width >= SPLIT_MIN_WIDTH)
475
+ return "split";
476
+ if (width >= UNIFIED_MIN_WIDTH)
477
+ return "unified";
478
+ return "summary";
479
+ }
480
+ /** Render a diff result as an array of ANSI-formatted terminal lines. */
481
+ export function renderDiff(diff, opts) {
482
+ if (diff.isIdentical)
483
+ return [`${p.dim}(no changes)${p.reset}`];
484
+ const mode = opts.mode ?? selectMode(opts.width);
485
+ const maxLines = opts.maxLines ?? 50;
486
+ const header = buildHeader(diff, opts.filePath);
487
+ if (mode === "summary") {
488
+ return [header, ...renderSummary(diff)];
489
+ }
490
+ let bodyLines;
491
+ switch (mode) {
492
+ case "split":
493
+ bodyLines = renderSplit(diff, opts);
494
+ break;
495
+ case "unified":
496
+ bodyLines = renderUnified(diff, opts);
497
+ break;
498
+ }
499
+ // Truncation
500
+ if (bodyLines.length > maxLines) {
501
+ const overflow = bodyLines.length - maxLines;
502
+ bodyLines = bodyLines.slice(0, maxLines);
503
+ bodyLines.push(`${p.dim}… ${overflow} more lines${p.reset}`);
504
+ }
505
+ return [header, ...bodyLines];
506
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Lightweight LCS-based line diff for file modification previews.
3
+ */
4
+ export interface DiffLine {
5
+ type: "context" | "added" | "removed";
6
+ oldNo: number | null;
7
+ newNo: number | null;
8
+ text: string;
9
+ }
10
+ export interface DiffHunk {
11
+ lines: DiffLine[];
12
+ }
13
+ export interface DiffResult {
14
+ hunks: DiffHunk[];
15
+ added: number;
16
+ removed: number;
17
+ isIdentical: boolean;
18
+ isNewFile: boolean;
19
+ }
20
+ /**
21
+ * Compute a line-level diff between old and new file content.
22
+ * Returns grouped hunks with 3 lines of context around each change.
23
+ */
24
+ export declare function computeDiff(oldText: string | null, newText: string): DiffResult;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Lightweight LCS-based line diff for file modification previews.
3
+ */
4
+ /**
5
+ * Compute a line-level diff between old and new file content.
6
+ * Returns grouped hunks with 3 lines of context around each change.
7
+ */
8
+ export function computeDiff(oldText, newText) {
9
+ // New file — everything is an addition
10
+ if (oldText === null) {
11
+ const lines = newText.split("\n");
12
+ return {
13
+ hunks: [
14
+ {
15
+ lines: lines.map((text, i) => ({
16
+ type: "added",
17
+ oldNo: null,
18
+ newNo: i + 1,
19
+ text,
20
+ })),
21
+ },
22
+ ],
23
+ added: lines.length,
24
+ removed: 0,
25
+ isIdentical: false,
26
+ isNewFile: true,
27
+ };
28
+ }
29
+ // Identical — nothing to show
30
+ if (oldText === newText) {
31
+ return {
32
+ hunks: [],
33
+ added: 0,
34
+ removed: 0,
35
+ isIdentical: true,
36
+ isNewFile: false,
37
+ };
38
+ }
39
+ // Build LCS table and backtrack to produce diff lines
40
+ const a = oldText.split("\n");
41
+ const b = newText.split("\n");
42
+ const dp = buildLcs(a, b);
43
+ const raw = backtrack(dp, a, b);
44
+ let added = 0;
45
+ let removed = 0;
46
+ for (const l of raw) {
47
+ if (l.type === "added")
48
+ added++;
49
+ else if (l.type === "removed")
50
+ removed++;
51
+ }
52
+ return {
53
+ hunks: groupHunks(raw, 3),
54
+ added,
55
+ removed,
56
+ isIdentical: false,
57
+ isNewFile: false,
58
+ };
59
+ }
60
+ function buildLcs(a, b) {
61
+ const m = a.length;
62
+ const n = b.length;
63
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
64
+ for (let i = 1; i <= m; i++)
65
+ for (let j = 1; j <= n; j++)
66
+ dp[i][j] =
67
+ a[i - 1] === b[j - 1]
68
+ ? dp[i - 1][j - 1] + 1
69
+ : Math.max(dp[i - 1][j], dp[i][j - 1]);
70
+ return dp;
71
+ }
72
+ function backtrack(dp, a, b) {
73
+ const result = [];
74
+ let i = a.length;
75
+ let j = b.length;
76
+ while (i > 0 || j > 0) {
77
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
78
+ result.unshift({ type: "context", oldNo: i, newNo: j, text: a[i - 1] });
79
+ i--;
80
+ j--;
81
+ }
82
+ else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
83
+ result.unshift({ type: "added", oldNo: null, newNo: j, text: b[j - 1] });
84
+ j--;
85
+ }
86
+ else {
87
+ result.unshift({
88
+ type: "removed",
89
+ oldNo: i,
90
+ newNo: null,
91
+ text: a[i - 1],
92
+ });
93
+ i--;
94
+ }
95
+ }
96
+ return result;
97
+ }
98
+ function groupHunks(lines, ctx) {
99
+ const changeIdx = [];
100
+ for (let i = 0; i < lines.length; i++)
101
+ if (lines[i].type !== "context")
102
+ changeIdx.push(i);
103
+ if (changeIdx.length === 0)
104
+ return [];
105
+ const hunks = [];
106
+ let start = Math.max(0, changeIdx[0] - ctx);
107
+ let end = Math.min(lines.length - 1, changeIdx[0] + ctx);
108
+ for (let k = 1; k < changeIdx.length; k++) {
109
+ const ns = Math.max(0, changeIdx[k] - ctx);
110
+ const ne = Math.min(lines.length - 1, changeIdx[k] + ctx);
111
+ if (ns <= end + 1) {
112
+ end = ne;
113
+ }
114
+ else {
115
+ hunks.push({ lines: lines.slice(start, end + 1) });
116
+ start = ns;
117
+ end = ne;
118
+ }
119
+ }
120
+ hunks.push({ lines: lines.slice(start, end + 1) });
121
+ return hunks;
122
+ }
@@ -0,0 +1,31 @@
1
+ export interface FileChange {
2
+ path: string;
3
+ relPath: string;
4
+ before: string;
5
+ after: string;
6
+ }
7
+ /**
8
+ * Snapshots the working directory before an agent prompt so that
9
+ * file modifications made by **any** method (ACP writeTextFile,
10
+ * the agent's own edit tools, shell commands, etc.) can be detected
11
+ * and shown as an interactive diff preview.
12
+ */
13
+ export declare class FileWatcher {
14
+ private cwd;
15
+ private baseline;
16
+ constructor(cwd: string);
17
+ /**
18
+ * Recursively snapshot all text files in the working directory.
19
+ * Skips common non-source directories, binary files, and files
20
+ * exceeding MAX_FILE_SIZE. Capped at MAX_FILES entries.
21
+ */
22
+ snapshot(): Promise<void>;
23
+ /** Update baseline after a write is approved (avoids double-reporting). */
24
+ approve(absPath: string, content: string): void;
25
+ /** Detect all tracked files whose on-disk content differs from baseline. */
26
+ detectChanges(): Promise<FileChange[]>;
27
+ /** Revert a file to its baseline content. */
28
+ revert(absPath: string): Promise<void>;
29
+ /** Clear all tracking state. */
30
+ reset(): void;
31
+ }