kafox-cli 0.7.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 +27 -9
- package/bin/kafox.js +24 -8
- package/package.json +3 -2
- package/src/cli.js +3 -3
- package/src/sessions.js +38 -1
- package/src/theme.js +4 -9
- package/src/tools.js +83 -16
- package/src/tui.js +79 -8
- package/src/wallpaper.js +380 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm install -g kafox-cli
|
|
|
12
12
|
O comando instalado e `kafox`. Alternativa sem npm:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
npm install -g https://kafox.pypag.com/static/downloads/kafox-cli-0.
|
|
15
|
+
npm install -g https://kafox.pypag.com/static/downloads/kafox-cli-0.8.0.tgz
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Requisito: Node.js 22.5+.
|
|
@@ -41,12 +41,12 @@ URL `https://kafox.pypag.com/cli?code=...`, entre na sua conta e clique em
|
|
|
41
41
|
| `kafox key` | cria uma API key |
|
|
42
42
|
| `kafox ask "pergunta" [-m modelo]` | pergunta ao gateway |
|
|
43
43
|
| `kafox agent "objetivo" [-y] [--max-steps 12] [--json]` | roda o agente sem TUI (script/CI) |
|
|
44
|
-
| `kafox history` | lista as conversas salvas (titulo + como retomar) |
|
|
44
|
+
| `kafox history [n]` | lista as conversas salvas (titulo - data + como retomar) |
|
|
45
45
|
| `kafox resume [codigo]` | retoma uma conversa; sem codigo abre o seletor na tela |
|
|
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
|
|
@@ -58,16 +58,32 @@ CLI mostra o codigo:
|
|
|
58
58
|
Retome esta conversa: kafox --resume 3f1c9a0b
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
`kafox history` lista
|
|
61
|
+
`kafox history` lista da mais nova para a mais antiga, cada uma com o comando
|
|
62
|
+
que retoma (e `kafox history 20` mostra so as 20 ultimas):
|
|
62
63
|
|
|
63
64
|
```
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
hello world em python, tem como fazer um sis... - 28/10
|
|
66
|
+
kafox resume 3f1c9a0b
|
|
67
|
+
|
|
68
|
+
nessa cli que voce ve, tem como fazer alguma co... - 27/10
|
|
69
|
+
kafox resume 8ad2f19c
|
|
66
70
|
```
|
|
67
71
|
|
|
68
72
|
`kafox resume` (sem codigo) abre o seletor com os titulos das conversas — setas
|
|
69
|
-
ou
|
|
70
|
-
|
|
73
|
+
ou a roda do mouse navegam, clique ou Enter retomam a conversa escolhida.
|
|
74
|
+
`kafox resume 3f1c` aceita o codigo inteiro ou so o comeco dele, e
|
|
75
|
+
`kafox continue` (ou `-c`) vai direto para a ultima.
|
|
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`.
|
|
71
87
|
|
|
72
88
|
## Agentes e subagentes
|
|
73
89
|
|
|
@@ -176,7 +192,9 @@ motivo do erro quando falha) e `/mcp` lista tudo; `/mcp reload` reconecta.
|
|
|
176
192
|
- `~/.kafox/mcp.json` guarda os servidores MCP.
|
|
177
193
|
|
|
178
194
|
A CLI so responde se a conta tiver saldo; sem credito ela avisa e nao chama o
|
|
179
|
-
gateway.
|
|
195
|
+
gateway. Nao existe limite de mensagens por chamada nem de passos do agente: a
|
|
196
|
+
conversa continua ate o modelo terminar (ou ate voce apertar Esc). O unico freio
|
|
197
|
+
e o saldo.
|
|
180
198
|
|
|
181
199
|
## Escopo
|
|
182
200
|
|
package/bin/kafox.js
CHANGED
|
@@ -25,19 +25,35 @@ program
|
|
|
25
25
|
.option("-c, --continue", "retoma a conversa mais recente")
|
|
26
26
|
.version(version);
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* `kafox history [n]`: lista as conversas salvas, da mais nova para a mais
|
|
30
|
+
* antiga, no formato "titulo - 28/10" seguido do comando para retomar.
|
|
31
|
+
* Sem argumento lista todas; `kafox history 20` mostra as 20 ultimas.
|
|
32
|
+
*/
|
|
33
|
+
function printHistory(limit) {
|
|
30
34
|
const sessions = require("../src/sessions");
|
|
31
35
|
const chats = sessions.list();
|
|
32
36
|
if (!chats.length) {
|
|
33
37
|
console.log("Nenhuma conversa salva ainda.");
|
|
38
|
+
console.log(chalk.dim("Comece um chat com `kafox` e saia com Ctrl+C para ele ser salvo."));
|
|
34
39
|
return;
|
|
35
40
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
|
|
42
|
+
const wanted = Number.parseInt(String(limit ?? ""), 10);
|
|
43
|
+
const shown =
|
|
44
|
+
Number.isFinite(wanted) && wanted > 0 ? chats.slice(0, wanted) : chats;
|
|
45
|
+
|
|
46
|
+
for (const chat of shown) {
|
|
47
|
+
const when = sessions.dayMonth(chat.updatedAt);
|
|
48
|
+
console.log(`${chalk.bold(chat.title)}${when ? ` - ${when}` : ""}`);
|
|
49
|
+
console.log(chalk.cyan(`kafox resume ${chat.id}`));
|
|
39
50
|
console.log("");
|
|
40
51
|
}
|
|
52
|
+
if (shown.length < chats.length) {
|
|
53
|
+
console.log(
|
|
54
|
+
chalk.dim(`${shown.length} de ${chats.length} conversa(s). Use \`kafox history\` para ver todas.`)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
program.command("login").description("Login with your Kafox account (device code SSO)").action(login);
|
|
@@ -73,10 +89,10 @@ program
|
|
|
73
89
|
program.command("theme").description("Open the theme selector").action(runTheme);
|
|
74
90
|
program.command("background").description("Set terminal background image (URL)").argument("[url]", "Image URL").action(setContentBackground);
|
|
75
91
|
program
|
|
76
|
-
.command("history")
|
|
92
|
+
.command("history [quantidade]")
|
|
77
93
|
.alias("historico")
|
|
78
|
-
.description("Lista
|
|
79
|
-
.action(printHistory);
|
|
94
|
+
.description("Lista as conversas salvas e como retomar (ex.: kafox history 20)")
|
|
95
|
+
.action((quantidade) => printHistory(quantidade));
|
|
80
96
|
|
|
81
97
|
program
|
|
82
98
|
.command("resume [codigo]")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kafox-cli",
|
|
3
|
-
"version": "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": "
|
|
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
|
@@ -40,6 +40,23 @@ function fileFor(id) {
|
|
|
40
40
|
return path.join(chatsDir(), `${id}.json`);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Codigo digitado pelo usuario: so letras/numeros (sem caminho, sem "."). */
|
|
44
|
+
function normalizeId(value) {
|
|
45
|
+
return String(value || "")
|
|
46
|
+
.trim()
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.replace(/[^a-z0-9]/g, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Data curta do chat: "2026-10-28T..." -> "28/10". */
|
|
52
|
+
function dayMonth(value) {
|
|
53
|
+
const parsed = new Date(value || "");
|
|
54
|
+
if (Number.isNaN(parsed.getTime())) return "";
|
|
55
|
+
const day = String(parsed.getDate()).padStart(2, "0");
|
|
56
|
+
const month = String(parsed.getMonth() + 1).padStart(2, "0");
|
|
57
|
+
return `${day}/${month}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
/** Titulo curto: primeira mensagem do usuario, em uma linha. */
|
|
44
61
|
function titleFrom(messages, fallback = "") {
|
|
45
62
|
const first = (messages || []).find(
|
|
@@ -52,7 +69,7 @@ function titleFrom(messages, fallback = "") {
|
|
|
52
69
|
return text.length > TITLE_MAX ? `${text.slice(0, TITLE_MAX - 1).trimEnd()}…` : text;
|
|
53
70
|
}
|
|
54
71
|
|
|
55
|
-
function save({ id, cwd, model, theme, messages, context, startedAt, title }) {
|
|
72
|
+
function save({ id, cwd, model, theme, allowAll, messages, context, startedAt, title }) {
|
|
56
73
|
if (!id) return null;
|
|
57
74
|
ensureDir();
|
|
58
75
|
const now = new Date().toISOString();
|
|
@@ -63,6 +80,8 @@ function save({ id, cwd, model, theme, messages, context, startedAt, title }) {
|
|
|
63
80
|
cwd: cwd || process.cwd(),
|
|
64
81
|
model: model || null,
|
|
65
82
|
theme: theme || null,
|
|
83
|
+
// "rodar tudo" e uma escolha da conversa: ao retomar, volta como estava.
|
|
84
|
+
allowAll: Boolean(allowAll),
|
|
66
85
|
startedAt: startedAt || now,
|
|
67
86
|
updatedAt: now,
|
|
68
87
|
messages: list,
|
|
@@ -105,6 +124,7 @@ function list() {
|
|
|
105
124
|
title: data.title || titleFrom(data.messages),
|
|
106
125
|
cwd: data.cwd,
|
|
107
126
|
model: data.model,
|
|
127
|
+
allowAll: Boolean(data.allowAll),
|
|
108
128
|
updatedAt: data.updatedAt,
|
|
109
129
|
messages: (data.messages || []).length,
|
|
110
130
|
});
|
|
@@ -116,6 +136,20 @@ function latest() {
|
|
|
116
136
|
return list()[0] || null;
|
|
117
137
|
}
|
|
118
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Acha a conversa por id exato ou por prefixo unico: `kafox resume 3f1c`
|
|
141
|
+
* tambem funciona quando o codigo foi copiado pela metade.
|
|
142
|
+
*/
|
|
143
|
+
function find(value) {
|
|
144
|
+
const needle = normalizeId(value);
|
|
145
|
+
if (!needle) return null;
|
|
146
|
+
const exact = load(needle);
|
|
147
|
+
if (exact) return exact;
|
|
148
|
+
const matches = list().filter((chat) => chat.id.startsWith(needle));
|
|
149
|
+
if (matches.length !== 1) return null;
|
|
150
|
+
return load(matches[0].id);
|
|
151
|
+
}
|
|
152
|
+
|
|
119
153
|
function prune() {
|
|
120
154
|
for (const chat of list().slice(MAX_SESSIONS)) {
|
|
121
155
|
try {
|
|
@@ -130,9 +164,12 @@ module.exports = {
|
|
|
130
164
|
chatsDir,
|
|
131
165
|
newId,
|
|
132
166
|
titleFrom,
|
|
167
|
+
normalizeId,
|
|
168
|
+
dayMonth,
|
|
133
169
|
save,
|
|
134
170
|
load,
|
|
135
171
|
list,
|
|
136
172
|
latest,
|
|
173
|
+
find,
|
|
137
174
|
prune,
|
|
138
175
|
};
|
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,
|
|
@@ -1168,11 +1170,13 @@ function buildOverlay(state, cols, rows) {
|
|
|
1168
1170
|
if (themePicker) {
|
|
1169
1171
|
line = padRight(` ${caret} ${padRight(item.id, 15)} ` + themeSwatch(item.id), width - 4);
|
|
1170
1172
|
} else if (chatPicker) {
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
+
// Formato do seletor: "titulo do chat - 28/10", com o codigo do
|
|
1174
|
+
// resume discreto na direita para quem quiser copiar.
|
|
1175
|
+
const right = chalk.dim(item.id);
|
|
1173
1176
|
const space = width - 6;
|
|
1174
|
-
const room = Math.max(12, space - visible(right) -
|
|
1175
|
-
const
|
|
1177
|
+
const room = Math.max(12, space - visible(right) - 12);
|
|
1178
|
+
const when = sessions.dayMonth(item.updatedAt);
|
|
1179
|
+
const left = ` ${caret} ${truncate(item.title || item.id, room)}${when ? ` - ${when}` : ""}`;
|
|
1176
1180
|
const gap = Math.max(1, space - visible(left) - visible(right));
|
|
1177
1181
|
line = padRight(left + " ".repeat(gap) + right, width - 4);
|
|
1178
1182
|
} else {
|
|
@@ -1333,6 +1337,9 @@ async function runTUI({
|
|
|
1333
1337
|
|
|
1334
1338
|
// resume: "pick" abre o seletor de conversas, "last" pega a mais recente e
|
|
1335
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";
|
|
1336
1343
|
let resumeMissing = null;
|
|
1337
1344
|
let resumePick = false;
|
|
1338
1345
|
if (resume) {
|
|
@@ -1340,7 +1347,7 @@ async function runTUI({
|
|
|
1340
1347
|
resumePick = true;
|
|
1341
1348
|
} else {
|
|
1342
1349
|
const id = resume === "last" || resume === true ? sessions.latest()?.id : String(resume);
|
|
1343
|
-
const saved = sessions.load(id);
|
|
1350
|
+
const saved = resume === "last" || resume === true ? sessions.load(id) : sessions.find(id);
|
|
1344
1351
|
if (saved) {
|
|
1345
1352
|
state.sessionId = saved.id;
|
|
1346
1353
|
state.sessionStartedAt = saved.startedAt;
|
|
@@ -1348,6 +1355,7 @@ async function runTUI({
|
|
|
1348
1355
|
state.context = Array.isArray(saved.context) ? saved.context : [];
|
|
1349
1356
|
if (saved.model) state.model = saved.model;
|
|
1350
1357
|
if (saved.theme) state.theme = saved.theme;
|
|
1358
|
+
state.autoApprove = forcedAllow || Boolean(saved.allowAll);
|
|
1351
1359
|
state.tokens = estimateTokens(state);
|
|
1352
1360
|
} else {
|
|
1353
1361
|
resumeMissing = String(id || resume);
|
|
@@ -1358,8 +1366,8 @@ async function runTUI({
|
|
|
1358
1366
|
state.sessionId = sessions.newId();
|
|
1359
1367
|
state.sessionStartedAt = new Date().toISOString();
|
|
1360
1368
|
}
|
|
1361
|
-
//
|
|
1362
|
-
|
|
1369
|
+
// Sem conversa retomada, so a flag/env libera a aprovacao automatica.
|
|
1370
|
+
if (forcedAllow) state.autoApprove = true;
|
|
1363
1371
|
let mcp = new McpHub({ cwd: process.cwd() });
|
|
1364
1372
|
let spinner = null;
|
|
1365
1373
|
let escTimer = null;
|
|
@@ -1442,6 +1450,7 @@ async function runTUI({
|
|
|
1442
1450
|
cwd: process.cwd(),
|
|
1443
1451
|
model: state.model,
|
|
1444
1452
|
theme: state.theme,
|
|
1453
|
+
allowAll: state.autoApprove,
|
|
1445
1454
|
messages: state.messages,
|
|
1446
1455
|
context: state.context,
|
|
1447
1456
|
startedAt: state.sessionStartedAt,
|
|
@@ -1456,9 +1465,51 @@ async function runTUI({
|
|
|
1456
1465
|
const cols = Math.max(30, stdout.columns || 80);
|
|
1457
1466
|
const rows = Math.max(14, stdout.rows || 24);
|
|
1458
1467
|
const frame = buildFrame(state, cols, rows, commands);
|
|
1468
|
+
const grid = wallpaperGrid(cols, rows);
|
|
1469
|
+
if (grid) frame.lines = wallpaper.paint(frame.lines, grid);
|
|
1459
1470
|
paintFrame(frame, { cols, rows });
|
|
1460
1471
|
}
|
|
1461
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
|
+
|
|
1462
1513
|
/**
|
|
1463
1514
|
* Desenho incremental: manda so as linhas diferentes, dentro de uma unica
|
|
1464
1515
|
* atualizacao sincronizada. E o que impede o piscar do modal e do streaming.
|
|
@@ -1548,7 +1599,7 @@ async function runTUI({
|
|
|
1548
1599
|
|
|
1549
1600
|
/** Troca a sessao atual pela conversa escolhida no seletor. */
|
|
1550
1601
|
function resumeChat(id) {
|
|
1551
|
-
const saved = sessions.
|
|
1602
|
+
const saved = sessions.find(id);
|
|
1552
1603
|
state.picker = null;
|
|
1553
1604
|
if (!saved) {
|
|
1554
1605
|
state.modal = { title: "Resume", text: "Nao consegui abrir essa conversa." };
|
|
@@ -1560,6 +1611,7 @@ async function runTUI({
|
|
|
1560
1611
|
state.context = Array.isArray(saved.context) ? saved.context : [];
|
|
1561
1612
|
if (saved.model) state.model = saved.model;
|
|
1562
1613
|
if (saved.theme) state.theme = saved.theme;
|
|
1614
|
+
state.autoApprove = forcedAllow || Boolean(saved.allowAll);
|
|
1563
1615
|
state.files = [];
|
|
1564
1616
|
state.tokens = estimateTokens(state);
|
|
1565
1617
|
state.scroll = 0;
|
|
@@ -1645,9 +1697,25 @@ async function runTUI({
|
|
|
1645
1697
|
// /models e /model sem argumento abrem o mesmo seletor navegavel.
|
|
1646
1698
|
if ((name === "/model" || name === "/models") && !parts[1]) return openModelPicker();
|
|
1647
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
|
+
}
|
|
1648
1715
|
if (name === "/allow") {
|
|
1649
1716
|
const argument = (parts[1] || "on").toLowerCase();
|
|
1650
1717
|
state.autoApprove = argument !== "off" && argument !== "ask";
|
|
1718
|
+
persist();
|
|
1651
1719
|
state.modal = {
|
|
1652
1720
|
title: "Aprovacao",
|
|
1653
1721
|
text: state.autoApprove
|
|
@@ -1956,6 +2024,7 @@ async function runTUI({
|
|
|
1956
2024
|
if (!pending) return;
|
|
1957
2025
|
state.approval = null;
|
|
1958
2026
|
if (decision === "always") state.autoApprove = true;
|
|
2027
|
+
if (decision === "always") persist();
|
|
1959
2028
|
render();
|
|
1960
2029
|
pending.resolve(decision !== "deny");
|
|
1961
2030
|
}
|
|
@@ -2275,6 +2344,8 @@ async function runTUI({
|
|
|
2275
2344
|
render();
|
|
2276
2345
|
if (resumePick) openChatPicker();
|
|
2277
2346
|
connectMcp().catch(() => {});
|
|
2347
|
+
const savedBackground = storage.getConfig().background;
|
|
2348
|
+
if (savedBackground) setBackground(savedBackground).catch(() => {});
|
|
2278
2349
|
|
|
2279
2350
|
spinner = setInterval(() => {
|
|
2280
2351
|
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
|
+
};
|