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.
- package/README.md +659 -0
- package/dist/acp-client.d.ts +76 -0
- package/dist/acp-client.js +507 -0
- package/dist/context-manager.d.ts +45 -0
- package/dist/context-manager.js +405 -0
- package/dist/core.d.ts +41 -0
- package/dist/core.js +76 -0
- package/dist/event-bus.d.ts +140 -0
- package/dist/event-bus.js +79 -0
- package/dist/executor.d.ts +31 -0
- package/dist/executor.js +116 -0
- package/dist/extension-loader.d.ts +16 -0
- package/dist/extension-loader.js +164 -0
- package/dist/extensions/file-autocomplete.d.ts +2 -0
- package/dist/extensions/file-autocomplete.js +63 -0
- package/dist/extensions/shell-recall.d.ts +9 -0
- package/dist/extensions/shell-recall.js +8 -0
- package/dist/extensions/slash-commands.d.ts +2 -0
- package/dist/extensions/slash-commands.js +105 -0
- package/dist/extensions/tui-renderer.d.ts +2 -0
- package/dist/extensions/tui-renderer.js +354 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +159 -0
- package/dist/input-handler.d.ts +48 -0
- package/dist/input-handler.js +302 -0
- package/dist/output-parser.d.ts +55 -0
- package/dist/output-parser.js +166 -0
- package/dist/shell.d.ts +54 -0
- package/dist/shell.js +219 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.js +1 -0
- package/dist/utils/ansi.d.ts +12 -0
- package/dist/utils/ansi.js +23 -0
- package/dist/utils/box-frame.d.ts +21 -0
- package/dist/utils/box-frame.js +60 -0
- package/dist/utils/diff-renderer.d.ts +20 -0
- package/dist/utils/diff-renderer.js +506 -0
- package/dist/utils/diff.d.ts +24 -0
- package/dist/utils/diff.js +122 -0
- package/dist/utils/file-watcher.d.ts +31 -0
- package/dist/utils/file-watcher.js +101 -0
- package/dist/utils/markdown.d.ts +39 -0
- package/dist/utils/markdown.js +248 -0
- package/dist/utils/palette.d.ts +32 -0
- package/dist/utils/palette.js +36 -0
- package/dist/utils/tool-display.d.ts +33 -0
- package/dist/utils/tool-display.js +141 -0
- package/examples/extensions/interactive-prompts.ts +161 -0
- package/examples/extensions/solarized-theme.ts +27 -0
- 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
|
+
}
|