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/README.md +20 -3
- package/package.json +1 -1
- package/src/cli.js +158 -23
- package/src/clipboard.js +183 -0
- package/src/images.js +341 -0
- package/src/mobile/index.js +1 -1
- package/src/prompt-history.js +64 -0
- package/src/sessions.js +88 -14
- package/src/tui.js +278 -41
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
|
|
1441
|
-
const
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
//
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
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
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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).
|