kafox-cli 0.10.2 → 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/cli.js +147 -22
- 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 +215 -41
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -4,8 +4,11 @@ const path = require("path");
|
|
|
4
4
|
const inquirer = require("inquirer");
|
|
5
5
|
const chalk = require("chalk");
|
|
6
6
|
const storage = require("./storage");
|
|
7
|
+
const promptHistory = require("./prompt-history");
|
|
7
8
|
const { ensureSession, fetchApi } = require("./auth");
|
|
8
|
-
const {
|
|
9
|
+
const { chat } = require("./gateway");
|
|
10
|
+
const { compactIfNeeded, trimHistory } = require("./compact");
|
|
11
|
+
const sessions = require("./sessions");
|
|
9
12
|
const { runTUI } = require("./tui");
|
|
10
13
|
const { runTheme, setContentBackground } = require("./theme");
|
|
11
14
|
const { logoText, logoLines } = require("./logo");
|
|
@@ -38,12 +41,61 @@ const COMMANDS = {
|
|
|
38
41
|
|
|
39
42
|
let history = [];
|
|
40
43
|
let modelOverride = null;
|
|
44
|
+
// Sessao do REPL sem TTY: e o que permite `kafox resume`/`kafox history`
|
|
45
|
+
// acharem uma conversa usada por pipe, script ou CI (sem passar pela TUI).
|
|
46
|
+
let plainSession = null;
|
|
41
47
|
|
|
42
48
|
function pushHistory(role, content) {
|
|
43
49
|
history.push({ role, content, at: new Date().toISOString() });
|
|
44
50
|
if (history.length > 200) history = history.slice(-200);
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Historico no formato OpenAI: e isso que vai para o provedor.
|
|
55
|
+
*
|
|
56
|
+
* Sem esse mapeamento o REPL sem TTY mandava so a linha digitada em cada
|
|
57
|
+
* requisicao, entao a IA respondia como se a conversa tivesse acabado de
|
|
58
|
+
* comecar. Mensagens seguidas do mesmo papel viram uma so (o provedor recusa
|
|
59
|
+
* duas em sequencia) e o system gerado pelo /compact e preservado.
|
|
60
|
+
*/
|
|
61
|
+
function conversationMessages(entries = history) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const role = String(entry?.role || "");
|
|
65
|
+
if (role !== "system" && role !== "user" && role !== "assistant") continue;
|
|
66
|
+
const content = String(entry?.content || "").trim();
|
|
67
|
+
if (!content) continue;
|
|
68
|
+
const last = out[out.length - 1];
|
|
69
|
+
if (last && last.role === role && role !== "system") last.content += `\n\n${content}`;
|
|
70
|
+
else out.push({ role, content });
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Conversa visivel, no formato que `sessions.save` e `kafox history` leem. */
|
|
76
|
+
function displayMessages() {
|
|
77
|
+
return history.map((entry) => ({ role: entry.role, text: entry.content }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Grava a conversa do REPL sem TTY em ~/.kafox/chats (best-effort). */
|
|
81
|
+
function savePlainSession() {
|
|
82
|
+
if (!plainSession) return;
|
|
83
|
+
try {
|
|
84
|
+
sessions.save({
|
|
85
|
+
id: plainSession.id,
|
|
86
|
+
cwd: process.cwd(),
|
|
87
|
+
model: modelOverride || storage.getConfig().model,
|
|
88
|
+
theme: storage.getConfig().theme,
|
|
89
|
+
allowAll: false,
|
|
90
|
+
messages: displayMessages(),
|
|
91
|
+
context: conversationMessages(),
|
|
92
|
+
startedAt: plainSession.startedAt,
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
// Persistencia e conveniencia: falha de disco nao derruba o chat.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
47
99
|
async function handleCommand(line) {
|
|
48
100
|
const [cmd, ...rest] = line.trim().split(/\s+/);
|
|
49
101
|
const arg = rest.join(" ");
|
|
@@ -178,6 +230,7 @@ async function handleCommand(line) {
|
|
|
178
230
|
return true;
|
|
179
231
|
case "/exit":
|
|
180
232
|
case "/quit":
|
|
233
|
+
savePlainSession();
|
|
181
234
|
process.exit(0);
|
|
182
235
|
default:
|
|
183
236
|
console.log(chalk.dim(`Unknown command: ${cmd}. Type /help`));
|
|
@@ -187,24 +240,35 @@ async function handleCommand(line) {
|
|
|
187
240
|
|
|
188
241
|
async function clearContext() {
|
|
189
242
|
history = [];
|
|
243
|
+
savePlainSession();
|
|
190
244
|
console.log(chalk.green("Context cleared."));
|
|
191
245
|
}
|
|
192
246
|
|
|
193
247
|
async function compact() {
|
|
194
|
-
|
|
248
|
+
const messages = conversationMessages();
|
|
249
|
+
if (!messages.length) {
|
|
195
250
|
console.log(chalk.dim("Nothing to compact."));
|
|
196
251
|
return;
|
|
197
252
|
}
|
|
198
|
-
const turns =
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
253
|
+
const turns = messages.length;
|
|
254
|
+
// Mesmo resumo estruturado da TUI: o texto antigo vira nota de contexto em
|
|
255
|
+
// vez de sumir (o comportamento antigo so guardava um aviso vazio).
|
|
256
|
+
const result = await compactIfNeeded(messages, {
|
|
257
|
+
model: modelOverride || storage.getConfig().model,
|
|
258
|
+
// 0 forca o resumo: quem digitou /compact quer compactar agora.
|
|
259
|
+
thresholdTokens: 0,
|
|
260
|
+
});
|
|
261
|
+
if (!result.compacted) {
|
|
262
|
+
console.log(chalk.yellow(`Nothing to compact: ${result.error || "resumo vazio"}`));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const at = new Date().toISOString();
|
|
266
|
+
history = result.messages.map((message) => ({
|
|
267
|
+
role: message.role,
|
|
268
|
+
content: String(message.content || ""),
|
|
269
|
+
at,
|
|
270
|
+
}));
|
|
271
|
+
savePlainSession();
|
|
208
272
|
console.log(
|
|
209
273
|
chalk.green(`Context compacted: ${turns} -> ${history.length} messages (auto summary).`)
|
|
210
274
|
);
|
|
@@ -234,13 +298,40 @@ async function showStatus() {
|
|
|
234
298
|
}
|
|
235
299
|
|
|
236
300
|
/** Modo simples: sem TTY (scripts, pipes) ou apos o login. */
|
|
237
|
-
async function runPlainREPL() {
|
|
301
|
+
async function runPlainREPL(options = {}) {
|
|
238
302
|
const cfg = storage.getConfig();
|
|
239
303
|
const theme = cfg.theme || "default";
|
|
304
|
+
history = [];
|
|
305
|
+
plainSession = { id: sessions.newId(), startedAt: new Date().toISOString() };
|
|
306
|
+
|
|
307
|
+
// `kafox resume <codigo>` tambem vale sem TTY: antes o pipe/script abria
|
|
308
|
+
// sempre uma conversa vazia, mesmo com a sessao salva no disco.
|
|
309
|
+
if (options.resume) {
|
|
310
|
+
const wanted = options.resume === "pick" || options.resume === true ? "last" : options.resume;
|
|
311
|
+
const saved = String(wanted) === "last" ? sessions.latest() : sessions.find(wanted);
|
|
312
|
+
if (saved) {
|
|
313
|
+
plainSession = { id: saved.id, startedAt: saved.startedAt || plainSession.startedAt };
|
|
314
|
+
const restored =
|
|
315
|
+
Array.isArray(saved.context) && saved.context.length
|
|
316
|
+
? saved.context
|
|
317
|
+
: sessions.contextFromMessages(saved.messages);
|
|
318
|
+
const at = new Date().toISOString();
|
|
319
|
+
history = restored
|
|
320
|
+
.filter((message) => ["system", "user", "assistant"].includes(message?.role))
|
|
321
|
+
.map((message) => ({ role: message.role, content: String(message.content || ""), at }));
|
|
322
|
+
} else {
|
|
323
|
+
console.log(chalk.yellow(` Conversa ${wanted} nao encontrada - comecando uma nova.`));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
240
326
|
|
|
241
327
|
console.log(logoLines(theme).join("\n"));
|
|
242
328
|
console.log(chalk.bold(`\n Kafox CLI v${VERSION}`));
|
|
243
329
|
console.log(chalk.dim(" Type /help for commands. Ctrl+C to exit.\n"));
|
|
330
|
+
if (history.length) {
|
|
331
|
+
console.log(
|
|
332
|
+
chalk.dim(` Retomando ${plainSession.id} (${conversationMessages().length} mensagens no contexto).`)
|
|
333
|
+
);
|
|
334
|
+
}
|
|
244
335
|
|
|
245
336
|
const token = await ensureSession().catch(() => null);
|
|
246
337
|
if (!token) {
|
|
@@ -268,35 +359,68 @@ async function runPlainREPL() {
|
|
|
268
359
|
input: process.stdin,
|
|
269
360
|
output: process.stdout,
|
|
270
361
|
prompt: chalk.cyan("kafox> "),
|
|
362
|
+
// O readline guarda o mais recente no começo da lista.
|
|
363
|
+
history: promptHistory.load().slice().reverse(),
|
|
364
|
+
historySize: promptHistory.MAX,
|
|
365
|
+
removeHistoryDuplicates: true,
|
|
271
366
|
});
|
|
272
367
|
|
|
273
|
-
|
|
274
|
-
rl.on("line", async (raw) => {
|
|
368
|
+
async function handleLine(raw) {
|
|
275
369
|
const line = raw.trim();
|
|
276
|
-
if (!line) return
|
|
370
|
+
if (!line) return;
|
|
277
371
|
try {
|
|
278
372
|
if (line.startsWith("/")) {
|
|
279
373
|
await handleCommand(line);
|
|
280
374
|
} else {
|
|
281
375
|
pushHistory("user", line);
|
|
282
|
-
|
|
283
|
-
|
|
376
|
+
promptHistory.push(line);
|
|
377
|
+
// O historico inteiro (nao so a ultima linha) e o que faz a IA lembrar
|
|
378
|
+
// do que ja foi dito nesta conversa.
|
|
379
|
+
const result = await chat({
|
|
380
|
+
messages: trimHistory(conversationMessages()),
|
|
381
|
+
model: modelOverride || storage.getConfig().model,
|
|
382
|
+
});
|
|
383
|
+
if (!result.ok) {
|
|
384
|
+
console.log(chalk.red(`\n${result.error}`));
|
|
385
|
+
} else {
|
|
386
|
+
const answer = String(result.text || "").trim() || "(resposta vazia)";
|
|
387
|
+
console.log(chalk.green("\nAnswer:"));
|
|
388
|
+
console.log(answer);
|
|
389
|
+
pushHistory("assistant", answer);
|
|
390
|
+
}
|
|
391
|
+
savePlainSession();
|
|
284
392
|
}
|
|
285
393
|
} catch (err) {
|
|
286
394
|
console.log(chalk.red(`\nError: ${err.message}`));
|
|
287
395
|
}
|
|
288
|
-
rl.prompt();
|
|
396
|
+
if (!rl.closed) rl.prompt();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Num pipe o stdin chega inteiro de uma vez: processar as linhas em paralelo
|
|
400
|
+
// embaralhava o historico (a segunda nao via a resposta da primeira).
|
|
401
|
+
let pending = Promise.resolve();
|
|
402
|
+
rl.prompt();
|
|
403
|
+
rl.on("line", (raw) => {
|
|
404
|
+
pending = pending.then(() => handleLine(raw));
|
|
405
|
+
return pending;
|
|
289
406
|
});
|
|
290
407
|
|
|
291
|
-
rl.on("close", () => {
|
|
292
|
-
|
|
408
|
+
rl.on("close", async () => {
|
|
409
|
+
// EOF do pipe nao pode cortar a ultima resposta no meio: antes o processo
|
|
410
|
+
// saia antes do POST /api/chat terminar e o chat ficava sem resposta.
|
|
411
|
+
await pending.catch(() => {});
|
|
412
|
+
savePlainSession();
|
|
413
|
+
if (plainSession && conversationMessages().length) {
|
|
414
|
+
console.log(chalk.dim(`\nConversa salva: kafox resume ${plainSession.id}`));
|
|
415
|
+
}
|
|
416
|
+
console.log(chalk.dim("See you."));
|
|
293
417
|
process.exit(0);
|
|
294
418
|
});
|
|
295
419
|
}
|
|
296
420
|
|
|
297
421
|
async function runREPL(options = {}) {
|
|
298
422
|
// Sem TTY (pipe/CI), mantem o REPL linha a linha.
|
|
299
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) return runPlainREPL();
|
|
423
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return runPlainREPL(options);
|
|
300
424
|
|
|
301
425
|
const cfg = storage.getConfig();
|
|
302
426
|
const token = await ensureSession().catch(() => null);
|
|
@@ -332,5 +456,6 @@ module.exports = {
|
|
|
332
456
|
clearContext,
|
|
333
457
|
showStatus,
|
|
334
458
|
handleCommand,
|
|
459
|
+
conversationMessages,
|
|
335
460
|
COMMANDS,
|
|
336
461
|
};
|
package/src/images.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anexos de imagem do kafox.
|
|
3
|
+
*
|
|
4
|
+
* - carrega arquivos (png/jpg/gif/webp) do disco ou de bytes colados
|
|
5
|
+
* - decodifica para RGBA e gera um preview "pixelado" em blocos, no estilo
|
|
6
|
+
* dos terminais graficos: cada celula de caractere cobre 2 pixels verticais
|
|
7
|
+
* usando o glifo de meio bloco (▀) e as cores de verdadeiro color.
|
|
8
|
+
* - o preview e o que a TUI desenha dentro da caixa de input; a imagem
|
|
9
|
+
* completa (base64) vai no payload multimodal enviado ao modelo.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
|
|
14
|
+
let jpegDecode = null;
|
|
15
|
+
try {
|
|
16
|
+
// Dep opcional: se nao existir, JPEG fica indisponivel mas PNG continua.
|
|
17
|
+
jpegDecode = require("jpeg-js").decode;
|
|
18
|
+
} catch {
|
|
19
|
+
jpegDecode = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAX_IMAGES = 3;
|
|
23
|
+
const MAX_BYTES = 12 * 1024 * 1024; // 12 MB por arquivo
|
|
24
|
+
const MAX_PREVIEW_COLS = 44;
|
|
25
|
+
const MAX_PREVIEW_ROWS = 10; // em celulas (cada celula = 2 pixels de altura)
|
|
26
|
+
|
|
27
|
+
const MIME_BY_EXT = {
|
|
28
|
+
".png": "image/png",
|
|
29
|
+
".jpg": "image/jpeg",
|
|
30
|
+
".jpeg": "image/jpeg",
|
|
31
|
+
".gif": "image/gif",
|
|
32
|
+
".webp": "image/webp",
|
|
33
|
+
".bmp": "image/bmp",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/* ------------------------------------------------------------- decodificacao */
|
|
37
|
+
|
|
38
|
+
function sniffMime(buffer) {
|
|
39
|
+
if (buffer.length >= 8 && buffer.readUInt32BE(0) === 0x89504e47) return "image/png";
|
|
40
|
+
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
|
|
41
|
+
if (buffer.length >= 6 && buffer.slice(0, 6).toString("ascii") === "GIF87a") return "image/gif";
|
|
42
|
+
if (buffer.length >= 6 && buffer.slice(0, 6).toString("ascii") === "GIF89a") return "image/gif";
|
|
43
|
+
if (buffer.length >= 12 && buffer.slice(0, 4).toString("ascii") === "RIFF" && buffer.slice(8, 12).toString("ascii") === "WEBP") {
|
|
44
|
+
return "image/webp";
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function decodePng(buffer) {
|
|
50
|
+
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
51
|
+
if (!buffer.slice(0, 8).equals(signature)) throw new Error("PNG inválido");
|
|
52
|
+
|
|
53
|
+
const { inflateSync } = require("zlib");
|
|
54
|
+
let offset = 8;
|
|
55
|
+
let width = 0;
|
|
56
|
+
let height = 0;
|
|
57
|
+
let depth = 0;
|
|
58
|
+
let colorType = 0;
|
|
59
|
+
let interlace = 0;
|
|
60
|
+
const idat = [];
|
|
61
|
+
|
|
62
|
+
while (offset + 8 <= buffer.length) {
|
|
63
|
+
const length = buffer.readUInt32BE(offset);
|
|
64
|
+
const type = buffer.slice(offset + 4, offset + 8).toString("ascii");
|
|
65
|
+
const data = buffer.slice(offset + 8, offset + 8 + length);
|
|
66
|
+
offset += 12 + length;
|
|
67
|
+
|
|
68
|
+
if (type === "IHDR") {
|
|
69
|
+
width = data.readUInt32BE(0);
|
|
70
|
+
height = data.readUInt32BE(4);
|
|
71
|
+
depth = data[8];
|
|
72
|
+
colorType = data[9];
|
|
73
|
+
interlace = data[12];
|
|
74
|
+
} else if (type === "IDAT") {
|
|
75
|
+
idat.push(data);
|
|
76
|
+
} else if (type === "IEND") {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!width || !height) throw new Error("PNG sem cabeçalho IHDR");
|
|
82
|
+
if (depth !== 8) throw new Error("PNG com profundidade não suportada");
|
|
83
|
+
if (interlace !== 0) throw new Error("PNG entrelaçado não suportado");
|
|
84
|
+
|
|
85
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType];
|
|
86
|
+
if (!channels) throw new Error("PNG com color type não suportado");
|
|
87
|
+
|
|
88
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
89
|
+
const stride = width * channels;
|
|
90
|
+
const out = Buffer.alloc(width * height * 4);
|
|
91
|
+
|
|
92
|
+
let prev = Buffer.alloc(stride);
|
|
93
|
+
let cursor = 0;
|
|
94
|
+
|
|
95
|
+
for (let y = 0; y < height; y += 1) {
|
|
96
|
+
const filter = raw[cursor];
|
|
97
|
+
cursor += 1;
|
|
98
|
+
const line = Buffer.from(raw.slice(cursor, cursor + stride));
|
|
99
|
+
cursor += stride;
|
|
100
|
+
|
|
101
|
+
for (let x = 0; x < stride; x += 1) {
|
|
102
|
+
const a = x >= channels ? line[x - channels] : 0;
|
|
103
|
+
const b = prev[x];
|
|
104
|
+
const c = x >= channels ? prev[x - channels] : 0;
|
|
105
|
+
switch (filter) {
|
|
106
|
+
case 1:
|
|
107
|
+
line[x] = (line[x] + a) & 0xff;
|
|
108
|
+
break;
|
|
109
|
+
case 2:
|
|
110
|
+
line[x] = (line[x] + b) & 0xff;
|
|
111
|
+
break;
|
|
112
|
+
case 3:
|
|
113
|
+
line[x] = (line[x] + ((a + b) >> 1)) & 0xff;
|
|
114
|
+
break;
|
|
115
|
+
case 4: {
|
|
116
|
+
const p = a + b - c;
|
|
117
|
+
const pa = Math.abs(p - a);
|
|
118
|
+
const pb = Math.abs(p - b);
|
|
119
|
+
const pc = Math.abs(p - c);
|
|
120
|
+
const pred = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
121
|
+
line[x] = (line[x] + pred) & 0xff;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
default:
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (let x = 0; x < width; x += 1) {
|
|
130
|
+
const src = x * channels;
|
|
131
|
+
const dst = (y * width + x) * 4;
|
|
132
|
+
if (colorType === 0) {
|
|
133
|
+
const gray = line[src];
|
|
134
|
+
out[dst] = gray;
|
|
135
|
+
out[dst + 1] = gray;
|
|
136
|
+
out[dst + 2] = gray;
|
|
137
|
+
out[dst + 3] = 255;
|
|
138
|
+
} else if (colorType === 2) {
|
|
139
|
+
out[dst] = line[src];
|
|
140
|
+
out[dst + 1] = line[src + 1];
|
|
141
|
+
out[dst + 2] = line[src + 2];
|
|
142
|
+
out[dst + 3] = 255;
|
|
143
|
+
} else if (colorType === 4) {
|
|
144
|
+
out[dst] = line[src];
|
|
145
|
+
out[dst + 1] = line[src];
|
|
146
|
+
out[dst + 2] = line[src];
|
|
147
|
+
out[dst + 3] = line[src + 1];
|
|
148
|
+
} else {
|
|
149
|
+
out[dst] = line[src];
|
|
150
|
+
out[dst + 1] = line[src + 1];
|
|
151
|
+
out[dst + 2] = line[src + 2];
|
|
152
|
+
out[dst + 3] = line[src + 3];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
prev = line;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { width, height, data: out };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function decodeImage(buffer, mime) {
|
|
163
|
+
const kind = mime || sniffMime(buffer);
|
|
164
|
+
if (kind === "image/png") return decodePng(buffer);
|
|
165
|
+
if (kind === "image/jpeg") {
|
|
166
|
+
if (!jpegDecode) throw new Error("JPEG requer a dependência jpeg-js");
|
|
167
|
+
const decoded = jpegDecode(buffer, { useTArray: true, maxMemoryUsageInMB: 512 });
|
|
168
|
+
// jpeg-js pode devolver RGB sem alfa; normaliza para RGBA.
|
|
169
|
+
if (decoded.data.length === decoded.width * decoded.height * 4) {
|
|
170
|
+
return { width: decoded.width, height: decoded.height, data: Buffer.from(decoded.data) };
|
|
171
|
+
}
|
|
172
|
+
const data = Buffer.alloc(decoded.width * decoded.height * 4);
|
|
173
|
+
for (let i = 0, j = 0; i < decoded.data.length; i += 3, j += 4) {
|
|
174
|
+
data[j] = decoded.data[i];
|
|
175
|
+
data[j + 1] = decoded.data[i + 1];
|
|
176
|
+
data[j + 2] = decoded.data[i + 2];
|
|
177
|
+
data[j + 3] = 255;
|
|
178
|
+
}
|
|
179
|
+
return { width: decoded.width, height: decoded.height, data };
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`formato não suportado: ${kind || "desconhecido"}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* --------------------------------------------------------------- pixelizacao */
|
|
185
|
+
|
|
186
|
+
function sampleBox(img, x0, y0, x1, y1) {
|
|
187
|
+
let r = 0;
|
|
188
|
+
let g = 0;
|
|
189
|
+
let b = 0;
|
|
190
|
+
let a = 0;
|
|
191
|
+
let n = 0;
|
|
192
|
+
const left = Math.max(0, Math.floor(x0));
|
|
193
|
+
const right = Math.min(img.width, Math.max(left + 1, Math.ceil(x1)));
|
|
194
|
+
const top = Math.max(0, Math.floor(y0));
|
|
195
|
+
const bottom = Math.min(img.height, Math.max(top + 1, Math.ceil(y1)));
|
|
196
|
+
|
|
197
|
+
for (let y = top; y < bottom; y += 1) {
|
|
198
|
+
for (let x = left; x < right; x += 1) {
|
|
199
|
+
const i = (y * img.width + x) * 4;
|
|
200
|
+
const alpha = img.data[i + 3] / 255;
|
|
201
|
+
// Composicao sobre branco: assim imagem com transparencia nao fica preta.
|
|
202
|
+
r += img.data[i] * alpha + 255 * (1 - alpha);
|
|
203
|
+
g += img.data[i + 1] * alpha + 255 * (1 - alpha);
|
|
204
|
+
b += img.data[i + 2] * alpha + 255 * (1 - alpha);
|
|
205
|
+
a += img.data[i + 3];
|
|
206
|
+
n += 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!n) return { r: 0, g: 0, b: 0, a: 0 };
|
|
211
|
+
return { r: r / n, g: g / n, b: b / n, a: a / n };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Gera o preview pixelado. Usa meio bloco: um caractere = 2 pixels empilhados,
|
|
216
|
+
* entao a quantidade de linhas de texto e metade dos pixels amostrados.
|
|
217
|
+
*/
|
|
218
|
+
function pixelate(img, { cols = MAX_PREVIEW_COLS, rows = MAX_PREVIEW_ROWS } = {}) {
|
|
219
|
+
const cellRows = clampNumber(rows, 1, 40);
|
|
220
|
+
const cellCols = clampNumber(cols, 4, 120);
|
|
221
|
+
const pxHigh = cellRows * 2;
|
|
222
|
+
|
|
223
|
+
const scale = Math.min(cellCols / img.width, pxHigh / img.height);
|
|
224
|
+
const drawW = Math.max(1, Math.round(img.width * scale));
|
|
225
|
+
const drawH = Math.max(1, Math.round(img.height * scale));
|
|
226
|
+
const offX = Math.floor((cellCols - drawW) / 2);
|
|
227
|
+
const offY = Math.floor((pxHigh - drawH) / 2);
|
|
228
|
+
|
|
229
|
+
const grid = [];
|
|
230
|
+
for (let y = 0; y < pxHigh; y += 1) {
|
|
231
|
+
const row = [];
|
|
232
|
+
for (let x = 0; x < cellCols; x += 1) {
|
|
233
|
+
const inside = x >= offX && x < offX + drawW && y >= offY && y < offY + drawH;
|
|
234
|
+
if (!inside) {
|
|
235
|
+
row.push(null);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const sx0 = ((x - offX) / drawW) * img.width;
|
|
239
|
+
const sy0 = ((y - offY) / drawH) * img.height;
|
|
240
|
+
row.push(sampleBox(img, sx0, sy0, sx0 + img.width / drawW, sy0 + img.height / drawH));
|
|
241
|
+
}
|
|
242
|
+
grid.push(row);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const lines = [];
|
|
246
|
+
for (let y = 0; y < grid.length; y += 2) {
|
|
247
|
+
let out = "";
|
|
248
|
+
for (let x = 0; x < cellCols; x += 1) {
|
|
249
|
+
const top = grid[y][x];
|
|
250
|
+
const bottom = grid[y + 1] ? grid[y + 1][x] : null;
|
|
251
|
+
if (!top && !bottom) {
|
|
252
|
+
out += " ";
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const fg = top || bottom;
|
|
256
|
+
const bg = bottom || top;
|
|
257
|
+
out += `\x1b[38;2;${Math.round(fg.r)};${Math.round(fg.g)};${Math.round(fg.b)}m` +
|
|
258
|
+
`\x1b[48;2;${Math.round(bg.r)};${Math.round(bg.g)};${Math.round(bg.b)}m▀\x1b[0m`;
|
|
259
|
+
}
|
|
260
|
+
lines.push(out);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { lines, width: cellCols, height: pxHigh };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function clampNumber(value, min, max) {
|
|
267
|
+
return Math.max(min, Math.min(max, value));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ------------------------------------------------------------------- anexos */
|
|
271
|
+
|
|
272
|
+
function mimeFor(file) {
|
|
273
|
+
return MIME_BY_EXT[path.extname(file).toLowerCase()] || null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Carrega uma imagem do disco e monta o anexo usado pela TUI.
|
|
278
|
+
* Retorna `{ ok, attachment }` ou `{ ok: false, error }` — nunca lanca.
|
|
279
|
+
*/
|
|
280
|
+
function loadImage(file, options = {}) {
|
|
281
|
+
const target = path.resolve(file);
|
|
282
|
+
let buffer;
|
|
283
|
+
try {
|
|
284
|
+
const stat = fs.statSync(target);
|
|
285
|
+
if (!stat.isFile()) return { ok: false, error: "não é um arquivo" };
|
|
286
|
+
if (stat.size > (options.maxBytes || MAX_BYTES)) {
|
|
287
|
+
return { ok: false, error: "arquivo maior que 12 MB" };
|
|
288
|
+
}
|
|
289
|
+
buffer = fs.readFileSync(target);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return { ok: false, error: `não consegui abrir (${error.code || error.message})` };
|
|
292
|
+
}
|
|
293
|
+
return buildAttachment(buffer, { name: path.basename(target), mime: mimeFor(target) || sniffMime(buffer), ...options });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Monta o anexo a partir de bytes ja em memoria (colagem/arrastar). */
|
|
297
|
+
function buildAttachment(buffer, { name = "imagem", mime, cols, rows } = {}) {
|
|
298
|
+
const kind = mime || sniffMime(buffer);
|
|
299
|
+
if (!kind) return { ok: false, error: "formato de imagem não reconhecido" };
|
|
300
|
+
|
|
301
|
+
let decoded;
|
|
302
|
+
try {
|
|
303
|
+
decoded = decodeImage(buffer, kind);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return { ok: false, error: `não consegui decodificar (${error.message})` };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const preview = pixelate(decoded, { cols, rows });
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
attachment: {
|
|
312
|
+
name,
|
|
313
|
+
mime: kind,
|
|
314
|
+
bytes: buffer.length,
|
|
315
|
+
width: decoded.width,
|
|
316
|
+
height: decoded.height,
|
|
317
|
+
dataUrl: `data:${kind};base64,${buffer.toString("base64")}`,
|
|
318
|
+
preview: preview.lines,
|
|
319
|
+
previewWidth: preview.width,
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function humanSize(bytes) {
|
|
325
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
326
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
327
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
module.exports = {
|
|
331
|
+
MAX_IMAGES,
|
|
332
|
+
MAX_BYTES,
|
|
333
|
+
MAX_PREVIEW_COLS,
|
|
334
|
+
MAX_PREVIEW_ROWS,
|
|
335
|
+
sniffMime,
|
|
336
|
+
decodeImage,
|
|
337
|
+
pixelate,
|
|
338
|
+
loadImage,
|
|
339
|
+
buildAttachment,
|
|
340
|
+
humanSize,
|
|
341
|
+
};
|
package/src/mobile/index.js
CHANGED
|
@@ -270,7 +270,7 @@ async function runMobile({
|
|
|
270
270
|
sessionId: saved?.id || sessions.newId(),
|
|
271
271
|
sessionStartedAt: saved?.startedAt || new Date().toISOString(),
|
|
272
272
|
messages: saved?.messages || [],
|
|
273
|
-
context: saved?.context
|
|
273
|
+
context: saved?.context?.length ? saved.context : sessions.contextFromMessages(saved?.messages),
|
|
274
274
|
model: saved?.model || null,
|
|
275
275
|
allowAll: allowAll || Boolean(saved?.allowAll),
|
|
276
276
|
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Histórico de linhas digitadas no terminal (setas ↑/↓).
|
|
3
|
+
*
|
|
4
|
+
* É separado das conversas salvas em ~/.kafox/chats: aqui ficam só os prompts
|
|
5
|
+
* recentes, para reaproveitar uma pergunta sem precisar decorar. O arquivo é
|
|
6
|
+
* privado (0600) e tem teto de 200 linhas para não crescer sem limite.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const storage = require("./storage");
|
|
12
|
+
|
|
13
|
+
const MAX = 200;
|
|
14
|
+
const FILE = "history.json";
|
|
15
|
+
|
|
16
|
+
function baseDir(dir) {
|
|
17
|
+
return dir || storage.DIR || path.join(os.homedir(), ".kafox");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function filePath(dir) {
|
|
21
|
+
return path.join(baseDir(dir), FILE);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sanitize(list) {
|
|
25
|
+
return (Array.isArray(list) ? list : [])
|
|
26
|
+
.filter((item) => typeof item === "string" && item.trim())
|
|
27
|
+
.map((item) => item.replace(/\r/g, "").trim())
|
|
28
|
+
.slice(-MAX);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function load(dir) {
|
|
32
|
+
try {
|
|
33
|
+
const raw = JSON.parse(fs.readFileSync(filePath(dir), "utf8"));
|
|
34
|
+
const list = Array.isArray(raw) ? raw : raw?.history;
|
|
35
|
+
return sanitize(list);
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function save(lines, dir) {
|
|
42
|
+
const clean = sanitize(lines);
|
|
43
|
+
try {
|
|
44
|
+
const file = filePath(dir);
|
|
45
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
46
|
+
const tmp = `${file}.tmp`;
|
|
47
|
+
fs.writeFileSync(tmp, JSON.stringify(clean), { mode: 0o600 });
|
|
48
|
+
fs.renameSync(tmp, file);
|
|
49
|
+
} catch {
|
|
50
|
+
// Histórico é conveniência: falha de disco não pode derrubar a TUI.
|
|
51
|
+
}
|
|
52
|
+
return clean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Adiciona uma linha, sem repetir a anterior, e devolve a lista atualizada. */
|
|
56
|
+
function push(line, dir) {
|
|
57
|
+
const clean = String(line ?? "").replace(/\r/g, "").trim();
|
|
58
|
+
if (!clean) return load(dir);
|
|
59
|
+
const lines = load(dir);
|
|
60
|
+
if (lines[lines.length - 1] === clean) return lines;
|
|
61
|
+
return save([...lines, clean].slice(-MAX), dir);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { MAX, FILE, filePath, load, save, push };
|
package/src/sessions.js
CHANGED
|
@@ -69,6 +69,29 @@ function titleFrom(messages, fallback = "") {
|
|
|
69
69
|
return text.length > TITLE_MAX ? `${text.slice(0, TITLE_MAX - 1).trimEnd()}…` : text;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Converte a conversa visivel (o que a TUI guarda em `state.messages`) no
|
|
74
|
+
* contexto que vai para o modelo.
|
|
75
|
+
*
|
|
76
|
+
* Sessoes antigas foram salvas sem `context`, mas com a conversa visivel. Sem
|
|
77
|
+
* essa reconstrucao, retomar a conversa mandava so a mensagem nova ao provedor
|
|
78
|
+
* e a IA "esquecia" tudo o que ja tinha sido dito.
|
|
79
|
+
*/
|
|
80
|
+
function contextFromMessages(messages) {
|
|
81
|
+
const context = [];
|
|
82
|
+
for (const message of Array.isArray(messages) ? messages : []) {
|
|
83
|
+
const text = String(message?.text ?? message?.content ?? "").trim();
|
|
84
|
+
if (!text) continue;
|
|
85
|
+
if (message.role === "user" || message.role === "assistant") {
|
|
86
|
+
context.push({ role: message.role, content: text });
|
|
87
|
+
} else if (message.role === "compaction") {
|
|
88
|
+
// O resumo e contexto, nao mensagem de chat.
|
|
89
|
+
context.push({ role: "system", content: text });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return context;
|
|
93
|
+
}
|
|
94
|
+
|
|
72
95
|
function save({ id, cwd, model, theme, allowAll, messages, context, startedAt, title }) {
|
|
73
96
|
if (!id) return null;
|
|
74
97
|
ensureDir();
|
|
@@ -166,6 +189,7 @@ module.exports = {
|
|
|
166
189
|
titleFrom,
|
|
167
190
|
normalizeId,
|
|
168
191
|
dayMonth,
|
|
192
|
+
contextFromMessages,
|
|
169
193
|
save,
|
|
170
194
|
load,
|
|
171
195
|
list,
|
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, "");
|
|
@@ -681,6 +683,10 @@ function initialState() {
|
|
|
681
683
|
return {
|
|
682
684
|
input: "",
|
|
683
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,
|
|
684
690
|
history: [],
|
|
685
691
|
historyIndex: -1,
|
|
686
692
|
messages: [],
|
|
@@ -1332,22 +1338,6 @@ function sidebarLines(state, width) {
|
|
|
1332
1338
|
lines.push(chalk.dim(`${formatTokens(tokens)} tokens · ${messages} msg${messages === 1 ? "" : "s"}`));
|
|
1333
1339
|
lines.push(chalk.dim(state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`));
|
|
1334
1340
|
|
|
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
1341
|
lines.push("");
|
|
1352
1342
|
lines.push(border(state, "MCP"));
|
|
1353
1343
|
if (state.mcp.loading) {
|
|
@@ -1436,30 +1426,87 @@ function modelLine(state, width) {
|
|
|
1436
1426
|
);
|
|
1437
1427
|
}
|
|
1438
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
|
+
*/
|
|
1439
1473
|
function inputBlock(state, width, { hero = false, tight = false } = {}) {
|
|
1440
|
-
const
|
|
1441
|
-
const
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
const
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
const
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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;
|
|
1456
1504
|
|
|
1457
1505
|
return {
|
|
1458
1506
|
lines,
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
footer: tight ? null : modelLine(state, width),
|
|
1507
|
+
footer,
|
|
1508
|
+
cursorRow: inputRow,
|
|
1509
|
+
cursorCol: cursorColInRow,
|
|
1463
1510
|
};
|
|
1464
1511
|
}
|
|
1465
1512
|
|
|
@@ -1747,6 +1794,8 @@ function buildFrame(state, cols, rows, commands = {}) {
|
|
|
1747
1794
|
...menu,
|
|
1748
1795
|
...input.lines,
|
|
1749
1796
|
...(input.footer ? [input.footer] : []),
|
|
1797
|
+
// Modelo + versao logo abaixo da caixa (some so em terminal baixo).
|
|
1798
|
+
...(bare ? [] : [modelLine(state, inner)]),
|
|
1750
1799
|
...status,
|
|
1751
1800
|
...hints,
|
|
1752
1801
|
];
|
|
@@ -1794,10 +1843,16 @@ function buildFrame(state, cols, rows, commands = {}) {
|
|
|
1794
1843
|
});
|
|
1795
1844
|
}
|
|
1796
1845
|
|
|
1797
|
-
const
|
|
1846
|
+
const inputTop = head.length + bodyHeight + queue.length + menu.length;
|
|
1847
|
+
const inputRow = inputTop + input.cursorRow;
|
|
1798
1848
|
const cursorRow = overlay ? 0 : clamp(inputRow + 1, 1, rows);
|
|
1799
1849
|
const cursorCol = overlay ? 0 : clamp(input.cursorCol, 1, cols);
|
|
1800
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
|
+
|
|
1801
1856
|
return {
|
|
1802
1857
|
lines,
|
|
1803
1858
|
cursorRow,
|
|
@@ -1821,6 +1876,10 @@ async function runTUI({
|
|
|
1821
1876
|
const stdin = process.stdin;
|
|
1822
1877
|
const stdout = process.stdout;
|
|
1823
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;
|
|
1824
1883
|
|
|
1825
1884
|
// resume: "pick" abre o seletor de conversas, "last" pega a mais recente e
|
|
1826
1885
|
// qualquer outro valor e o codigo do chat que o usuario fechou no Ctrl+C.
|
|
@@ -1839,7 +1898,12 @@ async function runTUI({
|
|
|
1839
1898
|
state.sessionId = saved.id;
|
|
1840
1899
|
state.sessionStartedAt = saved.startedAt;
|
|
1841
1900
|
state.messages = Array.isArray(saved.messages) ? saved.messages : [];
|
|
1842
|
-
|
|
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);
|
|
1843
1907
|
if (saved.model) state.model = saved.model;
|
|
1844
1908
|
if (saved.theme) state.theme = saved.theme;
|
|
1845
1909
|
state.autoApprove = forcedAllow || Boolean(saved.allowAll);
|
|
@@ -2143,7 +2207,10 @@ async function runTUI({
|
|
|
2143
2207
|
state.sessionId = saved.id;
|
|
2144
2208
|
state.sessionStartedAt = saved.startedAt;
|
|
2145
2209
|
state.messages = Array.isArray(saved.messages) ? saved.messages : [];
|
|
2146
|
-
state.context =
|
|
2210
|
+
state.context =
|
|
2211
|
+
Array.isArray(saved.context) && saved.context.length
|
|
2212
|
+
? saved.context
|
|
2213
|
+
: sessions.contextFromMessages(state.messages);
|
|
2147
2214
|
if (saved.model) state.model = saved.model;
|
|
2148
2215
|
if (saved.theme) state.theme = saved.theme;
|
|
2149
2216
|
state.autoApprove = forcedAllow || Boolean(saved.allowAll);
|
|
@@ -2225,6 +2292,35 @@ async function runTUI({
|
|
|
2225
2292
|
}
|
|
2226
2293
|
}
|
|
2227
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
|
+
|
|
2228
2324
|
async function runCommand(line) {
|
|
2229
2325
|
const parts = line.trim().split(/\s+/);
|
|
2230
2326
|
const name = parts[0];
|
|
@@ -2232,6 +2328,31 @@ async function runTUI({
|
|
|
2232
2328
|
|
|
2233
2329
|
// /models e /model sem argumento abrem o mesmo seletor navegavel.
|
|
2234
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
|
+
}
|
|
2235
2356
|
if (name === "/theme") return openThemePicker();
|
|
2236
2357
|
if (name === "/background" || name === "/bg") {
|
|
2237
2358
|
const value = parts.slice(1).join(" ");
|
|
@@ -2425,7 +2546,7 @@ async function runTUI({
|
|
|
2425
2546
|
function submit() {
|
|
2426
2547
|
const line = state.input.trim();
|
|
2427
2548
|
if (!line) return;
|
|
2428
|
-
state.history.push(line);
|
|
2549
|
+
state.history = promptHistory.push(line);
|
|
2429
2550
|
state.historyIndex = state.history.length;
|
|
2430
2551
|
state.input = "";
|
|
2431
2552
|
state.cursor = 0;
|
|
@@ -2467,7 +2588,27 @@ async function runTUI({
|
|
|
2467
2588
|
state.abort = controller;
|
|
2468
2589
|
state.escArmed = false;
|
|
2469
2590
|
startMessageText(line);
|
|
2470
|
-
|
|
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
|
+
}
|
|
2471
2612
|
state.busy = true;
|
|
2472
2613
|
state.streaming = { text: "", reasoning: "", thinkingStartedAt: 0 };
|
|
2473
2614
|
state.scroll = 0;
|
|
@@ -2602,6 +2743,23 @@ async function runTUI({
|
|
|
2602
2743
|
if (result.messages) state.context = trimHistory(result.messages);
|
|
2603
2744
|
// Reconectou de verdade: limpa o alerta e os erros de rede pendentes.
|
|
2604
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
|
+
});
|
|
2605
2763
|
} else {
|
|
2606
2764
|
// Erro de rede/servidor entra marcado como transitório: some sozinho
|
|
2607
2765
|
// quando a próxima resposta chegar. Erros de verdade (saldo, chave)
|
|
@@ -2736,6 +2894,22 @@ async function runTUI({
|
|
|
2736
2894
|
const cols = Math.max(30, stdout.columns || 80);
|
|
2737
2895
|
const rows = Math.max(14, stdout.rows || 24);
|
|
2738
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
|
+
|
|
2739
2913
|
if (!overlay || !overlay.hit) return;
|
|
2740
2914
|
|
|
2741
2915
|
const { first, count, left, width, kind } = overlay.hit;
|