kafox-cli 0.10.1 → 0.10.3
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/package.json +1 -1
- package/src/auth.js +4 -2
- package/src/cli.js +147 -22
- package/src/gateway.js +178 -25
- package/src/images.js +341 -0
- package/src/mobile/index.js +1 -1
- package/src/prompt-history.js +64 -0
- package/src/sessions.js +24 -0
- package/src/tui.js +386 -62
package/src/tui.js
CHANGED
|
@@ -25,8 +25,10 @@ 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, humanSize } = require("./images");
|
|
30
32
|
|
|
31
33
|
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
32
34
|
const stripAnsi = (value) => String(value).replace(ANSI, "");
|
|
@@ -316,8 +318,26 @@ function tableLines(header, aligns = [], body = [], width = 80, theme = "default
|
|
|
316
318
|
return lines;
|
|
317
319
|
}
|
|
318
320
|
|
|
319
|
-
|
|
321
|
+
// Cache do markdown renderizado por {texto, largura, tema}. Sem isso, cada
|
|
322
|
+
// frame (inclusive a cada tecla) reparseava o markdown de TODAS as mensagens
|
|
323
|
+
// da conversa, o que domina o tempo de render em chats longos.
|
|
324
|
+
const MARKDOWN_CACHE = new Map();
|
|
325
|
+
const MARKDOWN_CACHE_MAX = 400;
|
|
326
|
+
/** Converte Markdown simples em linhas prontas para o terminal (com cache). */
|
|
320
327
|
function markdownLines(text, width, theme = "default") {
|
|
328
|
+
const key = `${width}\u0000${theme}\u0000${text}`;
|
|
329
|
+
const hit = MARKDOWN_CACHE.get(key);
|
|
330
|
+
if (hit) return hit;
|
|
331
|
+
const out = markdownLinesUncached(text, width, theme);
|
|
332
|
+
if (MARKDOWN_CACHE.size >= MARKDOWN_CACHE_MAX) {
|
|
333
|
+
// Despeja o mais antigo (ordem de insercao do Map).
|
|
334
|
+
MARKDOWN_CACHE.delete(MARKDOWN_CACHE.keys().next().value);
|
|
335
|
+
}
|
|
336
|
+
MARKDOWN_CACHE.set(key, out);
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function markdownLinesUncached(text, width, theme = "default") {
|
|
321
341
|
const accent = accentFor(theme);
|
|
322
342
|
const out = [];
|
|
323
343
|
const rows = String(text).replace(/\r/g, "").split("\n");
|
|
@@ -481,6 +501,8 @@ const CONTROL_KEYS = {
|
|
|
481
501
|
12: { name: "l", ctrl: true },
|
|
482
502
|
13: { name: "return" },
|
|
483
503
|
21: { name: "u", ctrl: true },
|
|
504
|
+
// Ctrl+R: atualiza saldo da conta e dos provedores externos agora.
|
|
505
|
+
18: { name: "r", ctrl: true },
|
|
484
506
|
// Ctrl+Q: desiste das mensagens que estao na fila (nao cancela a atual).
|
|
485
507
|
17: { name: "q", ctrl: true },
|
|
486
508
|
23: { name: "w", ctrl: true },
|
|
@@ -661,6 +683,10 @@ function initialState() {
|
|
|
661
683
|
return {
|
|
662
684
|
input: "",
|
|
663
685
|
cursor: 0,
|
|
686
|
+
// Anexos de imagem do proximo turno (maximo MAX_IMAGES). Sao consumidos
|
|
687
|
+
// por submit() e nao sobrevivem a ele.
|
|
688
|
+
images: [],
|
|
689
|
+
imageError: null,
|
|
664
690
|
history: [],
|
|
665
691
|
historyIndex: -1,
|
|
666
692
|
messages: [],
|
|
@@ -684,6 +710,7 @@ function initialState() {
|
|
|
684
710
|
modal: null,
|
|
685
711
|
models: [],
|
|
686
712
|
balance: null,
|
|
713
|
+
providerBalances: [],
|
|
687
714
|
tokens: 0,
|
|
688
715
|
files: [],
|
|
689
716
|
mcp: { loading: false, statuses: [], tools: 0 },
|
|
@@ -754,6 +781,8 @@ function heroLines(state, width, available) {
|
|
|
754
781
|
" envia " +
|
|
755
782
|
chalk.dim("/") +
|
|
756
783
|
" menu " +
|
|
784
|
+
chalk.dim("ctrl+r") +
|
|
785
|
+
" saldo " +
|
|
757
786
|
chalk.dim("ctrl+c") +
|
|
758
787
|
" sai",
|
|
759
788
|
width
|
|
@@ -1082,40 +1111,81 @@ function compactionLines(message, width, state) {
|
|
|
1082
1111
|
return lines;
|
|
1083
1112
|
}
|
|
1084
1113
|
|
|
1085
|
-
|
|
1114
|
+
/**
|
|
1115
|
+
* Virtualizacao de mensagens.
|
|
1116
|
+
*
|
|
1117
|
+
* Sem isso, cada frame (inclusive a cada tecla) reconstruia linha a linha TODO
|
|
1118
|
+
* o historico da conversa; em chats longos isso domina o tempo de render e
|
|
1119
|
+
* trava a digitacao. Aqui cada mensagem tem suas linhas memorizadas (chaveadas
|
|
1120
|
+
* por texto/largura/tema + o estado que muda o desenho) e so as mensagens que
|
|
1121
|
+
* caem no viewport sao realmente remontadas.
|
|
1122
|
+
*/
|
|
1123
|
+
const MESSAGE_CACHE = new Map();
|
|
1124
|
+
const MESSAGE_CACHE_MAX = 600;
|
|
1125
|
+
|
|
1126
|
+
/** Chave de cache de uma mensagem: tudo que muda o desenho dela. */
|
|
1127
|
+
function messageCacheKey(message, width, state) {
|
|
1128
|
+
const streaming = state.streaming;
|
|
1129
|
+
const extra =
|
|
1130
|
+
message.role === "tool"
|
|
1131
|
+
? `${message.status || ""}\u0001${message.duration || ""}\u0001${state.toolCompact ? 1 : 0}`
|
|
1132
|
+
: message.role === "thinking"
|
|
1133
|
+
? `${message.done ? 1 : 0}`
|
|
1134
|
+
: "";
|
|
1135
|
+
return `${width}\u0000${state.theme}\u0000${message.role}\u0000${extra}\u0000${message.text}`;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** Linhas de UMA mensagem (com cache). */
|
|
1139
|
+
function messageBlock(message, width, state) {
|
|
1140
|
+
const key = messageCacheKey(message, width, state);
|
|
1141
|
+
const hit = MESSAGE_CACHE.get(key);
|
|
1142
|
+
if (hit) return hit;
|
|
1143
|
+
const out = messageBlockUncached(message, width, state);
|
|
1144
|
+
if (MESSAGE_CACHE.size >= MESSAGE_CACHE_MAX) {
|
|
1145
|
+
MESSAGE_CACHE.delete(MESSAGE_CACHE.keys().next().value);
|
|
1146
|
+
}
|
|
1147
|
+
MESSAGE_CACHE.set(key, out);
|
|
1148
|
+
return out;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function messageBlockUncached(message, width, state) {
|
|
1086
1152
|
const lines = [];
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
const mark = message.interrupted ? "⏹" : "!";
|
|
1114
|
-
for (const line of wrap(message.text, width - 4)) lines.push(color(indent(2) + mark + " " + line));
|
|
1115
|
-
}
|
|
1153
|
+
if (message.role === "user") {
|
|
1154
|
+
lines.push("");
|
|
1155
|
+
const body = wrap(message.text, width - 2);
|
|
1156
|
+
lines.push(indent(2) + chalk.cyan("▌ ") + chalk.bold(body[0] || ""));
|
|
1157
|
+
for (let index = 1; index < body.length; index += 1) {
|
|
1158
|
+
lines.push(indent(4) + chalk.bold(body[index]));
|
|
1159
|
+
}
|
|
1160
|
+
} else if (message.role === "compaction") {
|
|
1161
|
+
lines.push(...compactionLines(message, width, state));
|
|
1162
|
+
} else if (message.role === "assistant") {
|
|
1163
|
+
lines.push("");
|
|
1164
|
+
lines.push(...markdownLines(message.text, width - 2, state.theme));
|
|
1165
|
+
const meta = [];
|
|
1166
|
+
if (message.interrupted) meta.push("interrompido");
|
|
1167
|
+
if (message.ms) meta.push(`${(message.ms / 1000).toFixed(1)}s`);
|
|
1168
|
+
if (meta.length) lines.push("");
|
|
1169
|
+
if (meta.length) lines.push(indent(2) + chalk.dim(meta.join(" · ")));
|
|
1170
|
+
} else if (message.role === "thinking") {
|
|
1171
|
+
lines.push(...thinkingLines(message, width));
|
|
1172
|
+
} else if (message.role === "tool") {
|
|
1173
|
+
lines.push(...toolCardLines(message, width, state));
|
|
1174
|
+
} else {
|
|
1175
|
+
lines.push("");
|
|
1176
|
+
const color = message.error ? chalk.red : message.interrupted ? chalk.yellow : chalk.dim;
|
|
1177
|
+
const mark = message.interrupted ? "⏹" : "!";
|
|
1178
|
+
for (const line of wrap(message.text, width - 4)) lines.push(color(indent(2) + mark + " " + line));
|
|
1116
1179
|
}
|
|
1180
|
+
return lines;
|
|
1181
|
+
}
|
|
1117
1182
|
|
|
1118
|
-
|
|
1183
|
+
/**
|
|
1184
|
+
* "Cauda" da conversa: bloco de streaming, indicador de "trabalhando" etc.
|
|
1185
|
+
* Fica separado do historico porque muda a cada token e nao vale cachear.
|
|
1186
|
+
*/
|
|
1187
|
+
function messageTailLines(state, width) {
|
|
1188
|
+
const lines = [];
|
|
1119
1189
|
const streaming = state.streaming;
|
|
1120
1190
|
if (streaming && (streaming.reasoning || streaming.text)) {
|
|
1121
1191
|
if (streaming.reasoning) {
|
|
@@ -1134,7 +1204,6 @@ function messageLines(state, width) {
|
|
|
1134
1204
|
lines.push(...markdownLines(streaming.text, width - 2, state.theme));
|
|
1135
1205
|
}
|
|
1136
1206
|
}
|
|
1137
|
-
|
|
1138
1207
|
if (state.busy && !streaming?.text) {
|
|
1139
1208
|
lines.push("");
|
|
1140
1209
|
lines.push(indent(2) + chalk.dim("trabalhando" + ".".repeat(state.frame % 4)));
|
|
@@ -1142,6 +1211,47 @@ function messageLines(state, width) {
|
|
|
1142
1211
|
return lines;
|
|
1143
1212
|
}
|
|
1144
1213
|
|
|
1214
|
+
/**
|
|
1215
|
+
* Monta o corpo da conversa gastando o minimo: processa mensagens de tras pra
|
|
1216
|
+
* frente ate cobrir o viewport pedido (ou ate acabar o historico). Fora do
|
|
1217
|
+
* viewport nem tocamos no markdown. Devolve o array ja "padded" no topo para
|
|
1218
|
+
* preencher a altura do terminal.
|
|
1219
|
+
*/
|
|
1220
|
+
function messageLines(state, width, budget = Infinity) {
|
|
1221
|
+
const need = budget === Infinity ? Infinity : Math.max(0, budget);
|
|
1222
|
+
const blocks = [];
|
|
1223
|
+
let total = 0;
|
|
1224
|
+
|
|
1225
|
+
const tail = messageTailLines(state, width);
|
|
1226
|
+
if (tail.length) {
|
|
1227
|
+
blocks.push({ start: 0, lines: tail });
|
|
1228
|
+
total += tail.length;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
for (let index = state.messages.length - 1; index >= 0; index -= 1) {
|
|
1232
|
+
const message = state.messages[index];
|
|
1233
|
+
const lines = messageBlock(message, width, state);
|
|
1234
|
+
blocks.push({ lines });
|
|
1235
|
+
total += lines.length;
|
|
1236
|
+
// Ja cobrimos o viewport (com uma folga para o padding do topo)? Paramos.
|
|
1237
|
+
if (total >= need + 4) break;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const lines = [];
|
|
1241
|
+
for (let index = blocks.length - 1; index >= 0; index -= 1) {
|
|
1242
|
+
lines.push(...blocks[index].lines);
|
|
1243
|
+
}
|
|
1244
|
+
return lines;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function messageLinesFull(state, width) {
|
|
1248
|
+
const lines = [];
|
|
1249
|
+
for (const message of state.messages) lines.push(...messageBlock(message, width, state));
|
|
1250
|
+
lines.push(...messageTailLines(state, width));
|
|
1251
|
+
return lines;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
|
|
1145
1255
|
function filteredCommands(state, commands) {
|
|
1146
1256
|
if (state.menuDismissed) return [];
|
|
1147
1257
|
if (!state.input.startsWith("/") || state.input.includes(" ")) return [];
|
|
@@ -1316,30 +1426,87 @@ function modelLine(state, width) {
|
|
|
1316
1426
|
);
|
|
1317
1427
|
}
|
|
1318
1428
|
|
|
1429
|
+
/**
|
|
1430
|
+
* Preview dos anexos dentro da caixa de input: a imagem pixelada (blocos com
|
|
1431
|
+
* cor real) e, a direita de cada uma, o rotulo clicavel "✕" que a remove.
|
|
1432
|
+
* O preview e montado na ordem inversa de proposito — a TUI desenha a caixa
|
|
1433
|
+
* de baixo para cima ao posicionar o cursor, entao o texto fica por ultimo.
|
|
1434
|
+
*/
|
|
1435
|
+
function imagePreviewLines(state, width) {
|
|
1436
|
+
const items = state.images || [];
|
|
1437
|
+
state.imageHit = {};
|
|
1438
|
+
if (!items.length) return [];
|
|
1439
|
+
|
|
1440
|
+
const inner = Math.max(10, width - 2);
|
|
1441
|
+
const lines = [];
|
|
1442
|
+
|
|
1443
|
+
items.forEach((image, index) => {
|
|
1444
|
+
const preview = image.preview || [];
|
|
1445
|
+
const label = `${index + 1}. ${truncate(image.name, Math.max(8, inner - 22))} ${chalk.dim(
|
|
1446
|
+
`${image.width}×${image.height} · ${humanSize(image.bytes)}`
|
|
1447
|
+
)}`;
|
|
1448
|
+
const remove = chalk.red.bold("✕");
|
|
1449
|
+
// A linha do rotulo e a ultima coluna util (onde cai o "✕"), para o
|
|
1450
|
+
// clique do mouse acertar o alvo. A TUI pinta 1 espaco de borda a
|
|
1451
|
+
// esquerda, entao a coluna do ✕ (1-based) e a posicao dentro de `inner`.
|
|
1452
|
+
const labelLine = padRight(" " + label, inner - 2) + " " + remove + " ";
|
|
1453
|
+
// 1-based dentro da coluna total: 1 (borda "│") + 1 (espaco) + label + 1.
|
|
1454
|
+
const removeCol = 1 + 1 + visible(label) + 1;
|
|
1455
|
+
|
|
1456
|
+
lines.push(paintPanel(state.theme, padRight(labelLine, inner)));
|
|
1457
|
+
for (const line of preview) {
|
|
1458
|
+
lines.push(paintPanel(state.theme, padRight(" " + line, inner)));
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
state.imageHit[index] = { row: lines.length - 1 - preview.length, col: removeCol };
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
return lines;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Caixa de entrada: borda superior, previews de imagem anexadas (com o "✕"
|
|
1469
|
+
* de remover), a linha de digitacao com cursor e a borda inferior. Devolve as
|
|
1470
|
+
* linhas prontas, a linha/coluna do cursor dentro do bloco e a linha do
|
|
1471
|
+
* rodape (avisos, ex.: modelo sem visao).
|
|
1472
|
+
*/
|
|
1319
1473
|
function inputBlock(state, width, { hero = false, tight = false } = {}) {
|
|
1320
|
-
const
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
const
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
const
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1474
|
+
const inner = Math.max(10, width - 2);
|
|
1475
|
+
const edge = (text) => border(state, text);
|
|
1476
|
+
const row = (content) => edge("│") + paintPanel(state.theme, padRight(content, inner)) + edge("│");
|
|
1477
|
+
const lines = [];
|
|
1478
|
+
|
|
1479
|
+
lines.push(edge("┌" + "─".repeat(inner) + "┐"));
|
|
1480
|
+
|
|
1481
|
+
const previews = hero ? [] : imagePreviewLines(state, inner);
|
|
1482
|
+
for (const preview of previews) lines.push(preview);
|
|
1483
|
+
|
|
1484
|
+
const prompt = hero ? " " : "› ";
|
|
1485
|
+
const room = Math.max(4, inner - visible(prompt) - 1);
|
|
1486
|
+
// Janela de rolagem horizontal: mantem o cursor visivel em linhas longas.
|
|
1487
|
+
let offset = 0;
|
|
1488
|
+
if (state.cursor > room) offset = state.cursor - room;
|
|
1489
|
+
const shown = state.input.slice(offset, offset + room);
|
|
1490
|
+
const beforeCursor = state.input.slice(offset, state.cursor);
|
|
1491
|
+
// A coluna e absoluta na linha montada pelo frame: 1 espaco de borda +
|
|
1492
|
+
// 1 borda "│" + 1 espaco do row + prompt + o que ja foi digitado.
|
|
1493
|
+
const cursorColInRow = 4 + visible(prompt) + visible(beforeCursor);
|
|
1494
|
+
|
|
1495
|
+
const typed = state.input.length ? shown : chalk.dim("Escreva algo ou /comando");
|
|
1496
|
+
const inputRow = lines.length;
|
|
1497
|
+
lines.push(row(" " + prompt + typed));
|
|
1498
|
+
|
|
1499
|
+
lines.push(edge("└" + "─".repeat(inner) + "┘"));
|
|
1500
|
+
|
|
1501
|
+
const footer = state.imageError
|
|
1502
|
+
? chalk.red(truncate(`⚠ ${state.imageError}`, width))
|
|
1503
|
+
: null;
|
|
1336
1504
|
|
|
1337
1505
|
return {
|
|
1338
1506
|
lines,
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
footer: tight ? null : modelLine(state, width),
|
|
1507
|
+
footer,
|
|
1508
|
+
cursorRow: inputRow,
|
|
1509
|
+
cursorCol: cursorColInRow,
|
|
1343
1510
|
};
|
|
1344
1511
|
}
|
|
1345
1512
|
|
|
@@ -1627,6 +1794,8 @@ function buildFrame(state, cols, rows, commands = {}) {
|
|
|
1627
1794
|
...menu,
|
|
1628
1795
|
...input.lines,
|
|
1629
1796
|
...(input.footer ? [input.footer] : []),
|
|
1797
|
+
// Modelo + versao logo abaixo da caixa (some so em terminal baixo).
|
|
1798
|
+
...(bare ? [] : [modelLine(state, inner)]),
|
|
1630
1799
|
...status,
|
|
1631
1800
|
...hints,
|
|
1632
1801
|
];
|
|
@@ -1636,7 +1805,7 @@ function buildFrame(state, cols, rows, commands = {}) {
|
|
|
1636
1805
|
const bodyHeight = Math.max(0, rows - head.length - chrome.length);
|
|
1637
1806
|
state.pageSize = Math.max(1, bodyHeight);
|
|
1638
1807
|
|
|
1639
|
-
let body = hero ? [] : messageLines(state, inner);
|
|
1808
|
+
let body = hero ? [] : messageLines(state, inner, bodyHeight + state.scroll);
|
|
1640
1809
|
const maxScroll = Math.max(0, body.length - bodyHeight);
|
|
1641
1810
|
state.scroll = clamp(state.scroll, 0, maxScroll);
|
|
1642
1811
|
const end = body.length - state.scroll;
|
|
@@ -1674,10 +1843,16 @@ function buildFrame(state, cols, rows, commands = {}) {
|
|
|
1674
1843
|
});
|
|
1675
1844
|
}
|
|
1676
1845
|
|
|
1677
|
-
const
|
|
1846
|
+
const inputTop = head.length + bodyHeight + queue.length + menu.length;
|
|
1847
|
+
const inputRow = inputTop + input.cursorRow;
|
|
1678
1848
|
const cursorRow = overlay ? 0 : clamp(inputRow + 1, 1, rows);
|
|
1679
1849
|
const cursorCol = overlay ? 0 : clamp(input.cursorCol, 1, cols);
|
|
1680
1850
|
|
|
1851
|
+
// Guarda a linha (0-based) do topo do bloco de entrada para o clique do
|
|
1852
|
+
// mouse acertar o "✕" de cada preview (imageHit usa linhas relativas).
|
|
1853
|
+
state.inputTop = inputTop;
|
|
1854
|
+
state.inputInner = inner;
|
|
1855
|
+
|
|
1681
1856
|
return {
|
|
1682
1857
|
lines,
|
|
1683
1858
|
cursorRow,
|
|
@@ -1701,6 +1876,10 @@ async function runTUI({
|
|
|
1701
1876
|
const stdin = process.stdin;
|
|
1702
1877
|
const stdout = process.stdout;
|
|
1703
1878
|
const state = initialState();
|
|
1879
|
+
// Retoma também o histórico de linhas digitadas (↑/↓). Sem isso cada boot
|
|
1880
|
+
// começava com a lista vazia, mesmo com conversas salvas no disco.
|
|
1881
|
+
state.history = promptHistory.load();
|
|
1882
|
+
state.historyIndex = state.history.length;
|
|
1704
1883
|
|
|
1705
1884
|
// resume: "pick" abre o seletor de conversas, "last" pega a mais recente e
|
|
1706
1885
|
// qualquer outro valor e o codigo do chat que o usuario fechou no Ctrl+C.
|
|
@@ -1719,7 +1898,12 @@ async function runTUI({
|
|
|
1719
1898
|
state.sessionId = saved.id;
|
|
1720
1899
|
state.sessionStartedAt = saved.startedAt;
|
|
1721
1900
|
state.messages = Array.isArray(saved.messages) ? saved.messages : [];
|
|
1722
|
-
|
|
1901
|
+
// Sessao antiga pode nao ter `context`: reconstroi da conversa visivel
|
|
1902
|
+
// para a IA nao esquecer tudo ao retomar.
|
|
1903
|
+
state.context =
|
|
1904
|
+
Array.isArray(saved.context) && saved.context.length
|
|
1905
|
+
? saved.context
|
|
1906
|
+
: sessions.contextFromMessages(state.messages);
|
|
1723
1907
|
if (saved.model) state.model = saved.model;
|
|
1724
1908
|
if (saved.theme) state.theme = saved.theme;
|
|
1725
1909
|
state.autoApprove = forcedAllow || Boolean(saved.allowAll);
|
|
@@ -1755,19 +1939,35 @@ async function runTUI({
|
|
|
1755
1939
|
renderTimer.unref?.();
|
|
1756
1940
|
}
|
|
1757
1941
|
|
|
1942
|
+
// Digitar/apagar dispara uma tecla por evento: render() sincrono a cada tecla
|
|
1943
|
+
// reconstroi a conversa inteira e trava o input em chats longos. Este
|
|
1944
|
+
// agendador curto (~60fps) mantem a resposta visual imediata e coalesce
|
|
1945
|
+
// rajadas (segurar backspace/colar) num unico frame.
|
|
1946
|
+
let inputTimer = null;
|
|
1947
|
+
function scheduleInputRender() {
|
|
1948
|
+
if (inputTimer || closed) return;
|
|
1949
|
+
inputTimer = setTimeout(() => {
|
|
1950
|
+
inputTimer = null;
|
|
1951
|
+
render();
|
|
1952
|
+
}, 16);
|
|
1953
|
+
inputTimer.unref?.();
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1758
1956
|
const mouseMode = () => (state.mouse ? "\x1b[?1000h\x1b[?1006h" : "\x1b[?1000l\x1b[?1006l");
|
|
1759
1957
|
const enterScreen = () =>
|
|
1760
1958
|
write(`\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J${mouseMode()}\x1b[?2004h`);
|
|
1761
1959
|
const leaveScreen = () =>
|
|
1762
1960
|
write("\x1b[?2004l\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l");
|
|
1763
1961
|
|
|
1764
|
-
async function refreshAccount() {
|
|
1962
|
+
async function refreshAccount({ fresh = false } = {}) {
|
|
1765
1963
|
const token = await ensureSession();
|
|
1766
1964
|
if (!token) return;
|
|
1767
|
-
const response = await fetchApi(
|
|
1965
|
+
const response = await fetchApi(`/api/bootstrap${fresh ? "?fresh=1" : ""}`).catch(() => null);
|
|
1768
1966
|
if (!response || !response.ok) return;
|
|
1769
1967
|
const data = await response.json().catch(() => null);
|
|
1770
1968
|
if (data?.user) state.balance = data.user.balance_micros;
|
|
1969
|
+
// Saldo (credits) dos provedores externos, ex.: unlid/uncensored.
|
|
1970
|
+
state.providerBalances = Array.isArray(data?.provider_balances) ? data.provider_balances : [];
|
|
1771
1971
|
|
|
1772
1972
|
if (!state.model) {
|
|
1773
1973
|
const pricing = await fetchApi("/api/public/pricing")
|
|
@@ -1777,6 +1977,19 @@ async function runTUI({
|
|
|
1777
1977
|
}
|
|
1778
1978
|
}
|
|
1779
1979
|
|
|
1980
|
+
/** Atalho/botão de atualizar: re-busca saldo da conta e dos provedores. */
|
|
1981
|
+
async function refreshBalance() {
|
|
1982
|
+
if (state.balanceLoading) return;
|
|
1983
|
+
state.balanceLoading = true;
|
|
1984
|
+
render();
|
|
1985
|
+
try {
|
|
1986
|
+
await refreshAccount({ fresh: true });
|
|
1987
|
+
} finally {
|
|
1988
|
+
state.balanceLoading = false;
|
|
1989
|
+
render();
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1780
1993
|
function applyMcp() {
|
|
1781
1994
|
state.mcp.statuses = mcp.statuses();
|
|
1782
1995
|
state.mcp.tools = state.mcp.statuses.reduce((total, item) => total + item.tools, 0);
|
|
@@ -1994,7 +2207,10 @@ async function runTUI({
|
|
|
1994
2207
|
state.sessionId = saved.id;
|
|
1995
2208
|
state.sessionStartedAt = saved.startedAt;
|
|
1996
2209
|
state.messages = Array.isArray(saved.messages) ? saved.messages : [];
|
|
1997
|
-
state.context =
|
|
2210
|
+
state.context =
|
|
2211
|
+
Array.isArray(saved.context) && saved.context.length
|
|
2212
|
+
? saved.context
|
|
2213
|
+
: sessions.contextFromMessages(state.messages);
|
|
1998
2214
|
if (saved.model) state.model = saved.model;
|
|
1999
2215
|
if (saved.theme) state.theme = saved.theme;
|
|
2000
2216
|
state.autoApprove = forcedAllow || Boolean(saved.allowAll);
|
|
@@ -2076,6 +2292,35 @@ async function runTUI({
|
|
|
2076
2292
|
}
|
|
2077
2293
|
}
|
|
2078
2294
|
|
|
2295
|
+
// Anexa imagem(ns) ao proximo turno. Aceita um ou mais caminhos separados
|
|
2296
|
+
// por espaco. Respeita MAX_IMAGES e grava o ultimo erro em state.imageError.
|
|
2297
|
+
async function attachImages(argument) {
|
|
2298
|
+
const files = String(argument || "")
|
|
2299
|
+
.split(/\s+/)
|
|
2300
|
+
.map((part) => part.trim())
|
|
2301
|
+
.filter(Boolean);
|
|
2302
|
+
if (!files.length) return render();
|
|
2303
|
+
|
|
2304
|
+
const items = state.images || (state.images = []);
|
|
2305
|
+
let lastError = null;
|
|
2306
|
+
|
|
2307
|
+
for (const file of files) {
|
|
2308
|
+
if (items.length >= MAX_IMAGES) {
|
|
2309
|
+
lastError = `limite de ${MAX_IMAGES} imagens por mensagem`;
|
|
2310
|
+
break;
|
|
2311
|
+
}
|
|
2312
|
+
const result = loadImage(file.replace(/^~(?=\/)/, os.homedir()));
|
|
2313
|
+
if (!result || !result.ok) {
|
|
2314
|
+
lastError = `${path.basename(file)}: ${result?.error || "falha ao carregar"}`;
|
|
2315
|
+
continue;
|
|
2316
|
+
}
|
|
2317
|
+
items.push(result.attachment);
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
state.imageError = lastError;
|
|
2321
|
+
return render();
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2079
2324
|
async function runCommand(line) {
|
|
2080
2325
|
const parts = line.trim().split(/\s+/);
|
|
2081
2326
|
const name = parts[0];
|
|
@@ -2083,6 +2328,31 @@ async function runTUI({
|
|
|
2083
2328
|
|
|
2084
2329
|
// /models e /model sem argumento abrem o mesmo seletor navegavel.
|
|
2085
2330
|
if ((name === "/model" || name === "/models") && !parts[1]) return openModelPicker();
|
|
2331
|
+
|
|
2332
|
+
// /image <arquivo> anexa uma imagem ao proximo turno (ate MAX_IMAGES).
|
|
2333
|
+
// /image (sem argumento) lista o que esta anexado; /image clear limpa tudo.
|
|
2334
|
+
if (name === "/image" || name === "/images" || name === "/anexar") {
|
|
2335
|
+
const argument = parts.slice(1).join(" ").trim();
|
|
2336
|
+
if (!argument) {
|
|
2337
|
+
const items = state.images || [];
|
|
2338
|
+
state.modal = {
|
|
2339
|
+
title: "Imagens",
|
|
2340
|
+
text: items.length
|
|
2341
|
+
? `${items
|
|
2342
|
+
.map((image, index) => `${index + 1}. ${image.name} (${image.width}×${image.height} · ${humanSize(image.bytes)})`)
|
|
2343
|
+
.join("\n")}\n\nUse /image clear para remover todas. Clique no ✕ do preview para tirar uma.`
|
|
2344
|
+
: "Nenhuma imagem anexada.\n\nUse /image <arquivo> para anexar (PNG ou JPG, ate " +
|
|
2345
|
+
`${MAX_IMAGES} por vez). Exemplo: /image ~/Imagens/grafico.png`,
|
|
2346
|
+
};
|
|
2347
|
+
return render();
|
|
2348
|
+
}
|
|
2349
|
+
if (/^(clear|limpar|off|none|limpa)$/i.test(argument)) {
|
|
2350
|
+
state.images = [];
|
|
2351
|
+
state.imageError = null;
|
|
2352
|
+
return render();
|
|
2353
|
+
}
|
|
2354
|
+
return attachImages(argument);
|
|
2355
|
+
}
|
|
2086
2356
|
if (name === "/theme") return openThemePicker();
|
|
2087
2357
|
if (name === "/background" || name === "/bg") {
|
|
2088
2358
|
const value = parts.slice(1).join(" ");
|
|
@@ -2276,7 +2546,7 @@ async function runTUI({
|
|
|
2276
2546
|
function submit() {
|
|
2277
2547
|
const line = state.input.trim();
|
|
2278
2548
|
if (!line) return;
|
|
2279
|
-
state.history.push(line);
|
|
2549
|
+
state.history = promptHistory.push(line);
|
|
2280
2550
|
state.historyIndex = state.history.length;
|
|
2281
2551
|
state.input = "";
|
|
2282
2552
|
state.cursor = 0;
|
|
@@ -2318,7 +2588,27 @@ async function runTUI({
|
|
|
2318
2588
|
state.abort = controller;
|
|
2319
2589
|
state.escArmed = false;
|
|
2320
2590
|
startMessageText(line);
|
|
2321
|
-
|
|
2591
|
+
// Imagens anexadas viram partes multimodais no content desta mensagem.
|
|
2592
|
+
// O anexo e consumido no turno: some da lista assim que entra no contexto.
|
|
2593
|
+
const attached = state.images || [];
|
|
2594
|
+
// Guarda o tamanho do contexto ANTES deste turno: se o provedor recusar as
|
|
2595
|
+
// imagens (vision_not_supported), desfazemos o push e devolvemos os anexos
|
|
2596
|
+
// para o usuario reaproveitar com outro modelo, sem reenviar tudo a mao.
|
|
2597
|
+
const contextBefore = state.context.length;
|
|
2598
|
+
if (attached.length) {
|
|
2599
|
+
state.context.push({
|
|
2600
|
+
role: "user",
|
|
2601
|
+
content: [
|
|
2602
|
+
{ type: "text", text: line },
|
|
2603
|
+
...attached.map((image) => ({ type: "image_url", image_url: { url: image.dataUrl } })),
|
|
2604
|
+
],
|
|
2605
|
+
});
|
|
2606
|
+
startMessageText(`📎 ${attached.length} imagem(ns) anexada(s)`);
|
|
2607
|
+
state.images = [];
|
|
2608
|
+
state.imageError = null;
|
|
2609
|
+
} else {
|
|
2610
|
+
state.context.push({ role: "user", content: line });
|
|
2611
|
+
}
|
|
2322
2612
|
state.busy = true;
|
|
2323
2613
|
state.streaming = { text: "", reasoning: "", thinkingStartedAt: 0 };
|
|
2324
2614
|
state.scroll = 0;
|
|
@@ -2453,6 +2743,23 @@ async function runTUI({
|
|
|
2453
2743
|
if (result.messages) state.context = trimHistory(result.messages);
|
|
2454
2744
|
// Reconectou de verdade: limpa o alerta e os erros de rede pendentes.
|
|
2455
2745
|
clearRetry();
|
|
2746
|
+
} else if (result.code === "vision_not_supported") {
|
|
2747
|
+
// O modelo escolhido não aceita imagens: nada de erro genérico nem de
|
|
2748
|
+
// retry automático (reenviar a mesma imagem falharia de novo). Avisa de
|
|
2749
|
+
// forma clara e sugere trocar para um modelo com visão.
|
|
2750
|
+
// Desfaz o turno no contexto e devolve os anexos para reuso.
|
|
2751
|
+
if (attached.length && state.context.length > contextBefore) {
|
|
2752
|
+
state.context.length = contextBefore;
|
|
2753
|
+
state.images = attached;
|
|
2754
|
+
}
|
|
2755
|
+
state.messages.push({
|
|
2756
|
+
role: "system",
|
|
2757
|
+
text:
|
|
2758
|
+
result.error ||
|
|
2759
|
+
"Este modelo não aceita imagens. Escolha um modelo com visão (ex.: `kafox model`) e envie de novo — o anexo foi mantido.",
|
|
2760
|
+
error: true,
|
|
2761
|
+
connectionError: false,
|
|
2762
|
+
});
|
|
2456
2763
|
} else {
|
|
2457
2764
|
// Erro de rede/servidor entra marcado como transitório: some sozinho
|
|
2458
2765
|
// quando a próxima resposta chegar. Erros de verdade (saldo, chave)
|
|
@@ -2587,6 +2894,22 @@ async function runTUI({
|
|
|
2587
2894
|
const cols = Math.max(30, stdout.columns || 80);
|
|
2588
2895
|
const rows = Math.max(14, stdout.rows || 24);
|
|
2589
2896
|
const overlay = buildOverlay(state, cols, rows);
|
|
2897
|
+
|
|
2898
|
+
// "✕" dos previews: alvo clicavel mesmo sem overlay aberto. As coords do
|
|
2899
|
+
// mouse sao 1-based; imageHit guarda linha relativa + coluna 1-based.
|
|
2900
|
+
if (!overlay && state.imageHit && state.inputTop != null) {
|
|
2901
|
+
const rowAt = y - 1;
|
|
2902
|
+
for (const [key, hit] of Object.entries(state.imageHit)) {
|
|
2903
|
+
if (state.inputTop + hit.row !== rowAt) continue;
|
|
2904
|
+
const col = x - 1;
|
|
2905
|
+
if (col < hit.col - 1 || col > hit.col + 1) continue;
|
|
2906
|
+
const index = Number(key);
|
|
2907
|
+
state.images.splice(index, 1);
|
|
2908
|
+
state.imageError = null;
|
|
2909
|
+
return render();
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2590
2913
|
if (!overlay || !overlay.hit) return;
|
|
2591
2914
|
|
|
2592
2915
|
const { first, count, left, width, kind } = overlay.hit;
|
|
@@ -2617,7 +2940,7 @@ async function runTUI({
|
|
|
2617
2940
|
state.cursor = cursor == null ? value.length : clamp(cursor, 0, value.length);
|
|
2618
2941
|
state.menuIndex = 0;
|
|
2619
2942
|
state.menuDismissed = false;
|
|
2620
|
-
|
|
2943
|
+
scheduleInputRender();
|
|
2621
2944
|
}
|
|
2622
2945
|
|
|
2623
2946
|
function moveApproval(delta) {
|
|
@@ -2682,6 +3005,7 @@ async function runTUI({
|
|
|
2682
3005
|
|
|
2683
3006
|
if (key.name === "f2") return toggleMouse();
|
|
2684
3007
|
if (key.ctrl && key.name === "y") return copyLastAnswer();
|
|
3008
|
+
if (key.ctrl && key.name === "r") return refreshBalance();
|
|
2685
3009
|
|
|
2686
3010
|
// Modal de texto: qualquer tecla fecha — e se for texto, ele vai para o
|
|
2687
3011
|
// input em vez de ser engolido (quem digita nao perde o que escreveu).
|