llm-diff 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/src/render.js ADDED
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Terminal renderer — pretty-prints diff results.
3
+ *
4
+ * Supports two modes:
5
+ * • human – colored terminal output (default)
6
+ * • json – machine-readable JSON
7
+ */
8
+
9
+ // ANSI codes
10
+ const c = {
11
+ reset: '\x1b[0m',
12
+ bold: '\x1b[1m',
13
+ dim: '\x1b[2m',
14
+ red: '\x1b[31m',
15
+ green: '\x1b[32m',
16
+ yellow: '\x1b[33m',
17
+ cyan: '\x1b[36m',
18
+ bgRed: '\x1b[41m',
19
+ bgGreen:'\x1b[42m',
20
+ gray: '\x1b[90m',
21
+ white: '\x1b[37m',
22
+ strikethrough: '\x1b[9m',
23
+ };
24
+
25
+ /**
26
+ * Render the diff result to stdout.
27
+ */
28
+ export function render(result, opts = {}) {
29
+ if (opts.json) {
30
+ return renderJson(result);
31
+ }
32
+ return renderHuman(result, opts);
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // JSON output
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function renderJson(result) {
40
+ const { a, b, delta, model, runs } = result;
41
+ const output = {
42
+ model: model.id,
43
+ provider: model.provider,
44
+ runs,
45
+ a: {
46
+ inputTokens: a.inputTokens,
47
+ outputTokens: a.outputTokens,
48
+ totalTokens: a.inputTokens + a.outputTokens,
49
+ cost: round(a.cost, 6),
50
+ latencyMs: round(a.latencyMs, 0),
51
+ text: a.text,
52
+ },
53
+ b: {
54
+ inputTokens: b.inputTokens,
55
+ outputTokens: b.outputTokens,
56
+ totalTokens: b.inputTokens + b.outputTokens,
57
+ cost: round(b.cost, 6),
58
+ latencyMs: round(b.latencyMs, 0),
59
+ text: b.text,
60
+ },
61
+ delta: {
62
+ totalTokens: delta.totalTokens,
63
+ totalTokensPct: round(delta.totalTokensPct, 1),
64
+ cost: round(delta.cost, 6),
65
+ costPct: round(delta.costPct, 1),
66
+ latencyMs: round(delta.latencyMs, 0),
67
+ latencyPct: round(delta.latencyPct, 1),
68
+ },
69
+ };
70
+ console.log(JSON.stringify(output, null, 2));
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Human-readable output
75
+ // ---------------------------------------------------------------------------
76
+
77
+ function renderHuman(result, opts) {
78
+ const { a, b, delta, wordDiff, model, runs } = result;
79
+
80
+ const lines = [];
81
+
82
+ // Header
83
+ lines.push('');
84
+ lines.push(`${c.bold}${c.cyan}llm-diff${c.reset} ${c.dim}${model.provider}/${model.id}${c.reset}`);
85
+ if (runs > 1) {
86
+ lines.push(`${c.dim}averaged over ${runs} runs${c.reset}`);
87
+ }
88
+ lines.push('');
89
+
90
+ // Stats table
91
+ const totalA = a.inputTokens + a.outputTokens;
92
+ const totalB = b.inputTokens + b.outputTokens;
93
+
94
+ lines.push(statsRow(
95
+ 'tokens',
96
+ `${totalA}`,
97
+ `${totalB}`,
98
+ delta.totalTokens,
99
+ delta.totalTokensPct,
100
+ ''
101
+ ));
102
+
103
+ lines.push(statsRow(
104
+ ' input',
105
+ `${a.inputTokens}`,
106
+ `${b.inputTokens}`,
107
+ delta.inputTokens,
108
+ null,
109
+ '',
110
+ true
111
+ ));
112
+
113
+ lines.push(statsRow(
114
+ ' output',
115
+ `${a.outputTokens}`,
116
+ `${b.outputTokens}`,
117
+ delta.outputTokens,
118
+ null,
119
+ '',
120
+ true
121
+ ));
122
+
123
+ lines.push(statsRow(
124
+ 'cost',
125
+ `$${formatCost(a.cost)}`,
126
+ `$${formatCost(b.cost)}`,
127
+ delta.cost,
128
+ delta.costPct,
129
+ '$'
130
+ ));
131
+
132
+ lines.push(statsRow(
133
+ 'latency',
134
+ `${round(a.latencyMs, 0)}ms`,
135
+ `${round(b.latencyMs, 0)}ms`,
136
+ delta.latencyMs,
137
+ delta.latencyPct,
138
+ 'ms'
139
+ ));
140
+
141
+ lines.push('');
142
+
143
+ // Response diff
144
+ lines.push(`${c.bold}--- prompt A${c.reset}`);
145
+ lines.push(`${c.bold}+++ prompt B${c.reset}`);
146
+ lines.push('');
147
+
148
+ if (opts.full) {
149
+ // Full inline diff
150
+ for (const part of wordDiff) {
151
+ if (part.added) {
152
+ process.stdout.write(`${c.bgGreen}${c.white}${part.value}${c.reset}`);
153
+ } else if (part.removed) {
154
+ process.stdout.write(`${c.bgRed}${c.white}${c.strikethrough}${part.value}${c.reset}`);
155
+ } else {
156
+ process.stdout.write(part.value);
157
+ }
158
+ }
159
+ process.stdout.write('\n');
160
+ } else {
161
+ // Compact: only changed lines
162
+ renderCompactDiff(wordDiff);
163
+ }
164
+
165
+ lines.push('');
166
+
167
+ console.log(lines.join('\n'));
168
+ }
169
+
170
+ /**
171
+ * Compact diff view — shows only lines with changes, with surrounding context.
172
+ */
173
+ function renderCompactDiff(wordDiff) {
174
+ // Build the full A and B texts, then do a line diff
175
+ let aText = '';
176
+ let bText = '';
177
+ for (const part of wordDiff) {
178
+ if (!part.added) aText += part.value;
179
+ if (!part.removed) bText += part.value;
180
+ }
181
+
182
+ const aLines = aText.split('\n');
183
+ const bLines = bText.split('\n');
184
+
185
+ // Simple LCS-based line diff
186
+ const lineDiff = diffLines(aLines, bLines);
187
+
188
+ const contextLines = 2;
189
+ const output = [];
190
+ let lastPrinted = -1;
191
+
192
+ // Find which lines have changes
193
+ const changedIndices = new Set();
194
+ lineDiff.forEach((entry, i) => {
195
+ if (entry.type !== 'equal') {
196
+ // Mark surrounding context too
197
+ for (let j = Math.max(0, i - contextLines); j <= Math.min(lineDiff.length - 1, i + contextLines); j++) {
198
+ changedIndices.add(j);
199
+ }
200
+ }
201
+ });
202
+
203
+ lineDiff.forEach((entry, i) => {
204
+ if (!changedIndices.has(i)) return;
205
+
206
+ if (lastPrinted >= 0 && i > lastPrinted + 1) {
207
+ output.push(`${c.dim} ...${c.reset}`);
208
+ }
209
+ lastPrinted = i;
210
+
211
+ switch (entry.type) {
212
+ case 'removed':
213
+ output.push(`${c.red}- ${entry.value}${c.reset}`);
214
+ break;
215
+ case 'added':
216
+ output.push(`${c.green}+ ${entry.value}${c.reset}`);
217
+ break;
218
+ case 'equal':
219
+ output.push(`${c.dim} ${entry.value}${c.reset}`);
220
+ break;
221
+ }
222
+ });
223
+
224
+ console.log(output.join('\n'));
225
+ }
226
+
227
+ /**
228
+ * Minimal line differ (no external dep needed for this simple case).
229
+ */
230
+ function diffLines(aLines, bLines) {
231
+ const result = [];
232
+ let ai = 0;
233
+ let bi = 0;
234
+
235
+ // Simple greedy match — not optimal LCS but good enough for readable diffs
236
+ while (ai < aLines.length && bi < bLines.length) {
237
+ if (aLines[ai] === bLines[bi]) {
238
+ result.push({ type: 'equal', value: aLines[ai] });
239
+ ai++;
240
+ bi++;
241
+ } else {
242
+ // Look ahead for a match
243
+ const aMatch = findNext(bLines, aLines[ai], bi, 5);
244
+ const bMatch = findNext(aLines, bLines[bi], ai, 5);
245
+
246
+ if (aMatch === -1 && bMatch === -1) {
247
+ // Both lines changed
248
+ result.push({ type: 'removed', value: aLines[ai] });
249
+ result.push({ type: 'added', value: bLines[bi] });
250
+ ai++;
251
+ bi++;
252
+ } else if (bMatch !== -1 && (aMatch === -1 || bMatch - ai <= aMatch - bi)) {
253
+ // Lines were removed from A
254
+ while (ai < bMatch) {
255
+ result.push({ type: 'removed', value: aLines[ai] });
256
+ ai++;
257
+ }
258
+ } else {
259
+ // Lines were added in B
260
+ while (bi < aMatch) {
261
+ result.push({ type: 'added', value: bLines[bi] });
262
+ bi++;
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ while (ai < aLines.length) {
269
+ result.push({ type: 'removed', value: aLines[ai++] });
270
+ }
271
+ while (bi < bLines.length) {
272
+ result.push({ type: 'added', value: bLines[bi++] });
273
+ }
274
+
275
+ return result;
276
+ }
277
+
278
+ function findNext(arr, value, from, maxLook) {
279
+ const limit = Math.min(arr.length, from + maxLook);
280
+ for (let i = from; i < limit; i++) {
281
+ if (arr[i] === value) return i;
282
+ }
283
+ return -1;
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Stats formatting helpers
288
+ // ---------------------------------------------------------------------------
289
+
290
+ function statsRow(label, valA, valB, delta, deltaPct, unit, subdued = false) {
291
+ const labelStr = subdued
292
+ ? `${c.dim}${label.padEnd(12)}${c.reset}`
293
+ : `${c.bold}${label.padEnd(12)}${c.reset}`;
294
+
295
+ const arrow = `${c.dim}→${c.reset}`;
296
+ const valAStr = valA.padStart(10);
297
+ const valBStr = valB.padStart(10);
298
+
299
+ let deltaStr;
300
+ if (typeof delta === 'number') {
301
+ const sign = delta > 0 ? '+' : '';
302
+ const color = delta < 0 ? c.green : delta > 0 ? c.red : c.dim;
303
+
304
+ if (unit === '$') {
305
+ deltaStr = `${color}${sign}$${formatCost(Math.abs(delta))}${c.reset}`;
306
+ } else if (unit === 'ms') {
307
+ deltaStr = `${color}${sign}${round(delta, 0)}ms${c.reset}`;
308
+ } else {
309
+ deltaStr = `${color}${sign}${delta}${c.reset}`;
310
+ }
311
+
312
+ if (deltaPct != null && deltaPct !== 0) {
313
+ const pctColor = deltaPct < 0 ? c.green : deltaPct > 0 ? c.red : c.dim;
314
+ deltaStr += ` ${pctColor}(${deltaPct > 0 ? '+' : ''}${round(deltaPct, 1)}%)${c.reset}`;
315
+ }
316
+ } else {
317
+ deltaStr = '';
318
+ }
319
+
320
+ return ` ${labelStr}${valAStr} ${arrow} ${valBStr} ${deltaStr}`;
321
+ }
322
+
323
+ function formatCost(n) {
324
+ if (n < 0.001) return n.toFixed(6);
325
+ if (n < 0.01) return n.toFixed(5);
326
+ if (n < 1) return n.toFixed(4);
327
+ return n.toFixed(2);
328
+ }
329
+
330
+ function round(n, decimals) {
331
+ const f = Math.pow(10, decimals);
332
+ return Math.round(n * f) / f;
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Model list printer
337
+ // ---------------------------------------------------------------------------
338
+
339
+ export function renderModelList(grouped) {
340
+ console.log(`\n${c.bold}${c.cyan}Supported models${c.reset}\n`);
341
+ for (const [provider, models] of Object.entries(grouped)) {
342
+ console.log(` ${c.bold}${provider}${c.reset}`);
343
+ for (const m of models) {
344
+ const input = `$${m.inputCostPer1k.toFixed(5)}/1k in`;
345
+ const output = `$${m.outputCostPer1k.toFixed(5)}/1k out`;
346
+ console.log(` ${m.alias.padEnd(24)} ${c.dim}${input} ${output}${c.reset}`);
347
+ }
348
+ console.log('');
349
+ }
350
+ }