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/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
- /** Converte Markdown simples em linhas prontas para o terminal. */
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
- function messageLines(state, width) {
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
- for (const message of state.messages) {
1088
- if (message.role === "user") {
1089
- lines.push("");
1090
- const body = wrap(message.text, width - 2);
1091
- lines.push(indent(2) + chalk.cyan("▌ ") + chalk.bold(body[0] || ""));
1092
- for (let index = 1; index < body.length; index += 1) {
1093
- lines.push(indent(4) + chalk.bold(body[index]));
1094
- }
1095
- } else if (message.role === "compaction") {
1096
- lines.push(...compactionLines(message, width, state));
1097
- } else if (message.role === "assistant") {
1098
- lines.push("");
1099
- lines.push(...markdownLines(message.text, width - 2, state.theme));
1100
- // Só latência: a rota usada por trás é da Kafox, não do cliente.
1101
- const meta = [];
1102
- if (message.interrupted) meta.push("interrompido");
1103
- if (message.ms) meta.push(`${(message.ms / 1000).toFixed(1)}s`);
1104
- if (meta.length) lines.push("");
1105
- if (meta.length) lines.push(indent(2) + chalk.dim(meta.join(" · ")));
1106
- } else if (message.role === "thinking") {
1107
- lines.push(...thinkingLines(message, width));
1108
- } else if (message.role === "tool") {
1109
- lines.push(...toolCardLines(message, width, state));
1110
- } else {
1111
- lines.push("");
1112
- const color = message.error ? chalk.red : message.interrupted ? chalk.yellow : chalk.dim;
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
- // Resposta chegando token a token (e o "thinking" do modelo, se houver).
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 available = width - 4;
1321
- const start =
1322
- state.input.length > available
1323
- ? clamp(state.cursor - (available - 2), 0, Math.max(0, state.input.length - available))
1324
- : 0;
1325
- const shown = state.input.slice(start, start + available);
1326
-
1327
- const prompt = chalk.cyan("▌ ");
1328
- // A caixa fica sempre limpa: o "trabalhando" ja aparece na conversa, acima.
1329
- const body = state.input ? prompt + shown : prompt;
1330
-
1331
- const lines = [
1332
- rule(state, width),
1333
- paintPanel(state.theme, padRight(body, width)),
1334
- rule(state, width),
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
- cursorRow: 1,
1340
- cursorCol: 4 + (state.cursor - start),
1341
- // O modelo fica fora da caixa de texto, logo abaixo dela.
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 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;
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
- 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);
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("/api/bootstrap").catch(() => null);
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 = 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);
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
- 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
+ }
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
- render();
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).