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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Kafox CLI: TUI com streaming, thinking, edit com diff, agent tools (files/shell), MCP e temas",
5
5
  "repository": {
6
6
  "type": "git",
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 { ask } = require("./gateway");
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
- if (!history.length) {
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 = history.length;
199
- const last = history.slice(-6);
200
- history = [
201
- {
202
- role: "system",
203
- content: `(context compacted: ${turns} messages summarized)`,
204
- at: new Date().toISOString(),
205
- },
206
- ...last,
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
- rl.prompt();
274
- rl.on("line", async (raw) => {
368
+ async function handleLine(raw) {
275
369
  const line = raw.trim();
276
- if (!line) return rl.prompt();
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
- await ask(line, { model: modelOverride || storage.getConfig().model });
283
- pushHistory("assistant", "(answer above)");
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
- console.log(chalk.dim("\nSee you."));
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
+ };
@@ -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 available = width - 4;
1441
- const start =
1442
- state.input.length > available
1443
- ? clamp(state.cursor - (available - 2), 0, Math.max(0, state.input.length - available))
1444
- : 0;
1445
- const shown = state.input.slice(start, start + available);
1446
-
1447
- const prompt = chalk.cyan("▌ ");
1448
- // A caixa fica sempre limpa: o "trabalhando" ja aparece na conversa, acima.
1449
- const body = state.input ? prompt + shown : prompt;
1450
-
1451
- const lines = [
1452
- rule(state, width),
1453
- paintPanel(state.theme, padRight(body, width)),
1454
- rule(state, width),
1455
- ];
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
- cursorRow: 1,
1460
- cursorCol: 4 + (state.cursor - start),
1461
- // O modelo fica fora da caixa de texto, logo abaixo dela.
1462
- footer: tight ? null : modelLine(state, width),
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 inputRow = head.length + bodyHeight + queue.length + menu.length + input.cursorRow;
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
- state.context = Array.isArray(saved.context) ? saved.context : [];
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 = Array.isArray(saved.context) ? saved.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
- state.context.push({ role: "user", content: line });
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;