kafox-cli 0.8.0 → 0.9.1
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 +12 -1
- package/package.json +3 -2
- package/src/cli.js +3 -3
- package/src/sessions.js +4 -1
- package/src/theme.js +4 -9
- package/src/tools.js +83 -16
- package/src/tui.js +76 -2
- package/src/wallpaper.js +380 -0
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ URL `https://kafox.pypag.com/cli?code=...`, entre na sua conta e clique em
|
|
|
46
46
|
| `kafox continue` / `kafox -c` | volta para a conversa mais recente |
|
|
47
47
|
| `kafox status` | conta, saldo e gateway |
|
|
48
48
|
| `kafox theme` | seletor de tema |
|
|
49
|
-
| `kafox background [url]` | imagem de fundo
|
|
49
|
+
| `kafox background [arquivo\|url]` | imagem de fundo da interface (`off` remove) |
|
|
50
50
|
| `kafox compact` / `clear` | compacta / limpa o contexto do REPL |
|
|
51
51
|
|
|
52
52
|
## Conversas salvas
|
|
@@ -74,6 +74,17 @@ ou a roda do mouse navegam, clique ou Enter retomam a conversa escolhida.
|
|
|
74
74
|
`kafox resume 3f1c` aceita o codigo inteiro ou so o comeco dele, e
|
|
75
75
|
`kafox continue` (ou `-c`) vai direto para a ultima.
|
|
76
76
|
|
|
77
|
+
A conversa guarda o modelo, o tema e o modo **rodar tudo**: se voce retomar um
|
|
78
|
+
chat que estava com a aprovacao automatica ligada, ele volta ligado (e o
|
|
79
|
+
contrario tambem). `kafox --allow-all` continua valendo por cima de tudo.
|
|
80
|
+
|
|
81
|
+
## Imagem de fundo
|
|
82
|
+
|
|
83
|
+
`/background ~/Imagens/ponte.jpg` (ou uma URL https) desenha a foto atras da
|
|
84
|
+
conversa, com o texto por cima. Aceita **PNG** e **JPG**; a imagem e escurecida
|
|
85
|
+
para nao atrapalhar a leitura e se ajusta sozinha ao tamanho do terminal.
|
|
86
|
+
`/background off` remove, e a escolha fica em `~/.kafox/config.json`.
|
|
87
|
+
|
|
77
88
|
## Agentes e subagentes
|
|
78
89
|
|
|
79
90
|
O agente tem a ferramenta `spawn_subagent`: ele lança subagentes com objetivo
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kafox-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Kafox CLI: TUI com streaming, thinking, edit com diff, agent tools (files/shell), MCP e temas",
|
|
5
5
|
"bin": {
|
|
6
6
|
"kafox": "bin/kafox.js"
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"chalk": "4.1.2",
|
|
19
19
|
"commander": "12.1.0",
|
|
20
|
-
"inquirer": "8.2.6"
|
|
20
|
+
"inquirer": "8.2.6",
|
|
21
|
+
"jpeg-js": "^0.4.4"
|
|
21
22
|
},
|
|
22
23
|
"scripts": {
|
|
23
24
|
"test": "node --test \"test/*.test.js\"",
|
package/src/cli.js
CHANGED
|
@@ -28,7 +28,7 @@ const COMMANDS = {
|
|
|
28
28
|
"/agents": "lista os subagentes desta sessao (objetivo, ferramentas, tempo)",
|
|
29
29
|
"/mcp": "list MCP servers and tools (/mcp reload reconecta)",
|
|
30
30
|
"/allow": "aprovacao automatica nesta sessao (/allow off volta a perguntar)",
|
|
31
|
-
"/background": "
|
|
31
|
+
"/background": "imagem de fundo (/background <arquivo|url>; off remove)",
|
|
32
32
|
"/clear": "clear conversation context",
|
|
33
33
|
"/compact": "compact conversation context",
|
|
34
34
|
"/status": "show account, balance and gateway status",
|
|
@@ -315,8 +315,8 @@ async function runREPL(options = {}) {
|
|
|
315
315
|
return runTUI({
|
|
316
316
|
commands: COMMANDS,
|
|
317
317
|
onCommand: handleCommand,
|
|
318
|
-
// /theme
|
|
319
|
-
interactiveCommands: new Set(["/login", "/key"
|
|
318
|
+
// /theme e /background rodam dentro da TUI; os demais abrem prompt no terminal.
|
|
319
|
+
interactiveCommands: new Set(["/login", "/key"]),
|
|
320
320
|
autoApprove: Boolean(options?.allowAll),
|
|
321
321
|
// kafox --resume <codigo> volta para a conversa salva em ~/.kafox/chats.
|
|
322
322
|
resume: options?.resume || null,
|
package/src/sessions.js
CHANGED
|
@@ -69,7 +69,7 @@ function titleFrom(messages, fallback = "") {
|
|
|
69
69
|
return text.length > TITLE_MAX ? `${text.slice(0, TITLE_MAX - 1).trimEnd()}…` : text;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
function save({ id, cwd, model, theme, messages, context, startedAt, title }) {
|
|
72
|
+
function save({ id, cwd, model, theme, allowAll, messages, context, startedAt, title }) {
|
|
73
73
|
if (!id) return null;
|
|
74
74
|
ensureDir();
|
|
75
75
|
const now = new Date().toISOString();
|
|
@@ -80,6 +80,8 @@ function save({ id, cwd, model, theme, messages, context, startedAt, title }) {
|
|
|
80
80
|
cwd: cwd || process.cwd(),
|
|
81
81
|
model: model || null,
|
|
82
82
|
theme: theme || null,
|
|
83
|
+
// "rodar tudo" e uma escolha da conversa: ao retomar, volta como estava.
|
|
84
|
+
allowAll: Boolean(allowAll),
|
|
83
85
|
startedAt: startedAt || now,
|
|
84
86
|
updatedAt: now,
|
|
85
87
|
messages: list,
|
|
@@ -122,6 +124,7 @@ function list() {
|
|
|
122
124
|
title: data.title || titleFrom(data.messages),
|
|
123
125
|
cwd: data.cwd,
|
|
124
126
|
model: data.model,
|
|
127
|
+
allowAll: Boolean(data.allowAll),
|
|
125
128
|
updatedAt: data.updatedAt,
|
|
126
129
|
messages: (data.messages || []).length,
|
|
127
130
|
});
|
package/src/theme.js
CHANGED
|
@@ -356,21 +356,16 @@ async function setContentBackground(url) {
|
|
|
356
356
|
{
|
|
357
357
|
type: "input",
|
|
358
358
|
name: "url",
|
|
359
|
-
message: "
|
|
359
|
+
message: "Imagem de fundo (arquivo ou URL PNG/JPG):",
|
|
360
360
|
validate: (v) =>
|
|
361
|
-
|
|
362
|
-
"Enter a valid image URL (http/https)",
|
|
361
|
+
Boolean(v.trim()) || "Informe o caminho do arquivo ou a URL",
|
|
363
362
|
},
|
|
364
363
|
]);
|
|
365
364
|
bg = answer.url.trim();
|
|
366
365
|
}
|
|
367
366
|
storage.setConfig({ background: bg });
|
|
368
|
-
console.log(chalk.green(`
|
|
369
|
-
console.log(
|
|
370
|
-
chalk.dim(
|
|
371
|
-
"Tip: terminals like Windows Terminal / iTerm2 support image backgrounds. Use `kafox background` to open the modal."
|
|
372
|
-
)
|
|
373
|
-
);
|
|
367
|
+
console.log(chalk.green(`Fundo definido: ${bg}`));
|
|
368
|
+
console.log(chalk.dim("Abra a interface (kafox) para ver a imagem atras da conversa."));
|
|
374
369
|
}
|
|
375
370
|
|
|
376
371
|
module.exports = {
|
package/src/tools.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
|
-
const {
|
|
3
|
+
const { spawn } = require("child_process");
|
|
4
4
|
|
|
5
5
|
// Limites para nao estourar o contexto nem travar a TUI.
|
|
6
6
|
const MAX_READ_CHARS = 40_000;
|
|
@@ -213,27 +213,94 @@ function listDir(cwd, args) {
|
|
|
213
213
|
return `${relative(cwd, full)}\n${rows.join("\n")}${extra}`;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
// Quanto de cada stream fica em memoria enquanto o comando roda. A saida
|
|
217
|
+
// enviada ao modelo e menor (MAX_OUTPUT_CHARS), mas guardar um pouco mais
|
|
218
|
+
// evita perder o comeco do stderr de um comando que fala muito.
|
|
219
|
+
const MAX_COMMAND_BUFFER = 8 * 1024 * 1024;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Executa o comando sem travar a TUI: `spawn` assincrono em vez de
|
|
223
|
+
* `spawnSync`, para o spinner e o relogio do cartao continuarem andando
|
|
224
|
+
* enquanto o processo roda.
|
|
225
|
+
*/
|
|
216
226
|
function runCommand(cwd, args) {
|
|
217
227
|
const command = String(args.command || "").trim();
|
|
218
228
|
if (!command) throw new Error("comando vazio");
|
|
219
229
|
const timeout = Math.min(120_000, Math.max(1_000, Number(args.timeout_ms) || COMMAND_TIMEOUT_MS));
|
|
220
230
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
let child;
|
|
233
|
+
try {
|
|
234
|
+
child = spawn(command, {
|
|
235
|
+
shell: true,
|
|
236
|
+
cwd: path.resolve(cwd),
|
|
237
|
+
env: { ...process.env, KAFOX_CLI: "1" },
|
|
238
|
+
windowsHide: true,
|
|
239
|
+
});
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return resolve(`exit: ?\nstderr:\n${error.message}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let stdout = "";
|
|
245
|
+
let stderr = "";
|
|
246
|
+
let timedOut = false;
|
|
247
|
+
let spawnError = null;
|
|
248
|
+
let done = false;
|
|
229
249
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
250
|
+
// No Windows o `shell: true` cria um cmd.exe que nao leva o processo
|
|
251
|
+
// filho junto ao ser morto: taskkill /T encerra a arvore inteira.
|
|
252
|
+
const killTree = () => {
|
|
253
|
+
if (!child.pid) return;
|
|
254
|
+
if (process.platform === "win32") {
|
|
255
|
+
try {
|
|
256
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
257
|
+
windowsHide: true,
|
|
258
|
+
stdio: "ignore",
|
|
259
|
+
});
|
|
260
|
+
killer.on("error", () => {});
|
|
261
|
+
} catch {}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
child.kill("SIGKILL");
|
|
266
|
+
} catch {}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const timer = setTimeout(() => {
|
|
270
|
+
timedOut = true;
|
|
271
|
+
killTree();
|
|
272
|
+
}, timeout);
|
|
273
|
+
timer.unref?.();
|
|
274
|
+
|
|
275
|
+
child.stdout?.setEncoding("utf8");
|
|
276
|
+
child.stderr?.setEncoding("utf8");
|
|
277
|
+
child.stdout?.on("data", (chunk) => {
|
|
278
|
+
if (stdout.length < MAX_COMMAND_BUFFER) stdout += chunk;
|
|
279
|
+
});
|
|
280
|
+
child.stderr?.on("data", (chunk) => {
|
|
281
|
+
if (stderr.length < MAX_COMMAND_BUFFER) stderr += chunk;
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const finish = (status) => {
|
|
285
|
+
if (done) return;
|
|
286
|
+
done = true;
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
const out = truncate(stdout, MAX_OUTPUT_CHARS);
|
|
289
|
+
const err = truncate(stderr, MAX_OUTPUT_CHARS);
|
|
290
|
+
const parts = [`exit: ${status == null || spawnError ? "?" : status}`];
|
|
291
|
+
if (timedOut) parts.push(`timeout: ${timeout}ms`);
|
|
292
|
+
if (out.trim()) parts.push(`stdout:\n${out.trimEnd()}`);
|
|
293
|
+
if (err.trim()) parts.push(`stderr:\n${err.trimEnd()}`);
|
|
294
|
+
else if (spawnError) parts.push(`stderr:\n${spawnError}`);
|
|
295
|
+
resolve(parts.join("\n"));
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
child.on("error", (error) => {
|
|
299
|
+
spawnError = error.message;
|
|
300
|
+
finish(null);
|
|
301
|
+
});
|
|
302
|
+
child.on("close", (code) => finish(code));
|
|
303
|
+
});
|
|
237
304
|
}
|
|
238
305
|
|
|
239
306
|
async function executeTool(name, args = {}, context = {}) {
|
package/src/tui.js
CHANGED
|
@@ -24,6 +24,7 @@ const { diffBlocks } = require("./diff");
|
|
|
24
24
|
const { toolMeta, parseCommandOutput } = require("./tools");
|
|
25
25
|
const { McpHub } = require("./mcp");
|
|
26
26
|
const sessions = require("./sessions");
|
|
27
|
+
const wallpaper = require("./wallpaper");
|
|
27
28
|
|
|
28
29
|
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
29
30
|
const stripAnsi = (value) => String(value).replace(ANSI, "");
|
|
@@ -510,6 +511,7 @@ function initialState() {
|
|
|
510
511
|
pageSize: 10,
|
|
511
512
|
mouse: true,
|
|
512
513
|
retry: null,
|
|
514
|
+
wallpaper: null,
|
|
513
515
|
sessionId: null,
|
|
514
516
|
sessionStartedAt: null,
|
|
515
517
|
model: cfg.model || null,
|
|
@@ -1335,6 +1337,9 @@ async function runTUI({
|
|
|
1335
1337
|
|
|
1336
1338
|
// resume: "pick" abre o seletor de conversas, "last" pega a mais recente e
|
|
1337
1339
|
// qualquer outro valor e o codigo do chat que o usuario fechou no Ctrl+C.
|
|
1340
|
+
// `kafox --allow-all` (ou KAFOX_ALLOW_ALL=1) vale para a sessao inteira e
|
|
1341
|
+
// ganha do que esta salvo na conversa.
|
|
1342
|
+
const forcedAllow = Boolean(autoApprove) || process.env.KAFOX_ALLOW_ALL === "1";
|
|
1338
1343
|
let resumeMissing = null;
|
|
1339
1344
|
let resumePick = false;
|
|
1340
1345
|
if (resume) {
|
|
@@ -1350,6 +1355,7 @@ async function runTUI({
|
|
|
1350
1355
|
state.context = Array.isArray(saved.context) ? saved.context : [];
|
|
1351
1356
|
if (saved.model) state.model = saved.model;
|
|
1352
1357
|
if (saved.theme) state.theme = saved.theme;
|
|
1358
|
+
state.autoApprove = forcedAllow || Boolean(saved.allowAll);
|
|
1353
1359
|
state.tokens = estimateTokens(state);
|
|
1354
1360
|
} else {
|
|
1355
1361
|
resumeMissing = String(id || resume);
|
|
@@ -1360,8 +1366,8 @@ async function runTUI({
|
|
|
1360
1366
|
state.sessionId = sessions.newId();
|
|
1361
1367
|
state.sessionStartedAt = new Date().toISOString();
|
|
1362
1368
|
}
|
|
1363
|
-
//
|
|
1364
|
-
|
|
1369
|
+
// Sem conversa retomada, so a flag/env libera a aprovacao automatica.
|
|
1370
|
+
if (forcedAllow) state.autoApprove = true;
|
|
1365
1371
|
let mcp = new McpHub({ cwd: process.cwd() });
|
|
1366
1372
|
let spinner = null;
|
|
1367
1373
|
let escTimer = null;
|
|
@@ -1444,6 +1450,7 @@ async function runTUI({
|
|
|
1444
1450
|
cwd: process.cwd(),
|
|
1445
1451
|
model: state.model,
|
|
1446
1452
|
theme: state.theme,
|
|
1453
|
+
allowAll: state.autoApprove,
|
|
1447
1454
|
messages: state.messages,
|
|
1448
1455
|
context: state.context,
|
|
1449
1456
|
startedAt: state.sessionStartedAt,
|
|
@@ -1458,9 +1465,56 @@ async function runTUI({
|
|
|
1458
1465
|
const cols = Math.max(30, stdout.columns || 80);
|
|
1459
1466
|
const rows = Math.max(14, stdout.rows || 24);
|
|
1460
1467
|
const frame = buildFrame(state, cols, rows, commands);
|
|
1468
|
+
const grid = wallpaperGrid(cols, rows);
|
|
1469
|
+
if (grid) frame.lines = wallpaper.paint(frame.lines, grid);
|
|
1461
1470
|
paintFrame(frame, { cols, rows });
|
|
1462
1471
|
}
|
|
1463
1472
|
|
|
1473
|
+
/**
|
|
1474
|
+
* Grade da imagem no tamanho atual do terminal. Fica em cache: so e refeita
|
|
1475
|
+
* quando a tela muda de tamanho ou quando o fundo troca.
|
|
1476
|
+
*/
|
|
1477
|
+
function wallpaperGrid(cols, rows) {
|
|
1478
|
+
const bg = state.wallpaper;
|
|
1479
|
+
if (!bg?.image) return null;
|
|
1480
|
+
if (!bg.grid || bg.cols !== cols || bg.rows !== rows || bg.theme !== state.theme) {
|
|
1481
|
+
const base = wallpaper.hexToRgb(themeColors(state.theme).bg) || [12, 12, 16];
|
|
1482
|
+
bg.grid = wallpaper.buildGrid(bg.image, cols, rows, { dim: 0.45, base });
|
|
1483
|
+
bg.cols = cols;
|
|
1484
|
+
bg.rows = rows;
|
|
1485
|
+
bg.theme = state.theme;
|
|
1486
|
+
}
|
|
1487
|
+
return bg.grid;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** `/background <arquivo|url>` desenha a foto atras da conversa. */
|
|
1491
|
+
async function setBackground(source, { quiet = false } = {}) {
|
|
1492
|
+
const value = String(source || "").trim();
|
|
1493
|
+
if (!value || ["off", "none", "0", "limpar"].includes(value.toLowerCase())) {
|
|
1494
|
+
state.wallpaper = null;
|
|
1495
|
+
state.modal = { title: "Fundo", text: "Imagem de fundo removida." };
|
|
1496
|
+
storage.setConfig({ background: null });
|
|
1497
|
+
return render();
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
state.modal = null;
|
|
1501
|
+
state.wallpaper = { source: value, image: null, grid: null, cols: 0, rows: 0 };
|
|
1502
|
+
render();
|
|
1503
|
+
try {
|
|
1504
|
+
const image = await wallpaper.load(value);
|
|
1505
|
+
state.wallpaper = { source: value, image, grid: null, cols: 0, rows: 0 };
|
|
1506
|
+
storage.setConfig({ background: value });
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
state.wallpaper = null;
|
|
1509
|
+
// No inicio da sessao o fundo salvo pode falhar (offline, arquivo movido):
|
|
1510
|
+
// nao vale abrir um modal por isso, so avisa quando o usuario pediu.
|
|
1511
|
+
if (!quiet) {
|
|
1512
|
+
state.modal = { title: "Fundo", text: `Nao consegui carregar ${value}.\n${error.message}` };
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return render();
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1464
1518
|
/**
|
|
1465
1519
|
* Desenho incremental: manda so as linhas diferentes, dentro de uma unica
|
|
1466
1520
|
* atualizacao sincronizada. E o que impede o piscar do modal e do streaming.
|
|
@@ -1562,6 +1616,7 @@ async function runTUI({
|
|
|
1562
1616
|
state.context = Array.isArray(saved.context) ? saved.context : [];
|
|
1563
1617
|
if (saved.model) state.model = saved.model;
|
|
1564
1618
|
if (saved.theme) state.theme = saved.theme;
|
|
1619
|
+
state.autoApprove = forcedAllow || Boolean(saved.allowAll);
|
|
1565
1620
|
state.files = [];
|
|
1566
1621
|
state.tokens = estimateTokens(state);
|
|
1567
1622
|
state.scroll = 0;
|
|
@@ -1647,9 +1702,25 @@ async function runTUI({
|
|
|
1647
1702
|
// /models e /model sem argumento abrem o mesmo seletor navegavel.
|
|
1648
1703
|
if ((name === "/model" || name === "/models") && !parts[1]) return openModelPicker();
|
|
1649
1704
|
if (name === "/theme") return openThemePicker();
|
|
1705
|
+
if (name === "/background" || name === "/bg") {
|
|
1706
|
+
const value = parts.slice(1).join(" ");
|
|
1707
|
+
if (!value) {
|
|
1708
|
+
state.modal = {
|
|
1709
|
+
title: "Fundo",
|
|
1710
|
+
text:
|
|
1711
|
+
"kafox /background <arquivo ou URL>\n\n" +
|
|
1712
|
+
"Aceita PNG e JPG, de um arquivo do computador ou de um link https.\n" +
|
|
1713
|
+
"Exemplo: /background ~/Imagens/ponte.jpg\n" +
|
|
1714
|
+
"/background off remove a imagem.",
|
|
1715
|
+
};
|
|
1716
|
+
return render();
|
|
1717
|
+
}
|
|
1718
|
+
return setBackground(value);
|
|
1719
|
+
}
|
|
1650
1720
|
if (name === "/allow") {
|
|
1651
1721
|
const argument = (parts[1] || "on").toLowerCase();
|
|
1652
1722
|
state.autoApprove = argument !== "off" && argument !== "ask";
|
|
1723
|
+
persist();
|
|
1653
1724
|
state.modal = {
|
|
1654
1725
|
title: "Aprovacao",
|
|
1655
1726
|
text: state.autoApprove
|
|
@@ -1958,6 +2029,7 @@ async function runTUI({
|
|
|
1958
2029
|
if (!pending) return;
|
|
1959
2030
|
state.approval = null;
|
|
1960
2031
|
if (decision === "always") state.autoApprove = true;
|
|
2032
|
+
if (decision === "always") persist();
|
|
1961
2033
|
render();
|
|
1962
2034
|
pending.resolve(decision !== "deny");
|
|
1963
2035
|
}
|
|
@@ -2277,6 +2349,8 @@ async function runTUI({
|
|
|
2277
2349
|
render();
|
|
2278
2350
|
if (resumePick) openChatPicker();
|
|
2279
2351
|
connectMcp().catch(() => {});
|
|
2352
|
+
const savedBackground = storage.getConfig().background;
|
|
2353
|
+
if (savedBackground) setBackground(savedBackground, { quiet: true }).catch(() => {});
|
|
2280
2354
|
|
|
2281
2355
|
spinner = setInterval(() => {
|
|
2282
2356
|
if (!state.busy || closed) return;
|
package/src/wallpaper.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Imagem de fundo da TUI.
|
|
3
|
+
*
|
|
4
|
+
* O terminal nao tem "papel de parede": o jeito de mostrar uma foto e desenhar
|
|
5
|
+
* cada celula com dois pixels, um no primeiro plano e outro no fundo do caractere
|
|
6
|
+
* "▀" (meio bloco). Assim a imagem aparece atras do texto e o desenho continua
|
|
7
|
+
* legivel porque as cores sao escurecidas antes de entrar na tela.
|
|
8
|
+
*
|
|
9
|
+
* Suporta PNG (decodificado aqui, sem dependencia) e JPEG (jpeg-js), de um
|
|
10
|
+
* arquivo local ou de uma URL http(s).
|
|
11
|
+
*/
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const os = require("os");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const zlib = require("zlib");
|
|
16
|
+
|
|
17
|
+
const MAX_BYTES = 12 * 1024 * 1024;
|
|
18
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
19
|
+
const HALF_BLOCK = "▀";
|
|
20
|
+
|
|
21
|
+
/* ------------------------------------------------------------------ decoders */
|
|
22
|
+
|
|
23
|
+
function paeth(a, b, c) {
|
|
24
|
+
const p = a + b - c;
|
|
25
|
+
const pa = Math.abs(p - a);
|
|
26
|
+
const pb = Math.abs(p - b);
|
|
27
|
+
const pc = Math.abs(p - c);
|
|
28
|
+
if (pa <= pb && pa <= pc) return a;
|
|
29
|
+
return pb <= pc ? b : c;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** PNG 8 bits (RGB, RGBA, tons de cinza e paleta), sem entrelacamento. */
|
|
33
|
+
function decodePng(buffer) {
|
|
34
|
+
if (buffer.length < 8 || buffer.readUInt32BE(0) !== 0x89504e47) {
|
|
35
|
+
throw new Error("nao e um PNG valido");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let offset = 8;
|
|
39
|
+
let width = 0;
|
|
40
|
+
let height = 0;
|
|
41
|
+
let bitDepth = 8;
|
|
42
|
+
let colorType = 6;
|
|
43
|
+
let interlace = 0;
|
|
44
|
+
let palette = null;
|
|
45
|
+
const chunks = [];
|
|
46
|
+
|
|
47
|
+
while (offset + 8 <= buffer.length) {
|
|
48
|
+
const length = buffer.readUInt32BE(offset);
|
|
49
|
+
const type = buffer.toString("ascii", offset + 4, offset + 8);
|
|
50
|
+
const data = buffer.subarray(offset + 8, offset + 8 + length);
|
|
51
|
+
offset += 12 + length;
|
|
52
|
+
|
|
53
|
+
if (type === "IHDR") {
|
|
54
|
+
width = data.readUInt32BE(0);
|
|
55
|
+
height = data.readUInt32BE(4);
|
|
56
|
+
bitDepth = data[8];
|
|
57
|
+
colorType = data[9];
|
|
58
|
+
interlace = data[12];
|
|
59
|
+
} else if (type === "PLTE") {
|
|
60
|
+
palette = Buffer.from(data);
|
|
61
|
+
} else if (type === "IDAT") {
|
|
62
|
+
chunks.push(Buffer.from(data));
|
|
63
|
+
} else if (type === "IEND") {
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!width || !height) throw new Error("PNG sem cabecalho IHDR");
|
|
69
|
+
if (bitDepth !== 8) throw new Error(`PNG ${bitDepth} bits por canal nao suportado (use 8)`);
|
|
70
|
+
if (interlace) throw new Error("PNG entrelacado nao suportado");
|
|
71
|
+
|
|
72
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType];
|
|
73
|
+
if (!channels) throw new Error(`tipo de cor PNG ${colorType} nao suportado`);
|
|
74
|
+
if (colorType === 3 && !palette) throw new Error("PNG com paleta mas sem PLTE");
|
|
75
|
+
|
|
76
|
+
const raw = zlib.inflateSync(Buffer.concat(chunks));
|
|
77
|
+
const stride = width * channels;
|
|
78
|
+
const lines = Buffer.alloc(stride * height);
|
|
79
|
+
|
|
80
|
+
let read = 0;
|
|
81
|
+
for (let y = 0; y < height; y += 1) {
|
|
82
|
+
if (read >= raw.length) throw new Error("PNG truncado");
|
|
83
|
+
const filter = raw[read];
|
|
84
|
+
read += 1;
|
|
85
|
+
const out = lines.subarray(y * stride, (y + 1) * stride);
|
|
86
|
+
const prev = y ? lines.subarray((y - 1) * stride, y * stride) : null;
|
|
87
|
+
|
|
88
|
+
for (let x = 0; x < stride; x += 1) {
|
|
89
|
+
const value = raw[read + x];
|
|
90
|
+
const left = x >= channels ? out[x - channels] : 0;
|
|
91
|
+
const up = prev ? prev[x] : 0;
|
|
92
|
+
const upLeft = prev && x >= channels ? prev[x - channels] : 0;
|
|
93
|
+
let byte;
|
|
94
|
+
switch (filter) {
|
|
95
|
+
case 0:
|
|
96
|
+
byte = value;
|
|
97
|
+
break;
|
|
98
|
+
case 1:
|
|
99
|
+
byte = value + left;
|
|
100
|
+
break;
|
|
101
|
+
case 2:
|
|
102
|
+
byte = value + up;
|
|
103
|
+
break;
|
|
104
|
+
case 3:
|
|
105
|
+
byte = value + ((left + up) >> 1);
|
|
106
|
+
break;
|
|
107
|
+
case 4:
|
|
108
|
+
byte = value + paeth(left, up, upLeft);
|
|
109
|
+
break;
|
|
110
|
+
default:
|
|
111
|
+
throw new Error(`filtro PNG ${filter} desconhecido`);
|
|
112
|
+
}
|
|
113
|
+
out[x] = byte & 0xff;
|
|
114
|
+
}
|
|
115
|
+
read += stride;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pixels = new Uint8Array(width * height * 4);
|
|
119
|
+
for (let y = 0; y < height; y += 1) {
|
|
120
|
+
for (let x = 0; x < width; x += 1) {
|
|
121
|
+
const source = y * stride + x * channels;
|
|
122
|
+
const target = (y * width + x) * 4;
|
|
123
|
+
if (colorType === 0) {
|
|
124
|
+
const gray = lines[source];
|
|
125
|
+
pixels[target] = gray;
|
|
126
|
+
pixels[target + 1] = gray;
|
|
127
|
+
pixels[target + 2] = gray;
|
|
128
|
+
pixels[target + 3] = 255;
|
|
129
|
+
} else if (colorType === 4) {
|
|
130
|
+
const gray = lines[source];
|
|
131
|
+
pixels[target] = gray;
|
|
132
|
+
pixels[target + 1] = gray;
|
|
133
|
+
pixels[target + 2] = gray;
|
|
134
|
+
pixels[target + 3] = lines[source + 1];
|
|
135
|
+
} else if (colorType === 3) {
|
|
136
|
+
const index = lines[source] * 3;
|
|
137
|
+
pixels[target] = palette[index] ?? 0;
|
|
138
|
+
pixels[target + 1] = palette[index + 1] ?? 0;
|
|
139
|
+
pixels[target + 2] = palette[index + 2] ?? 0;
|
|
140
|
+
pixels[target + 3] = 255;
|
|
141
|
+
} else {
|
|
142
|
+
pixels[target] = lines[source];
|
|
143
|
+
pixels[target + 1] = lines[source + 1];
|
|
144
|
+
pixels[target + 2] = lines[source + 2];
|
|
145
|
+
pixels[target + 3] = colorType === 6 ? lines[source + 3] : 255;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { width, height, pixels };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function decodeJpeg(buffer) {
|
|
154
|
+
// Carregado so quando alguem usa JPEG: quem nunca pediu fundo nao paga por ele.
|
|
155
|
+
let jpeg;
|
|
156
|
+
try {
|
|
157
|
+
jpeg = require("jpeg-js");
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error("JPEG precisa do pacote jpeg-js (npm i -g kafox-cli de novo)");
|
|
160
|
+
}
|
|
161
|
+
const image = jpeg.decode(Buffer.from(buffer), { useTArray: true, formatAsRGBA: true });
|
|
162
|
+
if (!image?.width) throw new Error("JPEG invalido");
|
|
163
|
+
return { width: image.width, height: image.height, pixels: image.data };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Descobre o formato pelos primeiros bytes e devolve RGBA. */
|
|
167
|
+
function decodeBuffer(buffer) {
|
|
168
|
+
if (!buffer?.length) throw new Error("imagem vazia");
|
|
169
|
+
if (buffer.length < 12) throw new Error("arquivo pequeno demais para ser imagem");
|
|
170
|
+
|
|
171
|
+
const isPng = buffer.readUInt32BE(0) === 0x89504e47;
|
|
172
|
+
const isJpeg = buffer[0] === 0xff && buffer[1] === 0xd8;
|
|
173
|
+
if (isPng) return decodePng(buffer);
|
|
174
|
+
if (isJpeg) return decodeJpeg(buffer);
|
|
175
|
+
|
|
176
|
+
const head = buffer.toString("ascii", 0, 6);
|
|
177
|
+
if (head.startsWith("GIF")) throw new Error("GIF nao suportado: use PNG ou JPG");
|
|
178
|
+
if (head.startsWith("RIFF") && buffer.toString("ascii", 8, 12) === "WEBP") {
|
|
179
|
+
throw new Error("WebP nao suportado: use PNG ou JPG");
|
|
180
|
+
}
|
|
181
|
+
throw new Error("formato desconhecido: use PNG ou JPG");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* ------------------------------------------------------------------ carregar */
|
|
185
|
+
|
|
186
|
+
function expandHome(source) {
|
|
187
|
+
if (!source.startsWith("~")) return source;
|
|
188
|
+
return path.join(os.homedir(), source.slice(1));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function readSource(source) {
|
|
192
|
+
const value = String(source || "").trim();
|
|
193
|
+
if (!value) throw new Error("informe o caminho ou a URL da imagem");
|
|
194
|
+
|
|
195
|
+
if (/^https?:\/\//i.test(value)) {
|
|
196
|
+
const response = await fetch(value, {
|
|
197
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
198
|
+
headers: { accept: "image/png,image/jpeg,image/*" },
|
|
199
|
+
});
|
|
200
|
+
if (!response.ok) throw new Error(`download falhou (HTTP ${response.status})`);
|
|
201
|
+
const size = Number(response.headers.get("content-length") || 0);
|
|
202
|
+
if (size > MAX_BYTES) throw new Error(`imagem grande demais (${Math.round(size / 1024 / 1024)} MB)`);
|
|
203
|
+
return Buffer.from(await response.arrayBuffer());
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const file = path.resolve(expandHome(value));
|
|
207
|
+
const info = fs.statSync(file);
|
|
208
|
+
if (!info.isFile()) throw new Error(`${value} nao e um arquivo`);
|
|
209
|
+
if (info.size > MAX_BYTES) throw new Error(`imagem grande demais (${Math.round(info.size / 1024 / 1024)} MB)`);
|
|
210
|
+
return fs.readFileSync(file);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Carrega de URL ou arquivo e devolve a imagem em RGBA. */
|
|
214
|
+
async function load(source) {
|
|
215
|
+
return decodeBuffer(await readSource(source));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* ------------------------------------------------------------------ desenho */
|
|
219
|
+
|
|
220
|
+
function hexToRgb(value) {
|
|
221
|
+
const hex = String(value || "").replace("#", "");
|
|
222
|
+
if (hex.length !== 6) return null;
|
|
223
|
+
const number = parseInt(hex, 16);
|
|
224
|
+
if (Number.isNaN(number)) return null;
|
|
225
|
+
return [(number >> 16) & 0xff, (number >> 8) & 0xff, number & 0xff];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Recorta o centro da imagem no formato da tela. Cada meia linha do terminal e
|
|
230
|
+
* um pixel quadrado, entao a proporcao alvo e `cols x rows*2`.
|
|
231
|
+
*/
|
|
232
|
+
function coverRegion(image, cols, rows) {
|
|
233
|
+
const target = cols / Math.max(1, rows * 2);
|
|
234
|
+
const current = image.width / image.height;
|
|
235
|
+
if (current > target) {
|
|
236
|
+
const width = image.height * target;
|
|
237
|
+
return { x: (image.width - width) / 2, y: 0, width, height: image.height };
|
|
238
|
+
}
|
|
239
|
+
const height = image.width / target;
|
|
240
|
+
return { x: 0, y: (image.height - height) / 2, width: image.width, height };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function averageColor(image, x0, y0, x1, y1, samples = 4) {
|
|
244
|
+
const { pixels, width } = image;
|
|
245
|
+
let r = 0;
|
|
246
|
+
let g = 0;
|
|
247
|
+
let b = 0;
|
|
248
|
+
let count = 0;
|
|
249
|
+
const stepX = (x1 - x0) / samples;
|
|
250
|
+
const stepY = (y1 - y0) / samples;
|
|
251
|
+
|
|
252
|
+
for (let sy = 0; sy < samples; sy += 1) {
|
|
253
|
+
const py = Math.min(image.height - 1, Math.floor(y0 + sy * stepY));
|
|
254
|
+
for (let sx = 0; sx < samples; sx += 1) {
|
|
255
|
+
const px = Math.min(image.width - 1, Math.floor(x0 + sx * stepX));
|
|
256
|
+
const index = (py * width + px) * 4;
|
|
257
|
+
r += pixels[index];
|
|
258
|
+
g += pixels[index + 1];
|
|
259
|
+
b += pixels[index + 2];
|
|
260
|
+
count += 1;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!count) return [0, 0, 0];
|
|
265
|
+
return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Monta a grade de celulas: cada celula tem dois pixels (topo e base do "▀").
|
|
270
|
+
* `dim` mistura a imagem com `base` para o texto continuar legivel.
|
|
271
|
+
*/
|
|
272
|
+
function buildGrid(image, cols, rows, { dim = 0.45, base = null } = {}) {
|
|
273
|
+
const columns = Math.max(1, Math.floor(cols));
|
|
274
|
+
const lines = Math.max(1, Math.floor(rows) * 2);
|
|
275
|
+
const region = coverRegion(image, columns, lines);
|
|
276
|
+
const back = base || [10, 10, 14];
|
|
277
|
+
const mix = (color) => color.map((value, index) => Math.round(value * dim + back[index] * (1 - dim)));
|
|
278
|
+
|
|
279
|
+
const cells = [];
|
|
280
|
+
for (let row = 0; row < lines; row += 1) {
|
|
281
|
+
const y0 = region.y + (row * region.height) / lines;
|
|
282
|
+
const y1 = region.y + ((row + 1) * region.height) / lines;
|
|
283
|
+
const line = [];
|
|
284
|
+
for (let col = 0; col < columns; col += 1) {
|
|
285
|
+
const x0 = region.x + (col * region.width) / columns;
|
|
286
|
+
const x1 = region.x + ((col + 1) * region.width) / columns;
|
|
287
|
+
line.push(mix(averageColor(image, x0, y0, x1, y1)));
|
|
288
|
+
}
|
|
289
|
+
cells.push(line);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Junta as meias linhas em celulas com topo e base.
|
|
293
|
+
const grid = [];
|
|
294
|
+
for (let row = 0; row < lines; row += 2) {
|
|
295
|
+
const top = cells[row];
|
|
296
|
+
const bottom = cells[row + 1] || cells[row];
|
|
297
|
+
grid.push(top.map((color, col) => ({ top: color, bottom: bottom[col] })));
|
|
298
|
+
}
|
|
299
|
+
return { cols: columns, rows: Math.floor(rows), cells: grid };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* ------------------------------------------------------------------ ANSI */
|
|
303
|
+
|
|
304
|
+
/** Atualiza o estilo corrente a partir de uma sequencia SGR. */
|
|
305
|
+
function trackStyle(sequence, style) {
|
|
306
|
+
const params = sequence.slice(2, -1);
|
|
307
|
+
const parts = params ? params.split(";") : ["0"];
|
|
308
|
+
if (parts.includes("0")) style.length = 0;
|
|
309
|
+
for (const part of parts) {
|
|
310
|
+
if (part === "0" || part === "39" || part === "49") continue;
|
|
311
|
+
style.push(part);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function backgroundActive(style) {
|
|
316
|
+
return style.some((part) => {
|
|
317
|
+
const code = Number(part);
|
|
318
|
+
return (code >= 40 && code <= 47) || (code >= 100 && code <= 107) || code === 48;
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function cellEscape({ top, bottom }) {
|
|
323
|
+
return `\x1b[38;2;${top[0]};${top[1]};${top[2]}m\x1b[48;2;${bottom[0]};${bottom[1]};${bottom[2]}m`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Troca os espacos vazios do frame pelas celulas da imagem. Texto e celulas com
|
|
328
|
+
* fundo proprio (caixas, paineis) ficam como estao.
|
|
329
|
+
*/
|
|
330
|
+
function paint(lines, grid) {
|
|
331
|
+
if (!grid?.cells?.length) return lines;
|
|
332
|
+
|
|
333
|
+
return lines.map((line, row) => {
|
|
334
|
+
const cells = grid.cells[row];
|
|
335
|
+
if (!cells) return line;
|
|
336
|
+
if (!line.includes(" ")) return line;
|
|
337
|
+
|
|
338
|
+
let out = "";
|
|
339
|
+
let col = 0;
|
|
340
|
+
let index = 0;
|
|
341
|
+
const style = [];
|
|
342
|
+
|
|
343
|
+
while (index < line.length) {
|
|
344
|
+
if (line[index] === "\x1b") {
|
|
345
|
+
const match = /^\x1b\[[0-9;]*m/.exec(line.slice(index));
|
|
346
|
+
if (match) {
|
|
347
|
+
out += match[0];
|
|
348
|
+
trackStyle(match[0], style);
|
|
349
|
+
index += match[0].length;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const char = line[index];
|
|
355
|
+
const cell = cells[col];
|
|
356
|
+
if (char === " " && cell && !backgroundActive(style)) {
|
|
357
|
+
const restore = style.length ? `\x1b[${style.join(";")}m` : "";
|
|
358
|
+
out += `\x1b[0m${cellEscape(cell)}${HALF_BLOCK}${restore}`;
|
|
359
|
+
} else {
|
|
360
|
+
out += char;
|
|
361
|
+
}
|
|
362
|
+
index += 1;
|
|
363
|
+
col += 1;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return out;
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = {
|
|
371
|
+
load,
|
|
372
|
+
decodeBuffer,
|
|
373
|
+
decodePng,
|
|
374
|
+
buildGrid,
|
|
375
|
+
paint,
|
|
376
|
+
coverRegion,
|
|
377
|
+
hexToRgb,
|
|
378
|
+
HALF_BLOCK,
|
|
379
|
+
MAX_BYTES,
|
|
380
|
+
};
|