kafox-cli 0.10.2 → 0.10.4

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/src/tui.js CHANGED
@@ -25,8 +25,11 @@ const { diffBlocks } = require("./diff");
25
25
  const { toolMeta, parseCommandOutput } = require("./tools");
26
26
  const { McpHub } = require("./mcp");
27
27
  const sessions = require("./sessions");
28
+ const promptHistory = require("./prompt-history");
28
29
  const wallpaper = require("./wallpaper");
29
30
  const { listProviders } = require("./providers");
31
+ const { MAX_IMAGES, loadImage, buildAttachment, humanSize } = require("./images");
32
+ const clipboard = require("./clipboard");
30
33
 
31
34
  const ANSI = /\x1b\[[0-9;]*m/g;
32
35
  const stripAnsi = (value) => String(value).replace(ANSI, "");
@@ -499,6 +502,8 @@ const CONTROL_KEYS = {
499
502
  12: { name: "l", ctrl: true },
500
503
  13: { name: "return" },
501
504
  21: { name: "u", ctrl: true },
505
+ // Ctrl+V: cola o que esta na area de transferencia (imagem vira anexo).
506
+ 22: { name: "v", ctrl: true },
502
507
  // Ctrl+R: atualiza saldo da conta e dos provedores externos agora.
503
508
  18: { name: "r", ctrl: true },
504
509
  // Ctrl+Q: desiste das mensagens que estao na fila (nao cancela a atual).
@@ -681,6 +686,10 @@ function initialState() {
681
686
  return {
682
687
  input: "",
683
688
  cursor: 0,
689
+ // Anexos de imagem do proximo turno (maximo MAX_IMAGES). Sao consumidos
690
+ // por submit() e nao sobrevivem a ele.
691
+ images: [],
692
+ imageError: null,
684
693
  history: [],
685
694
  historyIndex: -1,
686
695
  messages: [],
@@ -1332,22 +1341,6 @@ function sidebarLines(state, width) {
1332
1341
  lines.push(chalk.dim(`${formatTokens(tokens)} tokens · ${messages} msg${messages === 1 ? "" : "s"}`));
1333
1342
  lines.push(chalk.dim(state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`));
1334
1343
 
1335
- // Saldo dos provedores externos (unlid etc.): so aparece quando o backend
1336
- // consegue ler. Moeda vem do provedor; "sem consulta" quando falha.
1337
- if (state.balanceLoading) lines.push(chalk.dim("atualizando…"));
1338
- for (const item of state.providerBalances || []) {
1339
- const label = truncate(item.label || item.id || "provider", Math.max(6, width - 12));
1340
- if (item.ok && item.balance != null) {
1341
- const money =
1342
- item.currency === "BRL"
1343
- ? `R$ ${Number(item.balance).toFixed(2)}`
1344
- : `${Number(item.balance).toFixed(2)} ${item.currency || "USD"}`;
1345
- lines.push(chalk.dim(`${label} `) + chalk.cyan(money));
1346
- } else {
1347
- lines.push(chalk.dim(`${label} `) + chalk.dim("--"));
1348
- }
1349
- }
1350
-
1351
1344
  lines.push("");
1352
1345
  lines.push(border(state, "MCP"));
1353
1346
  if (state.mcp.loading) {
@@ -1436,30 +1429,89 @@ function modelLine(state, width) {
1436
1429
  );
1437
1430
  }
1438
1431
 
1432
+ /**
1433
+ * Preview dos anexos dentro da caixa de input: a imagem pixelada (blocos com
1434
+ * cor real) e, a direita de cada uma, o rotulo clicavel "✕" que a remove.
1435
+ * O preview e montado na ordem inversa de proposito — a TUI desenha a caixa
1436
+ * de baixo para cima ao posicionar o cursor, entao o texto fica por ultimo.
1437
+ */
1438
+ function imagePreviewLines(state, width) {
1439
+ const items = state.images || [];
1440
+ state.imageHit = {};
1441
+ if (!items.length) return [];
1442
+
1443
+ const inner = Math.max(10, width - 2);
1444
+ const lines = [];
1445
+
1446
+ items.forEach((image, index) => {
1447
+ const preview = image.preview || [];
1448
+ const label = `${index + 1}. ${truncate(image.name, Math.max(8, inner - 22))} ${chalk.dim(
1449
+ `${image.width}×${image.height} · ${humanSize(image.bytes)}`
1450
+ )}`;
1451
+ const remove = chalk.red.bold("✕");
1452
+ // A linha do rotulo e a ultima coluna util (onde cai o "✕"), para o
1453
+ // clique do mouse acertar o alvo. A TUI pinta 1 espaco de borda a
1454
+ // esquerda, entao a coluna do ✕ (1-based) e a posicao dentro de `inner`.
1455
+ const labelLine = padRight(" " + label, inner - 2) + " " + remove + " ";
1456
+ // 1-based dentro da coluna total: 1 (borda "│") + 1 (espaco) + label + 1.
1457
+ const removeCol = 1 + 1 + visible(label) + 1;
1458
+
1459
+ lines.push(paintPanel(state.theme, padRight(labelLine, inner)));
1460
+ for (const line of preview) {
1461
+ lines.push(paintPanel(state.theme, padRight(" " + line, inner)));
1462
+ }
1463
+
1464
+ state.imageHit[index] = { row: lines.length - 1 - preview.length, col: removeCol };
1465
+ });
1466
+
1467
+ return lines;
1468
+ }
1469
+
1470
+ /**
1471
+ * Caixa de entrada: borda superior, previews de imagem anexadas (com o "✕"
1472
+ * de remover), a linha de digitacao com cursor e a borda inferior. Devolve as
1473
+ * linhas prontas, a linha/coluna do cursor dentro do bloco e a linha do
1474
+ * rodape (avisos, ex.: modelo sem visao).
1475
+ */
1439
1476
  function inputBlock(state, width, { hero = false, tight = false } = {}) {
1440
- const available = width - 4;
1441
- const start =
1442
- state.input.length > available
1443
- ? clamp(state.cursor - (available - 2), 0, Math.max(0, state.input.length - available))
1444
- : 0;
1445
- const shown = state.input.slice(start, start + available);
1446
-
1447
- const prompt = chalk.cyan("▌ ");
1448
- // A caixa fica sempre limpa: o "trabalhando" ja aparece na conversa, acima.
1449
- const body = state.input ? prompt + shown : prompt;
1450
-
1451
- const lines = [
1452
- rule(state, width),
1453
- paintPanel(state.theme, padRight(body, width)),
1454
- rule(state, width),
1455
- ];
1477
+ const inner = Math.max(10, width - 2);
1478
+ const edge = (text) => border(state, text);
1479
+ const row = (content) => edge("│") + paintPanel(state.theme, padRight(content, inner)) + edge("│");
1480
+ const lines = [];
1481
+
1482
+ lines.push(edge("┌" + "─".repeat(inner) + "┐"));
1483
+
1484
+ // Na tela inicial o preview tambem aparece: colar uma imagem antes da
1485
+ // primeira pergunta precisa de retorno visual na caixa.
1486
+ const previews = hero && !(state.images || []).length ? [] : imagePreviewLines(state, inner);
1487
+ for (const preview of previews) lines.push(preview);
1488
+
1489
+ const prompt = hero ? " " : "› ";
1490
+ const room = Math.max(4, inner - visible(prompt) - 1);
1491
+ // Janela de rolagem horizontal: mantem o cursor visivel em linhas longas.
1492
+ let offset = 0;
1493
+ if (state.cursor > room) offset = state.cursor - room;
1494
+ const shown = state.input.slice(offset, offset + room);
1495
+ const beforeCursor = state.input.slice(offset, state.cursor);
1496
+ // A coluna e absoluta na linha montada pelo frame: 1 espaco de borda +
1497
+ // 1 borda "│" + 1 espaco do row + prompt + o que ja foi digitado.
1498
+ const cursorColInRow = 4 + visible(prompt) + visible(beforeCursor);
1499
+
1500
+ const typed = state.input.length ? shown : chalk.dim("Escreva algo ou /comando");
1501
+ const inputRow = lines.length;
1502
+ lines.push(row(" " + prompt + typed));
1503
+
1504
+ lines.push(edge("└" + "─".repeat(inner) + "┘"));
1505
+
1506
+ const footer = state.imageError
1507
+ ? chalk.red(truncate(`⚠ ${state.imageError}`, width))
1508
+ : null;
1456
1509
 
1457
1510
  return {
1458
1511
  lines,
1459
- cursorRow: 1,
1460
- cursorCol: 4 + (state.cursor - start),
1461
- // O modelo fica fora da caixa de texto, logo abaixo dela.
1462
- footer: tight ? null : modelLine(state, width),
1512
+ footer,
1513
+ cursorRow: inputRow,
1514
+ cursorCol: cursorColInRow,
1463
1515
  };
1464
1516
  }
1465
1517
 
@@ -1747,6 +1799,8 @@ function buildFrame(state, cols, rows, commands = {}) {
1747
1799
  ...menu,
1748
1800
  ...input.lines,
1749
1801
  ...(input.footer ? [input.footer] : []),
1802
+ // Modelo + versao logo abaixo da caixa (some so em terminal baixo).
1803
+ ...(bare ? [] : [modelLine(state, inner)]),
1750
1804
  ...status,
1751
1805
  ...hints,
1752
1806
  ];
@@ -1794,10 +1848,16 @@ function buildFrame(state, cols, rows, commands = {}) {
1794
1848
  });
1795
1849
  }
1796
1850
 
1797
- const inputRow = head.length + bodyHeight + queue.length + menu.length + input.cursorRow;
1851
+ const inputTop = head.length + bodyHeight + queue.length + menu.length;
1852
+ const inputRow = inputTop + input.cursorRow;
1798
1853
  const cursorRow = overlay ? 0 : clamp(inputRow + 1, 1, rows);
1799
1854
  const cursorCol = overlay ? 0 : clamp(input.cursorCol, 1, cols);
1800
1855
 
1856
+ // Guarda a linha (0-based) do topo do bloco de entrada para o clique do
1857
+ // mouse acertar o "✕" de cada preview (imageHit usa linhas relativas).
1858
+ state.inputTop = inputTop;
1859
+ state.inputInner = inner;
1860
+
1801
1861
  return {
1802
1862
  lines,
1803
1863
  cursorRow,
@@ -1821,6 +1881,10 @@ async function runTUI({
1821
1881
  const stdin = process.stdin;
1822
1882
  const stdout = process.stdout;
1823
1883
  const state = initialState();
1884
+ // Retoma também o histórico de linhas digitadas (↑/↓). Sem isso cada boot
1885
+ // começava com a lista vazia, mesmo com conversas salvas no disco.
1886
+ state.history = promptHistory.load();
1887
+ state.historyIndex = state.history.length;
1824
1888
 
1825
1889
  // resume: "pick" abre o seletor de conversas, "last" pega a mais recente e
1826
1890
  // qualquer outro valor e o codigo do chat que o usuario fechou no Ctrl+C.
@@ -1839,7 +1903,12 @@ async function runTUI({
1839
1903
  state.sessionId = saved.id;
1840
1904
  state.sessionStartedAt = saved.startedAt;
1841
1905
  state.messages = Array.isArray(saved.messages) ? saved.messages : [];
1842
- state.context = Array.isArray(saved.context) ? saved.context : [];
1906
+ // Sessao antiga pode nao ter `context`: reconstroi da conversa visivel
1907
+ // para a IA nao esquecer tudo ao retomar.
1908
+ state.context =
1909
+ Array.isArray(saved.context) && saved.context.length
1910
+ ? saved.context
1911
+ : sessions.contextFromMessages(state.messages);
1843
1912
  if (saved.model) state.model = saved.model;
1844
1913
  if (saved.theme) state.theme = saved.theme;
1845
1914
  state.autoApprove = forcedAllow || Boolean(saved.allowAll);
@@ -2143,7 +2212,10 @@ async function runTUI({
2143
2212
  state.sessionId = saved.id;
2144
2213
  state.sessionStartedAt = saved.startedAt;
2145
2214
  state.messages = Array.isArray(saved.messages) ? saved.messages : [];
2146
- state.context = Array.isArray(saved.context) ? saved.context : [];
2215
+ state.context =
2216
+ Array.isArray(saved.context) && saved.context.length
2217
+ ? saved.context
2218
+ : sessions.contextFromMessages(state.messages);
2147
2219
  if (saved.model) state.model = saved.model;
2148
2220
  if (saved.theme) state.theme = saved.theme;
2149
2221
  state.autoApprove = forcedAllow || Boolean(saved.allowAll);
@@ -2225,6 +2297,75 @@ async function runTUI({
2225
2297
  }
2226
2298
  }
2227
2299
 
2300
+ // Anexa imagem(ns) ao proximo turno. Aceita um ou mais caminhos separados
2301
+ // por espaco. Respeita MAX_IMAGES e grava o ultimo erro em state.imageError.
2302
+ async function attachImages(argument) {
2303
+ const files = String(argument || "")
2304
+ .split(/\s+/)
2305
+ .map((part) => part.trim())
2306
+ .filter(Boolean);
2307
+ if (!files.length) return render();
2308
+
2309
+ const items = state.images || (state.images = []);
2310
+ let lastError = null;
2311
+
2312
+ for (const file of files) {
2313
+ if (items.length >= MAX_IMAGES) {
2314
+ lastError = `limite de ${MAX_IMAGES} imagens por mensagem`;
2315
+ break;
2316
+ }
2317
+ const result = loadImage(file.replace(/^~(?=\/)/, os.homedir()));
2318
+ if (!result || !result.ok) {
2319
+ lastError = `${path.basename(file)}: ${result?.error || "falha ao carregar"}`;
2320
+ continue;
2321
+ }
2322
+ items.push(result.attachment);
2323
+ }
2324
+
2325
+ state.imageError = lastError;
2326
+ return render();
2327
+ }
2328
+
2329
+ // Cola o que esta na area de transferencia: imagem entra como anexo do
2330
+ // proximo turno (Ctrl+V ou /paste) e texto sem imagem vai para a caixa de
2331
+ // escrita, como um paste comum. E isso que faz `Ctrl+V` de um print
2332
+ // funcionar mesmo o terminal entregando so texto.
2333
+ async function pasteFromClipboard() {
2334
+ const items = state.images || (state.images = []);
2335
+ if (items.length >= MAX_IMAGES) {
2336
+ state.imageError = `limite de ${MAX_IMAGES} imagens por mensagem`;
2337
+ return render();
2338
+ }
2339
+
2340
+ const shot = clipboard.readImage();
2341
+ if (shot.ok) {
2342
+ const result = buildAttachment(shot.buffer, {
2343
+ name: shot.mime === "image/jpeg" ? "colagem.jpg" : "colagem.png",
2344
+ mime: shot.mime,
2345
+ });
2346
+ if (!result.ok) {
2347
+ state.imageError = `colagem: ${result.error}`;
2348
+ return render();
2349
+ }
2350
+ items.push(result.attachment);
2351
+ state.imageError = null;
2352
+ return render();
2353
+ }
2354
+
2355
+ const text = String(clipboard.readText() || "")
2356
+ .replace(/[\r\n]+/g, " ")
2357
+ .replace(/[\x00-\x1f\x7f]/g, "");
2358
+ if (text.trim()) {
2359
+ return setInput(
2360
+ state.input.slice(0, state.cursor) + text + state.input.slice(state.cursor),
2361
+ state.cursor + text.length
2362
+ );
2363
+ }
2364
+
2365
+ state.imageError = shot.error;
2366
+ return render();
2367
+ }
2368
+
2228
2369
  async function runCommand(line) {
2229
2370
  const parts = line.trim().split(/\s+/);
2230
2371
  const name = parts[0];
@@ -2232,6 +2373,37 @@ async function runTUI({
2232
2373
 
2233
2374
  // /models e /model sem argumento abrem o mesmo seletor navegavel.
2234
2375
  if ((name === "/model" || name === "/models") && !parts[1]) return openModelPicker();
2376
+
2377
+ // /paste (atalho Ctrl+V) cola a imagem copiada; sem imagem, cola o texto.
2378
+ // Serve tambem para terminais que consomem o Ctrl+V antes da CLI.
2379
+ if (name === "/paste" || name === "/colar" || name === "/clipboard") {
2380
+ return pasteFromClipboard();
2381
+ }
2382
+
2383
+ // /image <arquivo> anexa uma imagem ao proximo turno (ate MAX_IMAGES).
2384
+ // /image (sem argumento) lista o que esta anexado; /image clear limpa tudo.
2385
+ if (name === "/image" || name === "/images" || name === "/anexar") {
2386
+ const argument = parts.slice(1).join(" ").trim();
2387
+ if (!argument) {
2388
+ const items = state.images || [];
2389
+ state.modal = {
2390
+ title: "Imagens",
2391
+ text: items.length
2392
+ ? `${items
2393
+ .map((image, index) => `${index + 1}. ${image.name} (${image.width}×${image.height} · ${humanSize(image.bytes)})`)
2394
+ .join("\n")}\n\nUse /image clear para remover todas. Clique no ✕ do preview para tirar uma.`
2395
+ : "Nenhuma imagem anexada.\n\nUse /image <arquivo> para anexar (PNG ou JPG, ate " +
2396
+ `${MAX_IMAGES} por vez). Exemplo: /image ~/Imagens/grafico.png`,
2397
+ };
2398
+ return render();
2399
+ }
2400
+ if (/^(clear|limpar|off|none|limpa)$/i.test(argument)) {
2401
+ state.images = [];
2402
+ state.imageError = null;
2403
+ return render();
2404
+ }
2405
+ return attachImages(argument);
2406
+ }
2235
2407
  if (name === "/theme") return openThemePicker();
2236
2408
  if (name === "/background" || name === "/bg") {
2237
2409
  const value = parts.slice(1).join(" ");
@@ -2290,11 +2462,21 @@ async function runTUI({
2290
2462
  }
2291
2463
 
2292
2464
  if (name === "/clear") {
2465
+ // Limpar o contexto e comecar outra conversa, nao continuar a mesma: com
2466
+ // o codigo antigo tudo o que vinha depois do /clear era gravado no mesmo
2467
+ // arquivo, entao conversas diferentes apareciam como uma so no history.
2293
2468
  state.messages = [];
2294
2469
  state.context = [];
2295
2470
  state.streaming = null;
2471
+ state.streamedText = "";
2296
2472
  state.files = [];
2473
+ state.images = [];
2474
+ state.imageError = null;
2475
+ state.retry = null;
2476
+ state.tokens = 0;
2297
2477
  state.scroll = 0;
2478
+ state.sessionId = sessions.newId();
2479
+ state.sessionStartedAt = new Date().toISOString();
2298
2480
  return render();
2299
2481
  }
2300
2482
  if (name === "/compact") {
@@ -2425,7 +2607,7 @@ async function runTUI({
2425
2607
  function submit() {
2426
2608
  const line = state.input.trim();
2427
2609
  if (!line) return;
2428
- state.history.push(line);
2610
+ state.history = promptHistory.push(line);
2429
2611
  state.historyIndex = state.history.length;
2430
2612
  state.input = "";
2431
2613
  state.cursor = 0;
@@ -2467,7 +2649,27 @@ async function runTUI({
2467
2649
  state.abort = controller;
2468
2650
  state.escArmed = false;
2469
2651
  startMessageText(line);
2470
- state.context.push({ role: "user", content: line });
2652
+ // Imagens anexadas viram partes multimodais no content desta mensagem.
2653
+ // O anexo e consumido no turno: some da lista assim que entra no contexto.
2654
+ const attached = state.images || [];
2655
+ // Guarda o tamanho do contexto ANTES deste turno: se o provedor recusar as
2656
+ // imagens (vision_not_supported), desfazemos o push e devolvemos os anexos
2657
+ // para o usuario reaproveitar com outro modelo, sem reenviar tudo a mao.
2658
+ const contextBefore = state.context.length;
2659
+ if (attached.length) {
2660
+ state.context.push({
2661
+ role: "user",
2662
+ content: [
2663
+ { type: "text", text: line },
2664
+ ...attached.map((image) => ({ type: "image_url", image_url: { url: image.dataUrl } })),
2665
+ ],
2666
+ });
2667
+ startMessageText(`📎 ${attached.length} imagem(ns) anexada(s)`);
2668
+ state.images = [];
2669
+ state.imageError = null;
2670
+ } else {
2671
+ state.context.push({ role: "user", content: line });
2672
+ }
2471
2673
  state.busy = true;
2472
2674
  state.streaming = { text: "", reasoning: "", thinkingStartedAt: 0 };
2473
2675
  state.scroll = 0;
@@ -2602,6 +2804,23 @@ async function runTUI({
2602
2804
  if (result.messages) state.context = trimHistory(result.messages);
2603
2805
  // Reconectou de verdade: limpa o alerta e os erros de rede pendentes.
2604
2806
  clearRetry();
2807
+ } else if (result.code === "vision_not_supported") {
2808
+ // O modelo escolhido não aceita imagens: nada de erro genérico nem de
2809
+ // retry automático (reenviar a mesma imagem falharia de novo). Avisa de
2810
+ // forma clara e sugere trocar para um modelo com visão.
2811
+ // Desfaz o turno no contexto e devolve os anexos para reuso.
2812
+ if (attached.length && state.context.length > contextBefore) {
2813
+ state.context.length = contextBefore;
2814
+ state.images = attached;
2815
+ }
2816
+ state.messages.push({
2817
+ role: "system",
2818
+ text:
2819
+ result.error ||
2820
+ "Este modelo não aceita imagens. Escolha um modelo com visão (ex.: `kafox model`) e envie de novo — o anexo foi mantido.",
2821
+ error: true,
2822
+ connectionError: false,
2823
+ });
2605
2824
  } else {
2606
2825
  // Erro de rede/servidor entra marcado como transitório: some sozinho
2607
2826
  // quando a próxima resposta chegar. Erros de verdade (saldo, chave)
@@ -2736,6 +2955,22 @@ async function runTUI({
2736
2955
  const cols = Math.max(30, stdout.columns || 80);
2737
2956
  const rows = Math.max(14, stdout.rows || 24);
2738
2957
  const overlay = buildOverlay(state, cols, rows);
2958
+
2959
+ // "✕" dos previews: alvo clicavel mesmo sem overlay aberto. As coords do
2960
+ // mouse sao 1-based; imageHit guarda linha relativa + coluna 1-based.
2961
+ if (!overlay && state.imageHit && state.inputTop != null) {
2962
+ const rowAt = y - 1;
2963
+ for (const [key, hit] of Object.entries(state.imageHit)) {
2964
+ if (state.inputTop + hit.row !== rowAt) continue;
2965
+ const col = x - 1;
2966
+ if (col < hit.col - 1 || col > hit.col + 1) continue;
2967
+ const index = Number(key);
2968
+ state.images.splice(index, 1);
2969
+ state.imageError = null;
2970
+ return render();
2971
+ }
2972
+ }
2973
+
2739
2974
  if (!overlay || !overlay.hit) return;
2740
2975
 
2741
2976
  const { first, count, left, width, kind } = overlay.hit;
@@ -2832,6 +3067,8 @@ async function runTUI({
2832
3067
  if (key.name === "f2") return toggleMouse();
2833
3068
  if (key.ctrl && key.name === "y") return copyLastAnswer();
2834
3069
  if (key.ctrl && key.name === "r") return refreshBalance();
3070
+ // Ctrl+V cola imagem (ou texto) da area de transferencia.
3071
+ if (key.ctrl && key.name === "v") return pasteFromClipboard();
2835
3072
 
2836
3073
  // Modal de texto: qualquer tecla fecha — e se for texto, ele vai para o
2837
3074
  // input em vez de ser engolido (quem digita nao perde o que escreveu).