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 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.3.0.tgz
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 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
@@ -58,16 +58,32 @@ CLI mostra o codigo:
58
58
  Retome esta conversa: kafox --resume 3f1c9a0b
59
59
  ```
60
60
 
61
- `kafox history` lista tudo:
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
- - hello world em python solicitado, usa arquivo e roda teste
65
- kafox resume 3f1c9a0b
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 mouse escolhem, Enter retoma. `kafox continue` (ou `-c`) vai direto para a
70
- ultima.
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
- /** `kafox history`: titulo de cada conversa e como retomar. */
29
- function printHistory() {
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
- for (const chat of chats) {
37
- console.log(`- ${chalk.bold(chat.title)}`);
38
- console.log(` kafox resume ${chat.id}`);
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 todas as conversas salvas")
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.7.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
@@ -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: "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,
@@ -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
- const when = String(item.updatedAt || "").slice(0, 10);
1172
- const right = chalk.dim(`${when} ${item.id}`);
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) - 2);
1175
- const left = ` ${caret} ${truncate(item.title || item.id, room)}`;
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
- // `kafox --allow-all` (ou KAFOX_ALLOW_ALL=1) ja comeca sem pedir aprovacao.
1362
- 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;
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.load(id);
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;
@@ -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
+ };