kafox-cli 0.10.0 → 0.10.2

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
@@ -16,7 +16,7 @@ const os = require("os");
16
16
  const path = require("path");
17
17
  const chalk = require("chalk");
18
18
  const storage = require("./storage");
19
- const { estimateTokens: countContextTokens, compactIfNeeded } = require("./compact");
19
+ const { estimateTokens: countContextTokens, compactIfNeeded, trimHistory } = require("./compact");
20
20
  const { ensureSession, fetchApi } = require("./auth");
21
21
  const { runAgent } = require("./agent");
22
22
  const { THEMES, themeColors, paintLine, paintPanel, borderColor } = require("./theme");
@@ -316,8 +316,26 @@ function tableLines(header, aligns = [], body = [], width = 80, theme = "default
316
316
  return lines;
317
317
  }
318
318
 
319
- /** Converte Markdown simples em linhas prontas para o terminal. */
319
+ // Cache do markdown renderizado por {texto, largura, tema}. Sem isso, cada
320
+ // frame (inclusive a cada tecla) reparseava o markdown de TODAS as mensagens
321
+ // da conversa, o que domina o tempo de render em chats longos.
322
+ const MARKDOWN_CACHE = new Map();
323
+ const MARKDOWN_CACHE_MAX = 400;
324
+ /** Converte Markdown simples em linhas prontas para o terminal (com cache). */
320
325
  function markdownLines(text, width, theme = "default") {
326
+ const key = `${width}\u0000${theme}\u0000${text}`;
327
+ const hit = MARKDOWN_CACHE.get(key);
328
+ if (hit) return hit;
329
+ const out = markdownLinesUncached(text, width, theme);
330
+ if (MARKDOWN_CACHE.size >= MARKDOWN_CACHE_MAX) {
331
+ // Despeja o mais antigo (ordem de insercao do Map).
332
+ MARKDOWN_CACHE.delete(MARKDOWN_CACHE.keys().next().value);
333
+ }
334
+ MARKDOWN_CACHE.set(key, out);
335
+ return out;
336
+ }
337
+
338
+ function markdownLinesUncached(text, width, theme = "default") {
321
339
  const accent = accentFor(theme);
322
340
  const out = [];
323
341
  const rows = String(text).replace(/\r/g, "").split("\n");
@@ -481,6 +499,8 @@ const CONTROL_KEYS = {
481
499
  12: { name: "l", ctrl: true },
482
500
  13: { name: "return" },
483
501
  21: { name: "u", ctrl: true },
502
+ // Ctrl+R: atualiza saldo da conta e dos provedores externos agora.
503
+ 18: { name: "r", ctrl: true },
484
504
  // Ctrl+Q: desiste das mensagens que estao na fila (nao cancela a atual).
485
505
  17: { name: "q", ctrl: true },
486
506
  23: { name: "w", ctrl: true },
@@ -684,6 +704,7 @@ function initialState() {
684
704
  modal: null,
685
705
  models: [],
686
706
  balance: null,
707
+ providerBalances: [],
687
708
  tokens: 0,
688
709
  files: [],
689
710
  mcp: { loading: false, statuses: [], tools: 0 },
@@ -754,6 +775,8 @@ function heroLines(state, width, available) {
754
775
  " envia " +
755
776
  chalk.dim("/") +
756
777
  " menu " +
778
+ chalk.dim("ctrl+r") +
779
+ " saldo " +
757
780
  chalk.dim("ctrl+c") +
758
781
  " sai",
759
782
  width
@@ -1082,40 +1105,81 @@ function compactionLines(message, width, state) {
1082
1105
  return lines;
1083
1106
  }
1084
1107
 
1085
- function messageLines(state, width) {
1108
+ /**
1109
+ * Virtualizacao de mensagens.
1110
+ *
1111
+ * Sem isso, cada frame (inclusive a cada tecla) reconstruia linha a linha TODO
1112
+ * o historico da conversa; em chats longos isso domina o tempo de render e
1113
+ * trava a digitacao. Aqui cada mensagem tem suas linhas memorizadas (chaveadas
1114
+ * por texto/largura/tema + o estado que muda o desenho) e so as mensagens que
1115
+ * caem no viewport sao realmente remontadas.
1116
+ */
1117
+ const MESSAGE_CACHE = new Map();
1118
+ const MESSAGE_CACHE_MAX = 600;
1119
+
1120
+ /** Chave de cache de uma mensagem: tudo que muda o desenho dela. */
1121
+ function messageCacheKey(message, width, state) {
1122
+ const streaming = state.streaming;
1123
+ const extra =
1124
+ message.role === "tool"
1125
+ ? `${message.status || ""}\u0001${message.duration || ""}\u0001${state.toolCompact ? 1 : 0}`
1126
+ : message.role === "thinking"
1127
+ ? `${message.done ? 1 : 0}`
1128
+ : "";
1129
+ return `${width}\u0000${state.theme}\u0000${message.role}\u0000${extra}\u0000${message.text}`;
1130
+ }
1131
+
1132
+ /** Linhas de UMA mensagem (com cache). */
1133
+ function messageBlock(message, width, state) {
1134
+ const key = messageCacheKey(message, width, state);
1135
+ const hit = MESSAGE_CACHE.get(key);
1136
+ if (hit) return hit;
1137
+ const out = messageBlockUncached(message, width, state);
1138
+ if (MESSAGE_CACHE.size >= MESSAGE_CACHE_MAX) {
1139
+ MESSAGE_CACHE.delete(MESSAGE_CACHE.keys().next().value);
1140
+ }
1141
+ MESSAGE_CACHE.set(key, out);
1142
+ return out;
1143
+ }
1144
+
1145
+ function messageBlockUncached(message, width, state) {
1086
1146
  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
- }
1147
+ if (message.role === "user") {
1148
+ lines.push("");
1149
+ const body = wrap(message.text, width - 2);
1150
+ lines.push(indent(2) + chalk.cyan("▌ ") + chalk.bold(body[0] || ""));
1151
+ for (let index = 1; index < body.length; index += 1) {
1152
+ lines.push(indent(4) + chalk.bold(body[index]));
1153
+ }
1154
+ } else if (message.role === "compaction") {
1155
+ lines.push(...compactionLines(message, width, state));
1156
+ } else if (message.role === "assistant") {
1157
+ lines.push("");
1158
+ lines.push(...markdownLines(message.text, width - 2, state.theme));
1159
+ const meta = [];
1160
+ if (message.interrupted) meta.push("interrompido");
1161
+ if (message.ms) meta.push(`${(message.ms / 1000).toFixed(1)}s`);
1162
+ if (meta.length) lines.push("");
1163
+ if (meta.length) lines.push(indent(2) + chalk.dim(meta.join(" · ")));
1164
+ } else if (message.role === "thinking") {
1165
+ lines.push(...thinkingLines(message, width));
1166
+ } else if (message.role === "tool") {
1167
+ lines.push(...toolCardLines(message, width, state));
1168
+ } else {
1169
+ lines.push("");
1170
+ const color = message.error ? chalk.red : message.interrupted ? chalk.yellow : chalk.dim;
1171
+ const mark = message.interrupted ? "⏹" : "!";
1172
+ for (const line of wrap(message.text, width - 4)) lines.push(color(indent(2) + mark + " " + line));
1116
1173
  }
1174
+ return lines;
1175
+ }
1117
1176
 
1118
- // Resposta chegando token a token (e o "thinking" do modelo, se houver).
1177
+ /**
1178
+ * "Cauda" da conversa: bloco de streaming, indicador de "trabalhando" etc.
1179
+ * Fica separado do historico porque muda a cada token e nao vale cachear.
1180
+ */
1181
+ function messageTailLines(state, width) {
1182
+ const lines = [];
1119
1183
  const streaming = state.streaming;
1120
1184
  if (streaming && (streaming.reasoning || streaming.text)) {
1121
1185
  if (streaming.reasoning) {
@@ -1134,7 +1198,6 @@ function messageLines(state, width) {
1134
1198
  lines.push(...markdownLines(streaming.text, width - 2, state.theme));
1135
1199
  }
1136
1200
  }
1137
-
1138
1201
  if (state.busy && !streaming?.text) {
1139
1202
  lines.push("");
1140
1203
  lines.push(indent(2) + chalk.dim("trabalhando" + ".".repeat(state.frame % 4)));
@@ -1142,6 +1205,47 @@ function messageLines(state, width) {
1142
1205
  return lines;
1143
1206
  }
1144
1207
 
1208
+ /**
1209
+ * Monta o corpo da conversa gastando o minimo: processa mensagens de tras pra
1210
+ * frente ate cobrir o viewport pedido (ou ate acabar o historico). Fora do
1211
+ * viewport nem tocamos no markdown. Devolve o array ja "padded" no topo para
1212
+ * preencher a altura do terminal.
1213
+ */
1214
+ function messageLines(state, width, budget = Infinity) {
1215
+ const need = budget === Infinity ? Infinity : Math.max(0, budget);
1216
+ const blocks = [];
1217
+ let total = 0;
1218
+
1219
+ const tail = messageTailLines(state, width);
1220
+ if (tail.length) {
1221
+ blocks.push({ start: 0, lines: tail });
1222
+ total += tail.length;
1223
+ }
1224
+
1225
+ for (let index = state.messages.length - 1; index >= 0; index -= 1) {
1226
+ const message = state.messages[index];
1227
+ const lines = messageBlock(message, width, state);
1228
+ blocks.push({ lines });
1229
+ total += lines.length;
1230
+ // Ja cobrimos o viewport (com uma folga para o padding do topo)? Paramos.
1231
+ if (total >= need + 4) break;
1232
+ }
1233
+
1234
+ const lines = [];
1235
+ for (let index = blocks.length - 1; index >= 0; index -= 1) {
1236
+ lines.push(...blocks[index].lines);
1237
+ }
1238
+ return lines;
1239
+ }
1240
+
1241
+ function messageLinesFull(state, width) {
1242
+ const lines = [];
1243
+ for (const message of state.messages) lines.push(...messageBlock(message, width, state));
1244
+ lines.push(...messageTailLines(state, width));
1245
+ return lines;
1246
+ }
1247
+
1248
+
1145
1249
  function filteredCommands(state, commands) {
1146
1250
  if (state.menuDismissed) return [];
1147
1251
  if (!state.input.startsWith("/") || state.input.includes(" ")) return [];
@@ -1228,6 +1332,22 @@ function sidebarLines(state, width) {
1228
1332
  lines.push(chalk.dim(`${formatTokens(tokens)} tokens · ${messages} msg${messages === 1 ? "" : "s"}`));
1229
1333
  lines.push(chalk.dim(state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`));
1230
1334
 
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
+
1231
1351
  lines.push("");
1232
1352
  lines.push(border(state, "MCP"));
1233
1353
  if (state.mcp.loading) {
@@ -1408,6 +1528,29 @@ function box(state, { title, body, hint, width }) {
1408
1528
 
1409
1529
  function approvalBody(state, width) {
1410
1530
  const { name, args, change } = state.approval;
1531
+
1532
+ // Pedido de pareamento do celular: mesmo widget de opcoes, conteudo proprio.
1533
+ if (name === "__pair__") {
1534
+ const body = [];
1535
+ const who = change?.device || "celular";
1536
+ body.push(chalk.bold("Um celular quer entrar nesta conversa."));
1537
+ body.push("");
1538
+ if (change?.title) body.push(truncate("conversa: " + change.title, width - 4));
1539
+ if (change?.base) body.push(chalk.dim(truncate(change.base, width - 4)));
1540
+ body.push(chalk.dim(truncate("dispositivo: " + who, width - 4)));
1541
+ body.push("");
1542
+ const optionsStart = body.length;
1543
+ const opts = state.approval.options || APPROVAL_OPTIONS;
1544
+ for (const [index, option] of opts.entries()) {
1545
+ const selected = index === state.approval.index;
1546
+ const line = padRight(`${selected ? "▸ " : " "}${option.label}`, width - 6);
1547
+ if (selected) body.push(chalk.inverse(chalk.bold(line)));
1548
+ else if (option.id === "deny") body.push(chalk.dim(line));
1549
+ else body.push(line);
1550
+ }
1551
+ return { title: "Pareamento do celular", body, optionsStart };
1552
+ }
1553
+
1411
1554
  const meta = toolMeta(name);
1412
1555
  const body = [];
1413
1556
 
@@ -1613,7 +1756,7 @@ function buildFrame(state, cols, rows, commands = {}) {
1613
1756
  const bodyHeight = Math.max(0, rows - head.length - chrome.length);
1614
1757
  state.pageSize = Math.max(1, bodyHeight);
1615
1758
 
1616
- let body = hero ? [] : messageLines(state, inner);
1759
+ let body = hero ? [] : messageLines(state, inner, bodyHeight + state.scroll);
1617
1760
  const maxScroll = Math.max(0, body.length - bodyHeight);
1618
1761
  state.scroll = clamp(state.scroll, 0, maxScroll);
1619
1762
  const end = body.length - state.scroll;
@@ -1732,19 +1875,35 @@ async function runTUI({
1732
1875
  renderTimer.unref?.();
1733
1876
  }
1734
1877
 
1878
+ // Digitar/apagar dispara uma tecla por evento: render() sincrono a cada tecla
1879
+ // reconstroi a conversa inteira e trava o input em chats longos. Este
1880
+ // agendador curto (~60fps) mantem a resposta visual imediata e coalesce
1881
+ // rajadas (segurar backspace/colar) num unico frame.
1882
+ let inputTimer = null;
1883
+ function scheduleInputRender() {
1884
+ if (inputTimer || closed) return;
1885
+ inputTimer = setTimeout(() => {
1886
+ inputTimer = null;
1887
+ render();
1888
+ }, 16);
1889
+ inputTimer.unref?.();
1890
+ }
1891
+
1735
1892
  const mouseMode = () => (state.mouse ? "\x1b[?1000h\x1b[?1006h" : "\x1b[?1000l\x1b[?1006l");
1736
1893
  const enterScreen = () =>
1737
1894
  write(`\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J${mouseMode()}\x1b[?2004h`);
1738
1895
  const leaveScreen = () =>
1739
1896
  write("\x1b[?2004l\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l");
1740
1897
 
1741
- async function refreshAccount() {
1898
+ async function refreshAccount({ fresh = false } = {}) {
1742
1899
  const token = await ensureSession();
1743
1900
  if (!token) return;
1744
- const response = await fetchApi("/api/bootstrap").catch(() => null);
1901
+ const response = await fetchApi(`/api/bootstrap${fresh ? "?fresh=1" : ""}`).catch(() => null);
1745
1902
  if (!response || !response.ok) return;
1746
1903
  const data = await response.json().catch(() => null);
1747
1904
  if (data?.user) state.balance = data.user.balance_micros;
1905
+ // Saldo (credits) dos provedores externos, ex.: unlid/uncensored.
1906
+ state.providerBalances = Array.isArray(data?.provider_balances) ? data.provider_balances : [];
1748
1907
 
1749
1908
  if (!state.model) {
1750
1909
  const pricing = await fetchApi("/api/public/pricing")
@@ -1754,6 +1913,19 @@ async function runTUI({
1754
1913
  }
1755
1914
  }
1756
1915
 
1916
+ /** Atalho/botão de atualizar: re-busca saldo da conta e dos provedores. */
1917
+ async function refreshBalance() {
1918
+ if (state.balanceLoading) return;
1919
+ state.balanceLoading = true;
1920
+ render();
1921
+ try {
1922
+ await refreshAccount({ fresh: true });
1923
+ } finally {
1924
+ state.balanceLoading = false;
1925
+ render();
1926
+ }
1927
+ }
1928
+
1757
1929
  function applyMcp() {
1758
1930
  state.mcp.statuses = mcp.statuses();
1759
1931
  state.mcp.tools = state.mcp.statuses.reduce((total, item) => total + item.tools, 0);
@@ -2420,14 +2592,14 @@ async function runTUI({
2420
2592
  text: "Interrompido por você (Esc).",
2421
2593
  interrupted: true,
2422
2594
  });
2423
- if (result.messages) state.context = result.messages;
2595
+ if (result.messages) state.context = trimHistory(result.messages);
2424
2596
  } else if (result.ok) {
2425
2597
  const text = String(result.text || "").trim();
2426
2598
  // O stream ja foi commitado em finishStreaming(): so empurra aqui se for
2427
2599
  // texto novo (ou se nao houve streaming, ex.: resposta sem onText).
2428
2600
  if (text) commitFinalText(state, text, result.ms);
2429
2601
  state.streamedText = "";
2430
- if (result.messages) state.context = result.messages;
2602
+ if (result.messages) state.context = trimHistory(result.messages);
2431
2603
  // Reconectou de verdade: limpa o alerta e os erros de rede pendentes.
2432
2604
  clearRetry();
2433
2605
  } else {
@@ -2510,10 +2682,15 @@ async function runTUI({
2510
2682
  const pending = state.approval;
2511
2683
  if (!pending) return;
2512
2684
  state.approval = null;
2513
- if (decision === "always") state.autoApprove = true;
2514
- if (decision === "always") persist();
2685
+ // O pedido de pareamento NAO mexe no autoApprove de ferramentas: "lembrar"
2686
+ // ali significa confiar naquele navegador, tratado por quem criou o pedido.
2687
+ const isPair = pending.name === "__pair__";
2688
+ if (decision === "always" && !isPair) state.autoApprove = true;
2689
+ if (decision === "always" && !isPair) persist();
2515
2690
  render();
2516
- pending.resolve(decision !== "deny");
2691
+ // Pareamento precisa do texto da decisao (once/always/deny); ferramentas
2692
+ // so querem o booleano "aprovado?".
2693
+ pending.resolve(isPair ? decision : decision !== "deny");
2517
2694
  }
2518
2695
 
2519
2696
  // Com captura de mouse ligada o terminal entrega a roda para a TUI, mas em
@@ -2589,11 +2766,12 @@ async function runTUI({
2589
2766
  state.cursor = cursor == null ? value.length : clamp(cursor, 0, value.length);
2590
2767
  state.menuIndex = 0;
2591
2768
  state.menuDismissed = false;
2592
- render();
2769
+ scheduleInputRender();
2593
2770
  }
2594
2771
 
2595
2772
  function moveApproval(delta) {
2596
- const count = APPROVAL_OPTIONS.length;
2773
+ const opts = state.approval.options || APPROVAL_OPTIONS;
2774
+ const count = opts.length;
2597
2775
  state.approval.index = (state.approval.index + delta + count) % count;
2598
2776
  render();
2599
2777
  }
@@ -2610,10 +2788,11 @@ async function runTUI({
2610
2788
 
2611
2789
  // Aprovacao de ferramenta: navega e confirma.
2612
2790
  if (state.approval) {
2791
+ const opts = state.approval.options || APPROVAL_OPTIONS;
2613
2792
  if (key.name === "up" || key.name === "down" || key.name === "tab") {
2614
2793
  return moveApproval(key.name === "up" ? -1 : 1);
2615
2794
  }
2616
- if (key.name === "return") return settleApproval(APPROVAL_OPTIONS[state.approval.index].id);
2795
+ if (key.name === "return") return settleApproval(opts[state.approval.index].id);
2617
2796
  if (key.name === "escape") return settleApproval("deny");
2618
2797
  if (key.name === "a") return settleApproval("always");
2619
2798
  if (key.name === "y") return settleApproval("once");
@@ -2652,6 +2831,7 @@ async function runTUI({
2652
2831
 
2653
2832
  if (key.name === "f2") return toggleMouse();
2654
2833
  if (key.ctrl && key.name === "y") return copyLastAnswer();
2834
+ if (key.ctrl && key.name === "r") return refreshBalance();
2655
2835
 
2656
2836
  // Modal de texto: qualquer tecla fecha — e se for texto, ele vai para o
2657
2837
  // input em vez de ser engolido (quem digita nao perde o que escreveu).