kafox-cli 0.3.0 → 0.7.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 +130 -11
- package/bin/kafox.js +73 -2
- package/package.json +4 -3
- package/src/agent.js +385 -0
- package/src/auth.js +77 -30
- package/src/cli.js +83 -10
- package/src/diff.js +193 -0
- package/src/gateway.js +235 -48
- package/src/login-screen.js +203 -0
- package/src/logo.js +108 -0
- package/src/mascot.js +3 -31
- package/src/mcp.js +557 -0
- package/src/models.js +1 -4
- package/src/runner.js +98 -0
- package/src/sessions.js +138 -0
- package/src/storage.js +2 -1
- package/src/subagents.js +156 -0
- package/src/theme.js +327 -3
- package/src/tools.js +371 -0
- package/src/tui.js +1707 -213
package/src/auth.js
CHANGED
|
@@ -20,12 +20,11 @@ async function ensureSession() {
|
|
|
20
20
|
return null;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Login por device code. Com TTY abre a tela cheia (logo grande, link no meio);
|
|
25
|
+
* sem TTY (pipe/CI) mantem o fluxo de texto, que continua funcionando.
|
|
26
|
+
*/
|
|
23
27
|
async function login() {
|
|
24
|
-
console.log(MASCOT);
|
|
25
|
-
console.log(
|
|
26
|
-
chalk.cyan(" Kafox CLI - login by device code (SSO)\n")
|
|
27
|
-
);
|
|
28
|
-
|
|
29
28
|
const res = await fetchApi("/api/cli/device", {
|
|
30
29
|
method: "POST",
|
|
31
30
|
body: "{}",
|
|
@@ -36,10 +35,15 @@ async function login() {
|
|
|
36
35
|
return;
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
const url = `${storage.getApiUrl()}/cli?code=${data.user_code}`;
|
|
39
|
+
|
|
40
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
41
|
+
return loginWithScreen({ url, data });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log(MASCOT);
|
|
45
|
+
console.log(chalk.cyan(" Kafox CLI - login by device code (SSO)\n"));
|
|
46
|
+
console.log(chalk.bold(" Open this URL in your browser to approve:\n") + chalk.cyan(` ${url}\n`));
|
|
43
47
|
console.log(` Code: ${chalk.bold(data.user_code)} (expires in ${data.expires_in}s)\n`);
|
|
44
48
|
|
|
45
49
|
const interval = Math.max(1, data.interval) * 1000;
|
|
@@ -50,32 +54,75 @@ async function login() {
|
|
|
50
54
|
process.stdout.write(`\r Waiting for approval${".".repeat(dots % 4)} `);
|
|
51
55
|
dots += 1;
|
|
52
56
|
await new Promise((r) => setTimeout(r, interval));
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
);
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (pd.status === "denied" || poll.status === 403) {
|
|
65
|
-
console.log(chalk.red("\n Login denied."));
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
if (poll.status === 404 || poll.status === 410) {
|
|
69
|
-
console.log(chalk.red("\n Device code expired. Run `kafox login` again."));
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
} catch {
|
|
73
|
-
// ignore transient errors, keep polling
|
|
57
|
+
const outcome = await pollDevice(data);
|
|
58
|
+
if (outcome.ok) {
|
|
59
|
+
console.log(chalk.green("\n Approved! Session saved to ~/.kafox/token.json"));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (outcome.stop) {
|
|
63
|
+
console.log(chalk.red(`\n ${outcome.error}`));
|
|
64
|
+
return;
|
|
74
65
|
}
|
|
75
66
|
}
|
|
76
67
|
console.log(chalk.red("\n Device code expired. Run `kafox login` again."));
|
|
77
68
|
}
|
|
78
69
|
|
|
70
|
+
/** Uma consulta do device code; grava a sessao quando aprovada. */
|
|
71
|
+
async function pollDevice(data) {
|
|
72
|
+
let poll;
|
|
73
|
+
try {
|
|
74
|
+
poll = await fetchApi(`/api/cli/poll/${encodeURIComponent(data.device_code)}`);
|
|
75
|
+
} catch {
|
|
76
|
+
return { ok: false, stop: false };
|
|
77
|
+
}
|
|
78
|
+
const pd = await poll.json().catch(() => ({}));
|
|
79
|
+
if (poll.status === 200 && pd.status === "approved") {
|
|
80
|
+
const previous = storage.getToken() || {};
|
|
81
|
+
storage.setToken({ session: pd.session, csrf: previous.csrf });
|
|
82
|
+
return { ok: true, stop: true };
|
|
83
|
+
}
|
|
84
|
+
if (pd.status === "denied" || poll.status === 403) {
|
|
85
|
+
return { ok: false, stop: true, error: "Login denied." };
|
|
86
|
+
}
|
|
87
|
+
if (poll.status === 404 || poll.status === 410) {
|
|
88
|
+
return { ok: false, stop: true, error: "Device code expired. Run `kafox login` again." };
|
|
89
|
+
}
|
|
90
|
+
return { ok: false, stop: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function loginWithScreen({ url, data }) {
|
|
94
|
+
const { runLoginScreen } = require("./login-screen");
|
|
95
|
+
const { version } = require("../package.json");
|
|
96
|
+
const theme = storage.getConfig().theme || "default";
|
|
97
|
+
|
|
98
|
+
const result = await runLoginScreen({
|
|
99
|
+
url,
|
|
100
|
+
code: data.user_code,
|
|
101
|
+
interval: data.interval,
|
|
102
|
+
expiresIn: data.expires_in,
|
|
103
|
+
theme,
|
|
104
|
+
version,
|
|
105
|
+
poll: async () => {
|
|
106
|
+
const outcome = await pollDevice(data);
|
|
107
|
+
if (outcome.ok) return { ok: true };
|
|
108
|
+
if (outcome.stop) return { ok: false, error: outcome.error };
|
|
109
|
+
return { ok: false };
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (result.ok) {
|
|
114
|
+
console.log(chalk.green(" Aprovado! Sessao salva em ~/.kafox/token.json"));
|
|
115
|
+
console.log(chalk.dim(" Bom proveito.\n"));
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (result.cancelled) {
|
|
119
|
+
console.log(chalk.dim(" Login cancelado.\n"));
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
console.log(chalk.red(` ${result.error || "Nao foi possivel entrar"}.\n`));
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
79
126
|
async function logout() {
|
|
80
127
|
storage.clearToken();
|
|
81
128
|
console.log(chalk.green(" Session ended."));
|
package/src/cli.js
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
const readline = require("readline");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
2
4
|
const inquirer = require("inquirer");
|
|
3
5
|
const chalk = require("chalk");
|
|
4
6
|
const storage = require("./storage");
|
|
5
7
|
const { ensureSession, fetchApi } = require("./auth");
|
|
6
8
|
const { ask } = require("./gateway");
|
|
7
9
|
const { runTUI } = require("./tui");
|
|
8
|
-
const { runTheme, setContentBackground
|
|
9
|
-
const {
|
|
10
|
+
const { runTheme, setContentBackground } = require("./theme");
|
|
11
|
+
const { logoText, logoLines } = require("./logo");
|
|
12
|
+
|
|
13
|
+
const mascot = logoText();
|
|
10
14
|
|
|
11
15
|
const { version: VERSION } = require("../package.json");
|
|
12
16
|
|
|
13
17
|
const COMMANDS = {
|
|
14
18
|
"/help": "show this help",
|
|
15
|
-
"/models": "
|
|
16
|
-
"/model": "
|
|
19
|
+
"/models": "escolhe o modelo (setas, mouse ou Enter)",
|
|
20
|
+
"/model": "escolhe o modelo no seletor, ou direto com /model <id>",
|
|
17
21
|
"/balance": "show balance",
|
|
18
22
|
"/pricing": "show recharge packages",
|
|
19
23
|
"/key": "create an API key",
|
|
@@ -21,6 +25,9 @@ const COMMANDS = {
|
|
|
21
25
|
"/logout": "end the session",
|
|
22
26
|
"/whoami": "show the linked account",
|
|
23
27
|
"/theme": "open theme selector",
|
|
28
|
+
"/agents": "lista os subagentes desta sessao (objetivo, ferramentas, tempo)",
|
|
29
|
+
"/mcp": "list MCP servers and tools (/mcp reload reconecta)",
|
|
30
|
+
"/allow": "aprovacao automatica nesta sessao (/allow off volta a perguntar)",
|
|
24
31
|
"/background": "set background image (modal asks for URL)",
|
|
25
32
|
"/clear": "clear conversation context",
|
|
26
33
|
"/compact": "compact conversation context",
|
|
@@ -45,6 +52,13 @@ async function handleCommand(line) {
|
|
|
45
52
|
for (const [key, value] of Object.entries(COMMANDS)) {
|
|
46
53
|
console.log(` ${chalk.bold(key)} ${chalk.dim(value)}`);
|
|
47
54
|
}
|
|
55
|
+
console.log(chalk.cyan("\nKeys:\n"));
|
|
56
|
+
console.log(` ${chalk.bold("Enter")} ${chalk.dim("manda a mensagem; durante a resposta ela entra na fila")}`);
|
|
57
|
+
console.log(` ${chalk.bold("Esc Esc")} ${chalk.dim("para a IA na hora (o primeiro Esc avisa)")}`);
|
|
58
|
+
console.log(` ${chalk.bold("PgUp/PgDn")} ${chalk.dim("ou a roda do mouse rola a conversa")}`);
|
|
59
|
+
console.log(` ${chalk.bold("Ctrl+Y")} ${chalk.dim("copia a ultima resposta para a area de transferencia")}`);
|
|
60
|
+
console.log(` ${chalk.bold("F2")} ${chalk.dim("solta o mouse para selecionar texto com o terminal")}`);
|
|
61
|
+
console.log(` ${chalk.bold("Ctrl+C")} ${chalk.dim("sai")}`);
|
|
48
62
|
console.log("");
|
|
49
63
|
return true;
|
|
50
64
|
case "/models": {
|
|
@@ -94,6 +108,61 @@ async function handleCommand(line) {
|
|
|
94
108
|
case "/theme":
|
|
95
109
|
await runTheme();
|
|
96
110
|
return true;
|
|
111
|
+
case "/allow": {
|
|
112
|
+
console.log(
|
|
113
|
+
chalk.dim(
|
|
114
|
+
"Na interface, /allow liga e /allow off desliga a aprovacao automatica.\n" +
|
|
115
|
+
"Para ja abrir assim: kafox --allow-all (ou KAFOX_ALLOW_ALL=1)."
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
case "/agents": {
|
|
121
|
+
const { listRuns } = require("./subagents");
|
|
122
|
+
const runs = listRuns();
|
|
123
|
+
if (!runs.length) {
|
|
124
|
+
console.log(chalk.dim("\nNenhum subagente nesta sessao."));
|
|
125
|
+
console.log(
|
|
126
|
+
chalk.dim(
|
|
127
|
+
"O agente abre subagentes sozinho quando dividir ajuda: objetivo proprio,\n" +
|
|
128
|
+
"ate 3 em paralelo, cada um em contexto separado. Para pedir na mao:\n" +
|
|
129
|
+
' "lanca dois subagentes: um revisa src/agent.js e outro roda os testes do backend"'
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
console.log(chalk.cyan(`\nSubagentes desta sessao (${runs.length}):\n`));
|
|
135
|
+
for (const run of runs) {
|
|
136
|
+
const status = run.ok ? chalk.green("ok") : chalk.red("falhou");
|
|
137
|
+
console.log(` ${status} ${chalk.bold(run.objective.slice(0, 90))}`);
|
|
138
|
+
console.log(
|
|
139
|
+
chalk.dim(
|
|
140
|
+
` ${run.tools.join(", ") || "read_file"} · ${run.steps} passo(s) · ${Math.round(run.ms / 1000)}s`
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
console.log("");
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
case "/mcp": {
|
|
148
|
+
const { McpHub } = require("./mcp");
|
|
149
|
+
const hub = new McpHub({ cwd: process.cwd() });
|
|
150
|
+
const statuses = hub.statuses();
|
|
151
|
+
hub.close();
|
|
152
|
+
if (!statuses.length) {
|
|
153
|
+
console.log(chalk.dim("\nNenhum servidor MCP configurado."));
|
|
154
|
+
const file = path.join(os.homedir(), ".kafox", "mcp.json");
|
|
155
|
+
console.log(chalk.dim(`Crie ${file} com:`));
|
|
156
|
+
console.log(chalk.dim('{ "mcp": { "playwright": { "type": "local", "command": ["npx","-y","@playwright/mcp@latest"] } } }'));
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
console.log(chalk.cyan("\nServidores MCP:\n"));
|
|
160
|
+
for (const status of statuses) {
|
|
161
|
+
console.log(` ${chalk.bold(status.name)} ${chalk.dim(status.kind)}`);
|
|
162
|
+
}
|
|
163
|
+
console.log(chalk.dim("\nAbra a TUI (kafox) e use /mcp para conectar e ver as ferramentas."));
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
97
166
|
case "/background":
|
|
98
167
|
await setContentBackground(arg || null);
|
|
99
168
|
return true;
|
|
@@ -168,8 +237,8 @@ async function runPlainREPL() {
|
|
|
168
237
|
const cfg = storage.getConfig();
|
|
169
238
|
const theme = cfg.theme || "default";
|
|
170
239
|
|
|
171
|
-
console.log(
|
|
172
|
-
console.log(
|
|
240
|
+
console.log(logoLines(theme).join("\n"));
|
|
241
|
+
console.log(chalk.bold(`\n Kafox CLI v${VERSION}`));
|
|
173
242
|
console.log(chalk.dim(" Type /help for commands. Ctrl+C to exit.\n"));
|
|
174
243
|
|
|
175
244
|
const token = await ensureSession().catch(() => null);
|
|
@@ -224,7 +293,7 @@ async function runPlainREPL() {
|
|
|
224
293
|
});
|
|
225
294
|
}
|
|
226
295
|
|
|
227
|
-
async function runREPL() {
|
|
296
|
+
async function runREPL(options = {}) {
|
|
228
297
|
// Sem TTY (pipe/CI), mantem o REPL linha a linha.
|
|
229
298
|
if (!process.stdin.isTTY || !process.stdout.isTTY) return runPlainREPL();
|
|
230
299
|
|
|
@@ -232,8 +301,8 @@ async function runREPL() {
|
|
|
232
301
|
const token = await ensureSession().catch(() => null);
|
|
233
302
|
|
|
234
303
|
if (!token) {
|
|
235
|
-
console.log(
|
|
236
|
-
console.log(
|
|
304
|
+
console.log(logoLines(cfg.theme || "default").join("\n"));
|
|
305
|
+
console.log(chalk.bold(`\n Kafox CLI v${VERSION}`));
|
|
237
306
|
console.log(chalk.yellow(" You are not logged in."));
|
|
238
307
|
const { doLogin } = await inquirer.prompt([
|
|
239
308
|
{ type: "confirm", name: "doLogin", message: "Login now?", default: true },
|
|
@@ -246,7 +315,11 @@ async function runREPL() {
|
|
|
246
315
|
return runTUI({
|
|
247
316
|
commands: COMMANDS,
|
|
248
317
|
onCommand: handleCommand,
|
|
249
|
-
|
|
318
|
+
// /theme roda dentro da TUI (modal com preview); os demais abrem prompt no terminal.
|
|
319
|
+
interactiveCommands: new Set(["/login", "/key", "/background"]),
|
|
320
|
+
autoApprove: Boolean(options?.allowAll),
|
|
321
|
+
// kafox --resume <codigo> volta para a conversa salva em ~/.kafox/chats.
|
|
322
|
+
resume: options?.resume || null,
|
|
250
323
|
});
|
|
251
324
|
}
|
|
252
325
|
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff de linhas para o preview de edicao (estilo `git diff`, enxuto).
|
|
3
|
+
*
|
|
4
|
+
* Usa LCS quando os trechos sao pequenos e cai para um resumo grosseiro em
|
|
5
|
+
* arquivos grandes, para nao travar a TUI.
|
|
6
|
+
*/
|
|
7
|
+
const MAX_LCS_LINES = 700;
|
|
8
|
+
|
|
9
|
+
function splitLines(text) {
|
|
10
|
+
return String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function commonPrefix(a, b) {
|
|
14
|
+
const limit = Math.min(a.length, b.length);
|
|
15
|
+
let index = 0;
|
|
16
|
+
while (index < limit && a[index] === b[index]) index += 1;
|
|
17
|
+
return index;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function commonSuffix(a, b, prefix) {
|
|
21
|
+
const limit = Math.min(a.length, b.length) - prefix;
|
|
22
|
+
let count = 0;
|
|
23
|
+
while (count < limit && a[a.length - 1 - count] === b[b.length - 1 - count]) count += 1;
|
|
24
|
+
return count;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** LCS classico: devolve as operacoes (del/add) do trecho que mudou. */
|
|
28
|
+
function lcsOps(left, right) {
|
|
29
|
+
const rows = left.length;
|
|
30
|
+
const cols = right.length;
|
|
31
|
+
const table = new Uint16Array((rows + 1) * (cols + 1));
|
|
32
|
+
const width = cols + 1;
|
|
33
|
+
|
|
34
|
+
for (let i = rows - 1; i >= 0; i -= 1) {
|
|
35
|
+
for (let j = cols - 1; j >= 0; j -= 1) {
|
|
36
|
+
table[i * width + j] =
|
|
37
|
+
left[i] === right[j]
|
|
38
|
+
? table[(i + 1) * width + j + 1] + 1
|
|
39
|
+
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ops = [];
|
|
44
|
+
let i = 0;
|
|
45
|
+
let j = 0;
|
|
46
|
+
while (i < rows && j < cols) {
|
|
47
|
+
if (left[i] === right[j]) {
|
|
48
|
+
ops.push({ type: "ctx", text: left[i] });
|
|
49
|
+
i += 1;
|
|
50
|
+
j += 1;
|
|
51
|
+
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
|
|
52
|
+
ops.push({ type: "del", text: left[i] });
|
|
53
|
+
i += 1;
|
|
54
|
+
} else {
|
|
55
|
+
ops.push({ type: "add", text: right[j] });
|
|
56
|
+
j += 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
while (i < rows) ops.push({ type: "del", text: left[i++] });
|
|
60
|
+
while (j < cols) ops.push({ type: "add", text: right[j++] });
|
|
61
|
+
return ops;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function coarseOps(left, right) {
|
|
65
|
+
return [
|
|
66
|
+
...left.map((text) => ({ type: "del", text })),
|
|
67
|
+
...right.map((text) => ({ type: "add", text })),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Todas as operacoes, com numero de linha nos dois lados.
|
|
73
|
+
* `truncated` avisa que o miolo foi tratado de forma grosseira.
|
|
74
|
+
*/
|
|
75
|
+
function buildOps(before, after) {
|
|
76
|
+
const left = splitLines(before);
|
|
77
|
+
const right = splitLines(after);
|
|
78
|
+
const prefix = commonPrefix(left, right);
|
|
79
|
+
const suffix = commonSuffix(left, right, prefix);
|
|
80
|
+
|
|
81
|
+
const leftMiddle = left.slice(prefix, left.length - suffix);
|
|
82
|
+
const rightMiddle = right.slice(prefix, right.length - suffix);
|
|
83
|
+
const truncated = leftMiddle.length > MAX_LCS_LINES || rightMiddle.length > MAX_LCS_LINES;
|
|
84
|
+
const middle = truncated ? coarseOps(leftMiddle, rightMiddle) : lcsOps(leftMiddle, rightMiddle);
|
|
85
|
+
|
|
86
|
+
const ops = [];
|
|
87
|
+
for (let index = 0; index < prefix; index += 1) {
|
|
88
|
+
ops.push({ type: "ctx", text: left[index], beforeNo: index + 1, afterNo: index + 1 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let beforeNo = prefix + 1;
|
|
92
|
+
let afterNo = prefix + 1;
|
|
93
|
+
let changed = false;
|
|
94
|
+
for (const op of middle) {
|
|
95
|
+
if (op.type === "ctx") {
|
|
96
|
+
ops.push({ ...op, beforeNo, afterNo });
|
|
97
|
+
beforeNo += 1;
|
|
98
|
+
afterNo += 1;
|
|
99
|
+
} else if (op.type === "del") {
|
|
100
|
+
ops.push({ ...op, beforeNo });
|
|
101
|
+
beforeNo += 1;
|
|
102
|
+
changed = true;
|
|
103
|
+
} else {
|
|
104
|
+
ops.push({ ...op, afterNo });
|
|
105
|
+
afterNo += 1;
|
|
106
|
+
changed = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (let index = 0; index < suffix; index += 1) {
|
|
111
|
+
const line = left.length - suffix + index;
|
|
112
|
+
ops.push({
|
|
113
|
+
type: "ctx",
|
|
114
|
+
text: left[line],
|
|
115
|
+
beforeNo: line + 1,
|
|
116
|
+
afterNo: right.length - suffix + index + 1,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { ops, truncated, changed };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function count(lines) {
|
|
124
|
+
return {
|
|
125
|
+
added: lines.filter((line) => line.type === "add").length,
|
|
126
|
+
removed: lines.filter((line) => line.type === "del").length,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Mantem so as mudancas e `context` linhas em volta (com o indice original). */
|
|
131
|
+
function keptWithContext(ops, context) {
|
|
132
|
+
const keep = new Set();
|
|
133
|
+
ops.forEach((op, index) => {
|
|
134
|
+
if (op.type === "ctx") return;
|
|
135
|
+
for (let cursor = index - context; cursor <= index + context; cursor += 1) {
|
|
136
|
+
if (cursor >= 0 && cursor < ops.length) keep.add(cursor);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
return ops.map((op, index) => ({ op, index })).filter((entry) => keep.has(entry.index));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Diff enxuto, com numeros de linha e `context` linhas de contexto.
|
|
144
|
+
* Arquivo identico devolve lista vazia.
|
|
145
|
+
*/
|
|
146
|
+
function diffLines(before, after, { context = 2 } = {}) {
|
|
147
|
+
const { ops, truncated, changed } = buildOps(before, after);
|
|
148
|
+
if (!changed) return { lines: [], added: 0, removed: 0, truncated: false };
|
|
149
|
+
const lines = keptWithContext(ops, context).map((entry) => entry.op);
|
|
150
|
+
return { lines, ...count(lines), truncated };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Diff agrupado em blocos separados (cada regiao que mudou vira um bloco),
|
|
155
|
+
* limitado a `maxLines` linhas para caber no preview.
|
|
156
|
+
*/
|
|
157
|
+
function diffBlocks(before, after, { context = 2, maxLines = 60 } = {}) {
|
|
158
|
+
const { ops, truncated, changed } = buildOps(before, after);
|
|
159
|
+
if (!changed) return { blocks: [], added: 0, removed: 0, truncated: false, total: 0 };
|
|
160
|
+
|
|
161
|
+
const kept = keptWithContext(ops, context);
|
|
162
|
+
const blocks = [];
|
|
163
|
+
let current = null;
|
|
164
|
+
let previousIndex = null;
|
|
165
|
+
|
|
166
|
+
for (const entry of kept) {
|
|
167
|
+
if (previousIndex == null || entry.index !== previousIndex + 1) {
|
|
168
|
+
current = [];
|
|
169
|
+
blocks.push(current);
|
|
170
|
+
}
|
|
171
|
+
current.push(entry.op);
|
|
172
|
+
previousIndex = entry.index;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let budget = maxLines;
|
|
176
|
+
const limited = [];
|
|
177
|
+
for (const block of blocks) {
|
|
178
|
+
if (budget <= 0) break;
|
|
179
|
+
const slice = block.slice(0, budget);
|
|
180
|
+
limited.push(slice);
|
|
181
|
+
budget -= slice.length;
|
|
182
|
+
if (slice.length < block.length) limited.push([{ type: "gap", text: "..." }]);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
blocks: limited,
|
|
187
|
+
...count(kept.map((entry) => entry.op)),
|
|
188
|
+
truncated,
|
|
189
|
+
total: blocks.length,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { diffLines, diffBlocks, splitLines, lcsOps, buildOps };
|