kafox-cli 0.9.0 → 0.9.5

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.8.0.tgz
15
+ npm install -g https://kafox.dev/static/downloads/kafox-cli-0.9.5.tgz
16
16
  ```
17
17
 
18
18
  Requisito: Node.js 22.5+.
@@ -24,7 +24,7 @@ kafox login
24
24
  ```
25
25
 
26
26
  O terminal vira uma tela de login (arte grande em cima, link no centro): abra a
27
- URL `https://kafox.pypag.com/cli?code=...`, entre na sua conta e clique em
27
+ URL `https://kafox.dev/cli?code=...`, entre na sua conta e clique em
28
28
  **Autorizar**. A sessao fica em `~/.kafox/token.json` (permissao 600). No primeiro
29
29
  `kafox` sem sessao ele pergunta se quer entrar e abre a mesma tela.
30
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.5",
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"
package/src/auth.js CHANGED
@@ -35,7 +35,8 @@ async function login() {
35
35
  return;
36
36
  }
37
37
 
38
- const url = `${storage.getApiUrl()}/cli?code=${data.user_code}`;
38
+ // A pagina de autorizacao fica no site, nao no host da API.
39
+ const url = `${storage.getWebUrl()}/cli?code=${data.user_code}`;
39
40
 
40
41
  if (process.stdout.isTTY && process.stdin.isTTY) {
41
42
  return loginWithScreen({ url, data });
package/src/gateway.js CHANGED
@@ -2,6 +2,14 @@ const chalk = require("chalk");
2
2
  const storage = require("./storage");
3
3
  const { ensureSession, fetchApi } = require("./auth");
4
4
 
5
+ /**
6
+ * Modelo do free tier: o nome carrega o sufixo " (free)" (ou ":free"). Esses
7
+ * modelos gastam a cota diaria, nao o saldo, entao funcionam com saldo zero.
8
+ */
9
+ function isFreeModel(model) {
10
+ return /(^free$|\s\(free\)$|:free$)/i.test(String(model || "").trim());
11
+ }
12
+
5
13
  /**
6
14
  * Garante sessao, saldo, modelo e chave antes de qualquer chamada.
7
15
  * A chave `kafox_live_...` e da propria conta: o gateway cobra por ela.
@@ -18,13 +26,6 @@ async function ensureReady(options = {}) {
18
26
  }
19
27
  const account = await boot.json().catch(() => null);
20
28
  const balance = Number(account?.user?.balance_micros || 0);
21
- if (balance <= 0) {
22
- return {
23
- ok: false,
24
- code: "no_balance",
25
- error: "Sem saldo para responder. Recarregue em https://kafox.pypag.com/dashboard",
26
- };
27
- }
28
29
 
29
30
  let model = options.model || storage.getConfig().model;
30
31
  if (!model) {
@@ -35,6 +36,17 @@ async function ensureReady(options = {}) {
35
36
  if (!model) return { ok: false, error: "Could not load model catalog." };
36
37
  }
37
38
 
39
+ // Free tier nao desconta saldo: com a conta zerada o modelo (free) responde.
40
+ if (balance <= 0 && !isFreeModel(model)) {
41
+ return {
42
+ ok: false,
43
+ code: "no_balance",
44
+ error:
45
+ `Sem saldo para responder. Recarregue em ${storage.getWebUrl()}/dashboard ` +
46
+ "ou use um modelo (free): `kafox model`.",
47
+ };
48
+ }
49
+
38
50
  let keyCreated = false;
39
51
  let apiKey = storage.getConfig().api_key;
40
52
  if (!apiKey) {
@@ -235,7 +247,6 @@ async function attemptChat({ messages, model, tools, onDelta, onReasoning, signa
235
247
  ms: Date.now() - started,
236
248
  model: ready.model,
237
249
  keyCreated: ready.keyCreated,
238
- provider: response.headers?.get?.("x-kafox-provider") || "",
239
250
  };
240
251
  }
241
252
 
@@ -307,6 +318,7 @@ module.exports = {
307
318
  askModel,
308
319
  chat,
309
320
  ensureReady,
321
+ isFreeModel,
310
322
  mergeToolCalls,
311
323
  backoffMs,
312
324
  RETRY_MAX,
package/src/storage.js CHANGED
@@ -28,7 +28,12 @@ function writeJSON(file, data) {
28
28
 
29
29
  function getApiUrl() {
30
30
  const cfg = readJSON(CONFIG_FILE, {});
31
- return cfg.api_url || process.env.KAFOX_API_URL || "https://kafox.pypag.com";
31
+ return cfg.api_url || process.env.KAFOX_API_URL || "https://backend.kafox.dev";
32
+ }
33
+
34
+ /** Endereco do site (painel, login, documentacao) — a API mora em outro host. */
35
+ function getWebUrl() {
36
+ return process.env.KAFOX_WEB_URL || "https://kafox.dev";
32
37
  }
33
38
 
34
39
  function getToken() {
@@ -87,6 +92,6 @@ function parseCookies(headers) {
87
92
  module.exports = {
88
93
  DIR, TOKEN_FILE, CONFIG_FILE,
89
94
  ensureDir, readJSON, writeJSON,
90
- getApiUrl, getToken, setToken, clearToken,
95
+ getApiUrl, getWebUrl, getToken, setToken, clearToken,
91
96
  getConfig, setConfig, getAuthHeaders, parseCookies,
92
97
  };
package/src/tui.js CHANGED
@@ -817,10 +817,10 @@ function messageLines(state, width) {
817
817
  } else if (message.role === "assistant") {
818
818
  lines.push("");
819
819
  lines.push(...markdownLines(message.text, width - 2, state.theme));
820
+ // Só latência: a rota usada por trás é da Kafox, não do cliente.
820
821
  const meta = [];
821
822
  if (message.interrupted) meta.push("interrompido");
822
823
  if (message.ms) meta.push(`${(message.ms / 1000).toFixed(1)}s`);
823
- if (message.provider) meta.push(message.provider);
824
824
  if (meta.length) lines.push("");
825
825
  if (meta.length) lines.push(indent(2) + chalk.dim(meta.join(" · ")));
826
826
  } else if (message.role === "thinking") {
@@ -975,9 +975,6 @@ function modelLine(state, width) {
975
975
  let label = `${model} · kafox v${state.version}`;
976
976
  if (state.escArmed) {
977
977
  label = `${model} · Esc de novo para parar`;
978
- } else if (state.retry) {
979
- const left = Math.max(1, Math.ceil(((state.retry.endsAt || 0) - Date.now()) / 1000));
980
- label = `${model} · reconectando em ${left}s (tentativa ${state.retry.attempt}/${state.retry.maxAttempts})`;
981
978
  }
982
979
  return paintPanel(
983
980
  state.theme,
@@ -994,14 +991,8 @@ function inputBlock(state, width, { hero = false, tight = false } = {}) {
994
991
  const shown = state.input.slice(start, start + available);
995
992
 
996
993
  const prompt = chalk.cyan("▌ ");
997
- let body;
998
- if (state.input) {
999
- body = prompt + shown;
1000
- } else {
1001
- // Enquanto a IA trabalha os pontinhos correm; parada, a caixa fica limpa.
1002
- const placeholder = state.busy ? `trabalhando${".".repeat(state.frame % 4)}` : "";
1003
- body = prompt + chalk.dim(truncate(placeholder, available));
1004
- }
994
+ // A caixa fica sempre limpa: o "trabalhando" ja aparece na conversa, acima.
995
+ const body = state.input ? prompt + shown : prompt;
1005
996
 
1006
997
  const lines = [
1007
998
  rule(state, width),
@@ -1029,16 +1020,27 @@ function statusLines(state, width, { bare = false } = {}) {
1029
1020
  return [left + gap + right];
1030
1021
  }
1031
1022
 
1023
+ /**
1024
+ * Linha de status do rodape. A lista fixa de atalhos saiu: agora o espaco e
1025
+ * usado para avisar problema de conexao (em vermelho) e posicao da rolagem.
1026
+ */
1032
1027
  function hintsLines(state, width, { bare = false } = {}) {
1033
1028
  if (bare) return [];
1034
- const parts = [];
1035
- if (state.scroll > 0) parts.push(`↑ ${state.scroll} linhas`);
1036
- if (state.busy) parts.push("Enter enfileira · Esc 2× para parar");
1037
- parts.push("PgUp/PgDn rola");
1038
- parts.push("Ctrl+Y copia");
1039
- parts.push(`F2 ${state.mouse ? "seleção" : "mouse"}`);
1040
- parts.push("/ comandos");
1041
- return [chalk.dim(truncate(parts.join(" · "), width))];
1029
+
1030
+ if (state.retry) {
1031
+ const left = Math.max(1, Math.ceil(((state.retry.endsAt || 0) - Date.now()) / 1000));
1032
+ const attempt = state.retry.maxAttempts
1033
+ ? ` (tentativa ${state.retry.attempt}/${state.retry.maxAttempts})`
1034
+ : "";
1035
+ const label = truncate(`⚠ ${state.retry.error || "fetch failed"} · reconectando em ${left}s${attempt}`, width);
1036
+ return [chalk.red(label)];
1037
+ }
1038
+
1039
+ if (state.scroll > 0) {
1040
+ return [chalk.dim(truncate(`↑ ${state.scroll} linhas`, width))];
1041
+ }
1042
+
1043
+ return [];
1042
1044
  }
1043
1045
 
1044
1046
  /* ------------------------------------------------------------------ overlay */
@@ -1466,7 +1468,12 @@ async function runTUI({
1466
1468
  const rows = Math.max(14, stdout.rows || 24);
1467
1469
  const frame = buildFrame(state, cols, rows, commands);
1468
1470
  const grid = wallpaperGrid(cols, rows);
1469
- if (grid) frame.lines = wallpaper.paint(frame.lines, grid);
1471
+ if (grid) {
1472
+ // A tela inteira vem pintada com o bg do tema; so essa cor e tratada como
1473
+ // area livre para a foto. Paineis e caixas (outra cor) continuam solidos.
1474
+ const base = wallpaper.hexToRgb(themeColors(state.theme).bg);
1475
+ frame.lines = wallpaper.paint(frame.lines, grid, { base });
1476
+ }
1470
1477
  paintFrame(frame, { cols, rows });
1471
1478
  }
1472
1479
 
@@ -1477,17 +1484,18 @@ async function runTUI({
1477
1484
  function wallpaperGrid(cols, rows) {
1478
1485
  const bg = state.wallpaper;
1479
1486
  if (!bg?.image) return null;
1480
- if (!bg.grid || bg.cols !== cols || bg.rows !== rows) {
1487
+ if (!bg.grid || bg.cols !== cols || bg.rows !== rows || bg.theme !== state.theme) {
1481
1488
  const base = wallpaper.hexToRgb(themeColors(state.theme).bg) || [12, 12, 16];
1482
1489
  bg.grid = wallpaper.buildGrid(bg.image, cols, rows, { dim: 0.45, base });
1483
1490
  bg.cols = cols;
1484
1491
  bg.rows = rows;
1492
+ bg.theme = state.theme;
1485
1493
  }
1486
1494
  return bg.grid;
1487
1495
  }
1488
1496
 
1489
1497
  /** `/background <arquivo|url>` desenha a foto atras da conversa. */
1490
- async function setBackground(source) {
1498
+ async function setBackground(source, { quiet = false } = {}) {
1491
1499
  const value = String(source || "").trim();
1492
1500
  if (!value || ["off", "none", "0", "limpar"].includes(value.toLowerCase())) {
1493
1501
  state.wallpaper = null;
@@ -1505,7 +1513,11 @@ async function runTUI({
1505
1513
  storage.setConfig({ background: value });
1506
1514
  } catch (error) {
1507
1515
  state.wallpaper = null;
1508
- state.modal = { title: "Fundo", text: `Nao consegui carregar ${value}.\n${error.message}` };
1516
+ // No inicio da sessao o fundo salvo pode falhar (offline, arquivo movido):
1517
+ // nao vale abrir um modal por isso, so avisa quando o usuario pediu.
1518
+ if (!quiet) {
1519
+ state.modal = { title: "Fundo", text: `Nao consegui carregar ${value}.\n${error.message}` };
1520
+ }
1509
1521
  }
1510
1522
  return render();
1511
1523
  }
@@ -1943,7 +1955,6 @@ async function runTUI({
1943
1955
  role: "assistant",
1944
1956
  text,
1945
1957
  ms: result.ms,
1946
- provider: result.provider || "",
1947
1958
  });
1948
1959
  }
1949
1960
  if (result.messages) state.context = result.messages;
@@ -2345,7 +2356,7 @@ async function runTUI({
2345
2356
  if (resumePick) openChatPicker();
2346
2357
  connectMcp().catch(() => {});
2347
2358
  const savedBackground = storage.getConfig().background;
2348
- if (savedBackground) setBackground(savedBackground).catch(() => {});
2359
+ if (savedBackground) setBackground(savedBackground, { quiet: true }).catch(() => {});
2349
2360
 
2350
2361
  spinner = setInterval(() => {
2351
2362
  if (!state.busy || closed) return;
package/src/wallpaper.js CHANGED
@@ -312,11 +312,39 @@ function trackStyle(sequence, style) {
312
312
  }
313
313
  }
314
314
 
315
- function backgroundActive(style) {
316
- return style.some((part) => {
315
+ /**
316
+ * Cor de fundo ativa no momento. O tema pinta a tela inteira com o proprio bg,
317
+ * entao essa cor conta como "fundo livre": a imagem pode entrar no lugar dela.
318
+ * Caixas e paineis usam outra cor e ficam intactos.
319
+ */
320
+ function activeBackground(style) {
321
+ let bg = null;
322
+ for (let index = 0; index < style.length; index += 1) {
323
+ const part = style[index];
324
+ if (part === "48" && style[index + 1] === "2") {
325
+ bg = [Number(style[index + 2]), Number(style[index + 3]), Number(style[index + 4])];
326
+ index += 4;
327
+ continue;
328
+ }
329
+ if (part === "48") {
330
+ // 48;5;n (paleta) ou 48;... qualquer outra variante: nao e o bg do tema.
331
+ bg = "outro";
332
+ index += 1;
333
+ continue;
334
+ }
317
335
  const code = Number(part);
318
- return (code >= 40 && code <= 47) || (code >= 100 && code <= 107) || code === 48;
319
- });
336
+ if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) bg = "outro";
337
+ }
338
+ return bg;
339
+ }
340
+
341
+ /** Aceita o espaco quando nao ha fundo ou quando o fundo e o bg do tema. */
342
+ function backgroundFree(style, base) {
343
+ const bg = activeBackground(style);
344
+ if (!bg) return true;
345
+ if (bg === "outro") return false;
346
+ if (!base) return false;
347
+ return bg[0] === base[0] && bg[1] === base[1] && bg[2] === base[2];
320
348
  }
321
349
 
322
350
  function cellEscape({ top, bottom }) {
@@ -327,7 +355,7 @@ function cellEscape({ top, bottom }) {
327
355
  * Troca os espacos vazios do frame pelas celulas da imagem. Texto e celulas com
328
356
  * fundo proprio (caixas, paineis) ficam como estao.
329
357
  */
330
- function paint(lines, grid) {
358
+ function paint(lines, grid, { base = null } = {}) {
331
359
  if (!grid?.cells?.length) return lines;
332
360
 
333
361
  return lines.map((line, row) => {
@@ -353,7 +381,7 @@ function paint(lines, grid) {
353
381
 
354
382
  const char = line[index];
355
383
  const cell = cells[col];
356
- if (char === " " && cell && !backgroundActive(style)) {
384
+ if (char === " " && cell && backgroundFree(style, base)) {
357
385
  const restore = style.length ? `\x1b[${style.join(";")}m` : "";
358
386
  out += `\x1b[0m${cellEscape(cell)}${HALF_BLOCK}${restore}`;
359
387
  } else {
@@ -373,6 +401,7 @@ module.exports = {
373
401
  decodePng,
374
402
  buildGrid,
375
403
  paint,
404
+ activeBackground,
376
405
  coverRegion,
377
406
  hexToRgb,
378
407
  HALF_BLOCK,