@danieltmn/tamnora 0.9.1 → 0.9.3
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/CHANGELOG.md +13 -0
- package/harness/packages/cli/src/tui/app.js +22 -3
- package/harness/packages/cli/src/tui/layout.js +59 -2
- package/harness/packages/cli/test/tui.test.js +30 -0
- package/harness/packages/core/src/config/app.js +2 -0
- package/harness/packages/core/src/index.js +2 -1
- package/harness/packages/core/src/providers/presets.js +4 -0
- package/harness/packages/core/src/stt/google.js +91 -0
- package/harness/packages/core/src/tools/diff.js +70 -1
- package/harness/packages/core/test/diff.test.js +24 -1
- package/harness/packages/server/public/app.js +824 -45
- package/harness/packages/server/public/index.html +121 -1
- package/harness/packages/server/public/styles.css +106 -12
- package/harness/packages/server/src/lib/channels/index.js +23 -3
- package/harness/packages/server/src/lib/channels/telegram.js +289 -101
- package/harness/packages/server/src/lib/presence.js +51 -0
- package/harness/packages/server/src/lib/profile.js +63 -0
- package/harness/packages/server/src/lib/prompt.js +7 -2
- package/harness/packages/server/src/lib/transcribe.js +51 -0
- package/harness/packages/server/src/routes/channels.js +2 -0
- package/harness/packages/server/src/routes/presence.js +17 -0
- package/harness/packages/server/src/routes/profile.js +22 -0
- package/harness/packages/server/src/routes/providers.js +2 -0
- package/harness/packages/server/src/routes/sessions.js +55 -33
- package/harness/packages/server/src/routes/transcribe.js +115 -0
- package/harness/packages/server/src/routes/tts.js +2 -1
- package/harness/packages/server/src/server.js +11 -0
- package/harness/packages/server/test/profile.test.js +85 -0
- package/harness/packages/server/test/server.test.js +34 -0
- package/harness/packages/server/test/telegram-features.test.js +241 -0
- package/harness/packages/server/test/telegram-voz.test.js +12 -5
- package/harness/packages/server/test/telegram.test.js +37 -2
- package/harness/packages/server/test/transcribe.test.js +215 -0
- package/harness/packages/server/test/tts.test.js +24 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.9.3] - 2026-10-07
|
|
9
|
+
|
|
10
|
+
### Cambios
|
|
11
|
+
|
|
12
|
+
- feat(telegram): audio solo cuando el usuario lo pide
|
|
13
|
+
- feat: diff lado a lado en TUI, proveedores Anthropic/Gemini y mejoras de consola
|
|
14
|
+
|
|
15
|
+
## [0.9.2] - 2026-10-07
|
|
16
|
+
|
|
17
|
+
### Cambios
|
|
18
|
+
|
|
19
|
+
- feat: perfil del usuario, dictado de voz y Telegram por proyecto
|
|
20
|
+
|
|
8
21
|
## [0.9.1] - 2026-10-07
|
|
9
22
|
|
|
10
23
|
### Cambios
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Full-screen terminal console (opencode/codex-like), zero dependencies.
|
|
2
2
|
// Alt-screen + raw input + incremental rendering; reuses the harness engine.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { createTools, diffRows, resolveInRoot } from '../lib/core.js';
|
|
5
6
|
import { runTurn } from '../lib/runtime.js';
|
|
6
7
|
import { parseKeys } from './keys.js';
|
|
7
8
|
import { render } from './layout.js';
|
|
@@ -67,6 +68,15 @@ export function runTui({
|
|
|
67
68
|
tools: [],
|
|
68
69
|
});
|
|
69
70
|
|
|
71
|
+
const readWorkspaceFile = (rel) => {
|
|
72
|
+
if (!rel) return '';
|
|
73
|
+
try {
|
|
74
|
+
return readFileSync(resolveInRoot(toolOptions.root, rel), 'utf8');
|
|
75
|
+
} catch {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
70
80
|
const onAsk = (name, args) =>
|
|
71
81
|
new Promise((resolve) => {
|
|
72
82
|
state.pendingAsk = { name, detail: args?.command ?? args?.path ?? '', resolve };
|
|
@@ -186,10 +196,19 @@ export function runTui({
|
|
|
186
196
|
} else if (event.type === 'stall') {
|
|
187
197
|
if (!sawText) assistant.text = `⚠ ${event.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...`;
|
|
188
198
|
} else if (event.type === 'tool_call') {
|
|
189
|
-
|
|
199
|
+
const tool = { name: event.name, args: JSON.stringify(event.args), path: event.args?.path };
|
|
200
|
+
if (event.name === 'write' || event.name === 'edit') {
|
|
201
|
+
tool.oldContent = readWorkspaceFile(event.args?.path);
|
|
202
|
+
}
|
|
203
|
+
assistant.tools.push(tool);
|
|
190
204
|
} else if (event.type === 'tool_result') {
|
|
191
205
|
const tool = assistant.tools[assistant.tools.length - 1];
|
|
192
|
-
if (tool)
|
|
206
|
+
if (tool) {
|
|
207
|
+
tool.result = String(event.result).slice(0, 2000);
|
|
208
|
+
if (tool.oldContent !== undefined) {
|
|
209
|
+
tool.diff = diffRows(tool.oldContent, readWorkspaceFile(tool.path), { maxRows: 400 });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
193
212
|
}
|
|
194
213
|
draw();
|
|
195
214
|
},
|
|
@@ -48,6 +48,53 @@ function clip(text, width) {
|
|
|
48
48
|
return arr.length <= width ? text : arr.slice(0, width).join('');
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function sanitize(text) {
|
|
52
|
+
return String(text ?? '').replace(/\t/g, ' ');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Una columna del diff: numero de linea dim y contenido rojo (viejo) / verde
|
|
56
|
+
// (nuevo) / normal (contexto). Devuelve texto con ANSI ya resuelto (raw).
|
|
57
|
+
function diffSide(cell, kind, numW, contentW) {
|
|
58
|
+
if (!cell) return color.dim(' '.repeat(numW + 3 + contentW));
|
|
59
|
+
const num = String(cell.no).padStart(numW);
|
|
60
|
+
const text = clip(sanitize(cell.text), contentW);
|
|
61
|
+
const pad = ' '.repeat(Math.max(0, contentW - chars(text).length));
|
|
62
|
+
const sign = kind === 'del' ? '-' : kind === 'add' ? '+' : ' ';
|
|
63
|
+
const paint = kind === 'del' ? color.red : kind === 'add' ? color.green : (t) => t;
|
|
64
|
+
return color.dim(`${num} `) + paint(`${sign} ${text}`) + pad;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Arma las lineas del diff lado a lado. Devuelve null si el ancho es muy chico
|
|
68
|
+
// (en ese caso el que llama cae al diff de una sola columna).
|
|
69
|
+
function buildDiff(diff, width) {
|
|
70
|
+
const indent = 2;
|
|
71
|
+
const mid = ' │ ';
|
|
72
|
+
const maxNo = diff.reduce((m, row) => Math.max(m, row.left?.no ?? 0, row.right?.no ?? 0), 1);
|
|
73
|
+
const numW = Math.max(3, String(maxNo).length);
|
|
74
|
+
const colW = Math.floor((width - indent - mid.length) / 2);
|
|
75
|
+
const contentW = colW - numW - 3;
|
|
76
|
+
if (contentW < 6) return null;
|
|
77
|
+
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const row of diff) {
|
|
80
|
+
if (row.type === 'skip') {
|
|
81
|
+
out.push({ text: `${' '.repeat(indent)}${color.dim('…')}`, raw: true });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const leftKind = row.type === 'add' ? 'blank' : row.type === 'ctx' ? 'ctx' : 'del';
|
|
85
|
+
const rightKind = row.type === 'del' ? 'blank' : row.type === 'ctx' ? 'ctx' : 'add';
|
|
86
|
+
const line =
|
|
87
|
+
' '.repeat(indent) +
|
|
88
|
+
diffSide(row.left, leftKind, numW, contentW) +
|
|
89
|
+
color.dim(mid) +
|
|
90
|
+
diffSide(row.right, rightKind, numW, contentW);
|
|
91
|
+
out.push({ text: line, raw: true });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const TOOL_LABELS = { write: 'Write', edit: 'Edit' };
|
|
97
|
+
|
|
51
98
|
function buildBody(state, width) {
|
|
52
99
|
const out = [];
|
|
53
100
|
for (const message of state.messages) {
|
|
@@ -62,6 +109,12 @@ function buildBody(state, width) {
|
|
|
62
109
|
for (const line of wrapText(message.text, width - 2)) out.push({ text: ` ${line}`, style: 'assistant' });
|
|
63
110
|
}
|
|
64
111
|
for (const tool of message.tools ?? []) {
|
|
112
|
+
const diff = tool.diff?.length ? buildDiff(tool.diff, width) : null;
|
|
113
|
+
if (diff) {
|
|
114
|
+
out.push({ text: ` ✎ ${TOOL_LABELS[tool.name] ?? tool.name} ${tool.path ?? ''}`.trimEnd(), style: 'tool' });
|
|
115
|
+
out.push(...diff);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
65
118
|
out.push({ text: ` ⚙ ${tool.name} ${clip(tool.args ?? '', width - 8)}`, style: 'tool' });
|
|
66
119
|
if (tool.result !== undefined) {
|
|
67
120
|
for (const line of wrapText(tool.result, width - 6)) out.push({ text: ` → ${line}`, style: 'dim' });
|
|
@@ -121,8 +174,12 @@ export function render(state, { cols = 80, rows = 24 } = {}) {
|
|
|
121
174
|
const parts = [];
|
|
122
175
|
for (let i = 0; i < rows; i++) {
|
|
123
176
|
const entry = lines[i] ?? { text: '', style: 'dim' };
|
|
124
|
-
|
|
125
|
-
parts.push(
|
|
177
|
+
parts.push(moveTo(i + 1, 1), '\x1b[2K');
|
|
178
|
+
if (entry.raw) parts.push(entry.text);
|
|
179
|
+
else {
|
|
180
|
+
const style = THEME[entry.style] ?? THEME.assistant;
|
|
181
|
+
parts.push(style(clip(entry.text, width)));
|
|
182
|
+
}
|
|
126
183
|
}
|
|
127
184
|
|
|
128
185
|
const cursorCol = state.pendingAsk ? 2 : Math.min(width, chars(` ❯ ${before}`).length + 1);
|
|
@@ -81,6 +81,36 @@ test('render places the cursor at inputCursor', () => {
|
|
|
81
81
|
assert.equal(cursor.col, 6);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
test('render shows a side-by-side diff for write/edit', () => {
|
|
85
|
+
const state = {
|
|
86
|
+
header: { provider: 'p', model: 'm', cwd: '/x' },
|
|
87
|
+
messages: [
|
|
88
|
+
{
|
|
89
|
+
role: 'assistant',
|
|
90
|
+
text: '',
|
|
91
|
+
tools: [
|
|
92
|
+
{
|
|
93
|
+
name: 'edit',
|
|
94
|
+
args: '{"path":"a.js"}',
|
|
95
|
+
path: 'a.js',
|
|
96
|
+
diff: [
|
|
97
|
+
{ type: 'ctx', left: { no: 1, text: 'const a = 1;' }, right: { no: 1, text: 'const a = 1;' } },
|
|
98
|
+
{ type: 'mod', left: { no: 2, text: 'const b = 2;' }, right: { no: 2, text: 'const b = 3;' } },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
input: '',
|
|
105
|
+
streaming: false,
|
|
106
|
+
pendingAsk: null,
|
|
107
|
+
};
|
|
108
|
+
const { frame } = render(state, { cols: 80, rows: 20 });
|
|
109
|
+
assert.match(frame, /Edit a\.js/);
|
|
110
|
+
assert.match(frame, /const b = 2;/);
|
|
111
|
+
assert.match(frame, /const b = 3;/);
|
|
112
|
+
});
|
|
113
|
+
|
|
84
114
|
test('render scrolls the viewport', () => {
|
|
85
115
|
const messages = Array.from({ length: 40 }, (_, i) => ({ role: 'assistant', text: `linea ${i}`, tools: [] }));
|
|
86
116
|
const base = { header: { provider: 'p', model: 'm', cwd: '/x' }, input: '', streaming: false, pendingAsk: null };
|
|
@@ -12,6 +12,8 @@ export const DEFAULT_APP = {
|
|
|
12
12
|
tunnel: { provider: 'tunnelmole', domain: '' },
|
|
13
13
|
authSecret: '',
|
|
14
14
|
user: null,
|
|
15
|
+
// Datos del usuario para que el agente lo conozca (se inyecta en el prompt).
|
|
16
|
+
profile: { address: '', name: '', language: '', notes: '', fields: {} },
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
export function randomToken(bytes = 32) {
|
|
@@ -12,7 +12,7 @@ export { resolveApiKey, resolveEnvMap } from './config/secrets.js';
|
|
|
12
12
|
export { ensureUtf8Console } from './console.js';
|
|
13
13
|
export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments } from './agent/runAgent.js';
|
|
14
14
|
export { estimateTokens, trimContext } from './agent/context.js';
|
|
15
|
-
export { diffLines } from './tools/diff.js';
|
|
15
|
+
export { diffLines, diffRows } from './tools/diff.js';
|
|
16
16
|
export { isGitRepo, gitStatus, gitDiff } from './git/index.js';
|
|
17
17
|
export { summarizeToolCall, previewToolResult } from './tools/labels.js';
|
|
18
18
|
export { MODES, MODE_IDS, resolveMode } from './agent/modes.js';
|
|
@@ -105,6 +105,7 @@ export {
|
|
|
105
105
|
sintetizar,
|
|
106
106
|
listarVoces,
|
|
107
107
|
} from './tts/edge.js';
|
|
108
|
+
export { reconocerGoogle, wavToPcm } from './stt/google.js';
|
|
108
109
|
export { pidAlive, killPidTree } from './pid.js';
|
|
109
110
|
export { openBrowser, openerFor } from './open.js';
|
|
110
111
|
export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW, staticServerChip } from './procs/detect.js';
|
|
@@ -9,6 +9,10 @@ function preset(id, label, baseUrl) {
|
|
|
9
9
|
export const OPENAI_COMPATIBLE_PRESETS = [
|
|
10
10
|
preset('deepseek', 'DeepSeek', 'https://api.deepseek.com/v1'),
|
|
11
11
|
preset('openai', 'OpenAI', 'https://api.openai.com/v1'),
|
|
12
|
+
// Anthropic y Gemini exponen una capa compatible con OpenAI (chat/completions
|
|
13
|
+
// + tools) que usa el mismo Bearer; sirven con este adaptador sin cambios.
|
|
14
|
+
preset('anthropic', 'Anthropic (Claude)', 'https://api.anthropic.com/v1'),
|
|
15
|
+
preset('gemini', 'Google Gemini', 'https://generativelanguage.googleapis.com/v1beta/openai'),
|
|
12
16
|
preset('groq', 'Groq', 'https://api.groq.com/openai/v1'),
|
|
13
17
|
preset('mistral', 'Mistral AI', 'https://api.mistral.ai/v1'),
|
|
14
18
|
preset('xai', 'xAI (Grok)', 'https://api.x.ai/v1'),
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Reconocimiento de voz con el servicio no oficial de Chromium/Google (el mismo
|
|
2
|
+
// que usa el Web Speech API del navegador). No necesita API key: usa la clave
|
|
3
|
+
// publica que trae Chromium. Analogо al TTS de Edge: gratis, sin dependencias y
|
|
4
|
+
// con el mismo riesgo (si cambia el protocolo/clave hay que actualizarlo).
|
|
5
|
+
|
|
6
|
+
const GOOGLE_KEY = 'AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw';
|
|
7
|
+
const GOOGLE_URL = 'https://www.google.com/speech-api/v2/recognize';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extrae el trozo PCM de un WAV y su sample rate. Si no es un WAV conocido,
|
|
11
|
+
* devuelve el buffer tal cual a 16 kHz.
|
|
12
|
+
*/
|
|
13
|
+
export function wavToPcm(buffer) {
|
|
14
|
+
const wav = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer ?? []);
|
|
15
|
+
if (wav.length < 44 || wav.toString('ascii', 0, 4) !== 'RIFF' || wav.toString('ascii', 8, 12) !== 'WAVE') {
|
|
16
|
+
return { pcm: wav, rate: 16000, channels: 1, bits: 16 };
|
|
17
|
+
}
|
|
18
|
+
let rate = 16000;
|
|
19
|
+
let channels = 1;
|
|
20
|
+
let bits = 16;
|
|
21
|
+
let offset = 12;
|
|
22
|
+
while (offset + 8 <= wav.length) {
|
|
23
|
+
const id = wav.toString('ascii', offset, offset + 4);
|
|
24
|
+
const size = wav.readUInt32LE(offset + 4);
|
|
25
|
+
if (id === 'fmt ' && size >= 16) {
|
|
26
|
+
channels = wav.readUInt16LE(offset + 10);
|
|
27
|
+
rate = wav.readUInt32LE(offset + 12);
|
|
28
|
+
bits = wav.readUInt16LE(offset + 22);
|
|
29
|
+
} else if (id === 'data') {
|
|
30
|
+
return { pcm: wav.subarray(offset + 8, offset + 8 + size), rate, channels, bits };
|
|
31
|
+
}
|
|
32
|
+
offset += 8 + size + (size % 2);
|
|
33
|
+
}
|
|
34
|
+
return { pcm: wav.subarray(44), rate, channels, bits };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Convierte PCM multicanal/8-bit a 16-bit mono little-endian. */
|
|
38
|
+
function aMono16(pcm, channels, bits) {
|
|
39
|
+
if (bits !== 16) return pcm;
|
|
40
|
+
if (channels <= 1) return pcm;
|
|
41
|
+
const frames = Math.floor(pcm.length / 2 / channels);
|
|
42
|
+
const out = Buffer.alloc(frames * 2);
|
|
43
|
+
for (let i = 0; i < frames; i++) {
|
|
44
|
+
let sum = 0;
|
|
45
|
+
for (let c = 0; c < channels; c++) sum += pcm.readInt16LE((i * channels + c) * 2);
|
|
46
|
+
out.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(sum / channels))), i * 2);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parsearRespuesta(texto) {
|
|
52
|
+
let transcript = '';
|
|
53
|
+
for (const linea of String(texto ?? '').split('\n')) {
|
|
54
|
+
const value = linea.trim();
|
|
55
|
+
if (!value) continue;
|
|
56
|
+
let data;
|
|
57
|
+
try {
|
|
58
|
+
data = JSON.parse(value);
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const results = data?.result ?? data?.results ?? [];
|
|
63
|
+
for (const r of results) {
|
|
64
|
+
const alt = r?.alternative?.[0];
|
|
65
|
+
if (alt?.transcript) transcript += `${transcript ? ' ' : ''}${alt.transcript}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return transcript.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Transcribe un WAV (o PCM crudo) contra el servicio de Google. Devuelve el
|
|
73
|
+
* texto ('' si no se reconoció nada).
|
|
74
|
+
*/
|
|
75
|
+
export async function reconocerGoogle(audio, { lang = 'es-AR', signal } = {}) {
|
|
76
|
+
const { pcm, rate, channels, bits } = wavToPcm(audio);
|
|
77
|
+
const mono = aMono16(pcm, channels, bits);
|
|
78
|
+
if (!mono.length) return '';
|
|
79
|
+
const url = `${GOOGLE_URL}?output=json&lang=${encodeURIComponent(lang || 'es-AR')}&key=${GOOGLE_KEY}&client=chromium`;
|
|
80
|
+
const res = await fetch(url, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': `audio/l16; rate=${rate || 16000}` },
|
|
83
|
+
body: mono,
|
|
84
|
+
signal,
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const detail = String((await res.text().catch(() => '')) ?? '').slice(0, 200);
|
|
88
|
+
throw new Error(`reconocimiento de Google falló (HTTP ${res.status})${detail ? `: ${detail}` : ''}`);
|
|
89
|
+
}
|
|
90
|
+
return parsearRespuesta(await res.text());
|
|
91
|
+
}
|
|
@@ -7,7 +7,9 @@ const MAX_CELLS = 1_500_000;
|
|
|
7
7
|
|
|
8
8
|
function splitLines(text) {
|
|
9
9
|
if (text === '' || text == null) return [];
|
|
10
|
-
|
|
10
|
+
const lines = String(text).replace(/\r\n/g, '\n').split('\n');
|
|
11
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
|
|
12
|
+
return lines;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
function computeOps(a, b) {
|
|
@@ -88,3 +90,70 @@ export function diffLines(oldText, newText, { context = 3, maxLines = 200 } = {}
|
|
|
88
90
|
if (truncated) out.push(' … (diff truncado)');
|
|
89
91
|
return out.join('\n');
|
|
90
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Diff estructurado para una vista lado a lado (dos columnas): devuelve filas
|
|
96
|
+
* con la linea vieja (izquierda) y la nueva (derecha) ya numeradas. Las
|
|
97
|
+
* supresiones y adiciones de un mismo bloque se emparejan en la misma fila.
|
|
98
|
+
*
|
|
99
|
+
* @returns {Array<{type:'ctx'|'mod'|'del'|'add'|'skip', left:({no:number,text:string}|null), right:({no:number,text:string}|null)}>}
|
|
100
|
+
* [] si no hay cambios.
|
|
101
|
+
*/
|
|
102
|
+
export function diffRows(oldText, newText, { context = 3, maxRows = 400 } = {}) {
|
|
103
|
+
const ops = computeOps(splitLines(oldText), splitLines(newText));
|
|
104
|
+
const changes = ops.map((o, idx) => (o.t === 'eq' ? -1 : idx)).filter((idx) => idx >= 0);
|
|
105
|
+
if (!changes.length) return [];
|
|
106
|
+
|
|
107
|
+
const keep = new Array(ops.length).fill(false);
|
|
108
|
+
for (const idx of changes) {
|
|
109
|
+
for (let j = Math.max(0, idx - context); j <= Math.min(ops.length - 1, idx + context); j++) keep[j] = true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const rows = [];
|
|
113
|
+
let oldNo = 1;
|
|
114
|
+
let newNo = 1;
|
|
115
|
+
let pending = [];
|
|
116
|
+
let skipped = false;
|
|
117
|
+
|
|
118
|
+
const flush = () => {
|
|
119
|
+
const dels = pending.filter((p) => p.t === 'del');
|
|
120
|
+
const adds = pending.filter((p) => p.t === 'add');
|
|
121
|
+
const total = Math.max(dels.length, adds.length);
|
|
122
|
+
for (let i = 0; i < total; i++) {
|
|
123
|
+
const d = dels[i] ?? null;
|
|
124
|
+
const a = adds[i] ?? null;
|
|
125
|
+
rows.push({
|
|
126
|
+
type: d && a ? 'mod' : d ? 'del' : 'add',
|
|
127
|
+
left: d ? { no: d.no, text: d.line } : null,
|
|
128
|
+
right: a ? { no: a.no, text: a.line } : null,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
pending = [];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
for (let idx = 0; idx < ops.length; idx++) {
|
|
135
|
+
const op = ops[idx];
|
|
136
|
+
if (op.t === 'eq') {
|
|
137
|
+
flush();
|
|
138
|
+
if (keep[idx]) {
|
|
139
|
+
rows.push({ type: 'ctx', left: { no: oldNo, text: op.line }, right: { no: newNo, text: op.line } });
|
|
140
|
+
skipped = false;
|
|
141
|
+
} else if (!skipped) {
|
|
142
|
+
rows.push({ type: 'skip', left: null, right: null });
|
|
143
|
+
skipped = true;
|
|
144
|
+
}
|
|
145
|
+
oldNo++;
|
|
146
|
+
newNo++;
|
|
147
|
+
} else if (op.t === 'del') {
|
|
148
|
+
pending.push({ t: 'del', line: op.line, no: oldNo++ });
|
|
149
|
+
} else {
|
|
150
|
+
pending.push({ t: 'add', line: op.line, no: newNo++ });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
flush();
|
|
154
|
+
|
|
155
|
+
if (rows.length > maxRows) {
|
|
156
|
+
return rows.slice(0, maxRows).concat({ type: 'skip', left: null, right: null });
|
|
157
|
+
}
|
|
158
|
+
return rows;
|
|
159
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { diffLines } from '../src/index.js';
|
|
3
|
+
import { diffLines, diffRows } from '../src/index.js';
|
|
4
4
|
|
|
5
5
|
test('diffLines reports added and removed lines with context', () => {
|
|
6
6
|
const diff = diffLines('a\nb\nc\n', 'a\nB\nc\n');
|
|
@@ -18,3 +18,26 @@ test('diffLines marks a new file as additions', () => {
|
|
|
18
18
|
assert.match(diff, /^\+ l1$/m);
|
|
19
19
|
assert.match(diff, /^\+ l2$/m);
|
|
20
20
|
});
|
|
21
|
+
|
|
22
|
+
test('diffRows pairs a removed and an added line side by side', () => {
|
|
23
|
+
const rows = diffRows('a\nb\nc\n', 'a\nB\nc\n');
|
|
24
|
+
const mod = rows.find((r) => r.type === 'mod');
|
|
25
|
+
assert.equal(mod.left.text, 'b');
|
|
26
|
+
assert.equal(mod.left.no, 2);
|
|
27
|
+
assert.equal(mod.right.text, 'B');
|
|
28
|
+
assert.equal(mod.right.no, 2);
|
|
29
|
+
assert.ok(rows.some((r) => r.type === 'ctx' && r.left.no === 1 && r.right.no === 1));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('diffRows numbers additions for a new file', () => {
|
|
33
|
+
const rows = diffRows('', 'l1\nl2\n');
|
|
34
|
+
assert.equal(rows.length, 2);
|
|
35
|
+
assert.equal(rows[0].type, 'add');
|
|
36
|
+
assert.equal(rows[0].left, null);
|
|
37
|
+
assert.equal(rows[0].right.text, 'l1');
|
|
38
|
+
assert.equal(rows[1].right.no, 2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('diffRows returns an empty list when there are no changes', () => {
|
|
42
|
+
assert.deepEqual(diffRows('x\ny\n', 'x\ny\n'), []);
|
|
43
|
+
});
|