kafox-cli 0.8.0 → 0.9.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 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 do terminal |
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.8.0",
3
+ "version": "0.9.0",
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": "set background image (modal asks for URL)",
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 roda dentro da TUI (modal com preview); os demais abrem prompt no terminal.
319
- interactiveCommands: new Set(["/login", "/key", "/background"]),
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: "Background image URL (PNG/JPG):",
359
+ message: "Imagem de fundo (arquivo ou URL PNG/JPG):",
360
360
  validate: (v) =>
361
- /^https?:\/\/.+\.(png|jpg|jpeg|gif|webp)(\?.*)?$/i.test(v.trim()) ||
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(`Background set: ${bg}`));
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 { spawnSync } = require("child_process");
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
- const result = spawnSync(command, {
222
- shell: true,
223
- cwd: path.resolve(cwd),
224
- timeout,
225
- encoding: "utf8",
226
- maxBuffer: 8 * 1024 * 1024,
227
- env: { ...process.env, KAFOX_CLI: "1" },
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
- const stdout = truncate(result.stdout || "", MAX_OUTPUT_CHARS);
231
- const stderr = truncate(result.stderr || "", MAX_OUTPUT_CHARS);
232
- const parts = [`exit: ${result.status == null ? "?" : result.status}`];
233
- if (result.error?.code === "ETIMEDOUT") parts.push(`timeout: ${timeout}ms`);
234
- if (stdout.trim()) parts.push(`stdout:\n${stdout.trimEnd()}`);
235
- if (stderr.trim()) parts.push(`stderr:\n${stderr.trimEnd()}`);
236
- return parts.join("\n");
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
- // `kafox --allow-all` (ou KAFOX_ALLOW_ALL=1) ja comeca sem pedir aprovacao.
1364
- state.autoApprove = Boolean(autoApprove) || process.env.KAFOX_ALLOW_ALL === "1";
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,51 @@ 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) {
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
+ }
1486
+ return bg.grid;
1487
+ }
1488
+
1489
+ /** `/background <arquivo|url>` desenha a foto atras da conversa. */
1490
+ async function setBackground(source) {
1491
+ const value = String(source || "").trim();
1492
+ if (!value || ["off", "none", "0", "limpar"].includes(value.toLowerCase())) {
1493
+ state.wallpaper = null;
1494
+ state.modal = { title: "Fundo", text: "Imagem de fundo removida." };
1495
+ storage.setConfig({ background: null });
1496
+ return render();
1497
+ }
1498
+
1499
+ state.modal = null;
1500
+ state.wallpaper = { source: value, image: null, grid: null, cols: 0, rows: 0 };
1501
+ render();
1502
+ try {
1503
+ const image = await wallpaper.load(value);
1504
+ state.wallpaper = { source: value, image, grid: null, cols: 0, rows: 0 };
1505
+ storage.setConfig({ background: value });
1506
+ } catch (error) {
1507
+ state.wallpaper = null;
1508
+ state.modal = { title: "Fundo", text: `Nao consegui carregar ${value}.\n${error.message}` };
1509
+ }
1510
+ return render();
1511
+ }
1512
+
1464
1513
  /**
1465
1514
  * Desenho incremental: manda so as linhas diferentes, dentro de uma unica
1466
1515
  * atualizacao sincronizada. E o que impede o piscar do modal e do streaming.
@@ -1562,6 +1611,7 @@ async function runTUI({
1562
1611
  state.context = Array.isArray(saved.context) ? saved.context : [];
1563
1612
  if (saved.model) state.model = saved.model;
1564
1613
  if (saved.theme) state.theme = saved.theme;
1614
+ state.autoApprove = forcedAllow || Boolean(saved.allowAll);
1565
1615
  state.files = [];
1566
1616
  state.tokens = estimateTokens(state);
1567
1617
  state.scroll = 0;
@@ -1647,9 +1697,25 @@ async function runTUI({
1647
1697
  // /models e /model sem argumento abrem o mesmo seletor navegavel.
1648
1698
  if ((name === "/model" || name === "/models") && !parts[1]) return openModelPicker();
1649
1699
  if (name === "/theme") return openThemePicker();
1700
+ if (name === "/background" || name === "/bg") {
1701
+ const value = parts.slice(1).join(" ");
1702
+ if (!value) {
1703
+ state.modal = {
1704
+ title: "Fundo",
1705
+ text:
1706
+ "kafox /background <arquivo ou URL>\n\n" +
1707
+ "Aceita PNG e JPG, de um arquivo do computador ou de um link https.\n" +
1708
+ "Exemplo: /background ~/Imagens/ponte.jpg\n" +
1709
+ "/background off remove a imagem.",
1710
+ };
1711
+ return render();
1712
+ }
1713
+ return setBackground(value);
1714
+ }
1650
1715
  if (name === "/allow") {
1651
1716
  const argument = (parts[1] || "on").toLowerCase();
1652
1717
  state.autoApprove = argument !== "off" && argument !== "ask";
1718
+ persist();
1653
1719
  state.modal = {
1654
1720
  title: "Aprovacao",
1655
1721
  text: state.autoApprove
@@ -1958,6 +2024,7 @@ async function runTUI({
1958
2024
  if (!pending) return;
1959
2025
  state.approval = null;
1960
2026
  if (decision === "always") state.autoApprove = true;
2027
+ if (decision === "always") persist();
1961
2028
  render();
1962
2029
  pending.resolve(decision !== "deny");
1963
2030
  }
@@ -2277,6 +2344,8 @@ async function runTUI({
2277
2344
  render();
2278
2345
  if (resumePick) openChatPicker();
2279
2346
  connectMcp().catch(() => {});
2347
+ const savedBackground = storage.getConfig().background;
2348
+ if (savedBackground) setBackground(savedBackground).catch(() => {});
2280
2349
 
2281
2350
  spinner = setInterval(() => {
2282
2351
  if (!state.busy || closed) return;
@@ -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
+ };