kafox-cli 0.10.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
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/auth.js CHANGED
@@ -14,8 +14,10 @@ async function fetchApi(pathname, options = {}) {
14
14
  return await fetch(url, {
15
15
  ...options,
16
16
  headers: { ...storage.getAuthHeaders(options.headers), ...options.headers },
17
- // timeout por tentativa: nao deixa um host travado segurar o login.
18
- signal: AbortSignal.timeout ? AbortSignal.timeout(12000) : undefined,
17
+ // Timeout por tentativa: nao deixa um host travado segurar o login.
18
+ // Mas se o caller passou o proprio signal (ex.: o stream do chat, que
19
+ // pode durar minutos), respeita ele em vez de matar em 12s.
20
+ signal: options.signal || (AbortSignal.timeout ? AbortSignal.timeout(12000) : undefined),
19
21
  });
20
22
  } catch (error) {
21
23
  lastError = error;
package/src/gateway.js CHANGED
@@ -117,7 +117,9 @@ function wait(ms, signal) {
117
117
  resolve(value);
118
118
  };
119
119
  const timer = setTimeout(() => done(true), ms);
120
- timer.unref?.();
120
+ // Sem unref(): o backoff precisa manter o event loop vivo. Se este for o
121
+ // unico timer pendente e estiver unref'd, o Node dorme e a CLI so acorda
122
+ // quando o usuario digita algo — o retry ficaria "travado" ate um input.
121
123
  function onAbort() {
122
124
  done(false);
123
125
  }
@@ -139,7 +141,7 @@ function errorFrom(data, status) {
139
141
  * Le um stream SSE de chat completions (OpenAI-compatible) e chama os
140
142
  * callbacks a cada delta. Devolve o resultado final.
141
143
  */
142
- async function readStream({ response, started, onDelta, onReasoning }) {
144
+ async function readStream({ response, started, onDelta, onReasoning, skipPrefix = "", signal } = {}) {
143
145
  const decoder = new TextDecoder();
144
146
  let buffer = "";
145
147
  let text = "";
@@ -147,6 +149,73 @@ async function readStream({ response, started, onDelta, onReasoning }) {
147
149
  let usage = null;
148
150
  const toolCalls = [];
149
151
 
152
+ // Watchdog de inatividade: o provedor as vezes morre sem FIN e o `for await`
153
+ // do body fica pendurado pra sempre (o event loop dorme e a CLI so acorda no
154
+ // proximo input). Se nenhum byte chega por IDLE_MS, aborta localmente e marca
155
+ // como retryable — o chat() reconecta por continuacao.
156
+ const IDLE_MS = Number(process.env.KAFOX_STREAM_IDLE_MS) || 90_000;
157
+ const idle = new AbortController();
158
+ let idleTimer = null;
159
+ let idleFired = false;
160
+ const clearIdle = () => {
161
+ if (idleTimer) clearTimeout(idleTimer);
162
+ idleTimer = null;
163
+ };
164
+ // Quando o watchdog dispara, cancelamos o reader do body: isso desbloqueia o
165
+ // `read()` pendurado (o signal do fetch sozinho nao interrompe um body ja
166
+ // iniciado se a conexao morreu sem FIN).
167
+ let reader = null;
168
+ const onIdleAbort = () => {
169
+ idleFired = true;
170
+ reader?.cancel?.().catch(() => {});
171
+ };
172
+ idle.signal.addEventListener("abort", onIdleAbort, { once: true });
173
+ const bumpIdle = () => {
174
+ if (idleTimer) clearTimeout(idleTimer);
175
+ idleTimer = setTimeout(() => idle.abort(new Error("stream idle timeout")), IDLE_MS);
176
+ };
177
+ bumpIdle();
178
+ // Continuacao: o provedor costuma repetir o inicio ja recebido antes de
179
+ // seguir. Segura a emissao ate passar do prefixo para nao duplicar na tela.
180
+ let toSkip = skipPrefix;
181
+ // Texto efetivamente entregue ao onDelta nesta tentativa (sem o prefixo).
182
+ let emitted = "";
183
+
184
+ const emitContent = (chunk) => {
185
+ if (toSkip) {
186
+ if (chunk.length <= toSkip.length) {
187
+ // O trecho novo ainda esta dentro do prefixo repetido: confere se bate.
188
+ if (toSkip.startsWith(chunk)) {
189
+ toSkip = toSkip.slice(chunk.length);
190
+ return;
191
+ }
192
+ // Divergiu do prefixo: o modelo recomecou; emite tudo o que veio.
193
+ toSkip = "";
194
+ emitted += chunk;
195
+ onDelta?.(chunk);
196
+ return;
197
+ }
198
+ // Passou do prefixo: descarta a parte repetida e emite o resto.
199
+ const overlap = chunk.slice(0, toSkip.length);
200
+ if (overlap === toSkip || chunk.startsWith(toSkip)) {
201
+ const rest = chunk.slice(toSkip.length);
202
+ toSkip = "";
203
+ if (rest) {
204
+ emitted += rest;
205
+ onDelta?.(rest);
206
+ }
207
+ return;
208
+ }
209
+ // Nao bateu: recomecou, emite tudo.
210
+ toSkip = "";
211
+ emitted += chunk;
212
+ onDelta?.(chunk);
213
+ return;
214
+ }
215
+ emitted += chunk;
216
+ onDelta?.(chunk);
217
+ };
218
+
150
219
  const handleLine = (line) => {
151
220
  if (!line.startsWith("data:")) return null;
152
221
  const data = line.slice(5).trim();
@@ -172,48 +241,81 @@ async function readStream({ response, started, onDelta, onReasoning }) {
172
241
  }
173
242
  if (delta.content) {
174
243
  text += delta.content;
175
- onDelta?.(delta.content);
244
+ emitContent(delta.content);
176
245
  }
177
246
  if (Array.isArray(delta.tool_calls)) mergeToolCalls(toolCalls, delta.tool_calls);
178
247
  return null;
179
248
  };
180
249
 
181
250
  try {
182
- for await (const chunk of response.body) {
183
- buffer += decoder.decode(chunk, { stream: true });
251
+ reader = response.body.getReader();
252
+ while (true) {
253
+ const { done, value } = await reader.read();
254
+ if (done) break;
255
+ bumpIdle();
256
+ buffer += decoder.decode(value, { stream: true });
184
257
  let index = buffer.indexOf("\n");
185
258
  while (index >= 0) {
186
259
  const line = buffer.slice(0, index).replace(/\r$/, "");
187
260
  buffer = buffer.slice(index + 1);
188
261
  const failure = handleLine(line);
189
- if (failure) return { ok: false, error: failure.error, retryable: failure.retryable };
262
+ if (failure) { clearIdle(); return { ok: false, error: failure.error, retryable: failure.retryable }; }
190
263
  index = buffer.indexOf("\n");
191
264
  }
192
265
  }
193
266
  if (buffer.trim()) handleLine(buffer.trim());
267
+ // O cancel() do watchdog pode encerrar o reader como "done": se foi o
268
+ // watchdog que disparou, isso e falha e deve reconectar.
269
+ if (idleFired) {
270
+ clearIdle();
271
+ return {
272
+ ok: false,
273
+ error: `Stream interrompido: sem resposta do provedor por ${Math.round(IDLE_MS / 1000)}s`,
274
+ text,
275
+ emitted,
276
+ retryable: true,
277
+ };
278
+ }
194
279
  } catch (error) {
195
- if (error?.name === "AbortError") {
196
- return { ok: true, text, reasoning, toolCalls, ms: Date.now() - started, aborted: true };
280
+ clearIdle();
281
+ if (error?.name === "AbortError" || idleFired) {
282
+ // Abort com o signal do usuario ativo = ele apertou Esc: encerra.
283
+ if (signal?.aborted && !idleFired) {
284
+ return { ok: true, text, emitted, reasoning, toolCalls, ms: Date.now() - started, aborted: true };
285
+ }
286
+ // Sem abort do usuario, falha de conexao ou watchdog de inatividade:
287
+ // marca como retryable para o chat() reconectar de onde parou.
288
+ return {
289
+ ok: false,
290
+ error: idleFired
291
+ ? `Stream interrompido: sem resposta do provedor por ${Math.round(IDLE_MS / 1000)}s`
292
+ : `Stream interrompido: ${error.message || "conexao caiu"}`,
293
+ text,
294
+ emitted,
295
+ retryable: true,
296
+ };
197
297
  }
198
298
  return {
199
299
  ok: false,
200
300
  error: `Stream interrompido: ${error.message}`,
201
301
  text,
302
+ emitted,
202
303
  retryable: retryableMessage(error.message),
203
304
  };
204
305
  }
306
+ clearIdle();
205
307
 
206
- return { ok: true, text, reasoning, toolCalls: toolCalls.filter(Boolean), usage, ms: Date.now() - started };
308
+ return { ok: true, text, emitted, reasoning, toolCalls: toolCalls.filter(Boolean), usage, ms: Date.now() - started };
207
309
  }
208
310
 
209
311
  /**
210
312
  * Uma tentativa no provedor kafox (gateway proprio): login/saldo/chave da conta
211
313
  * e POST em `/api/chat`.
212
314
  */
213
- async function attemptChat({ messages, model, tools, onDelta, onReasoning, signal } = {}) {
315
+ async function attemptChat({ messages, model, tools, onDelta, onReasoning, signal, skipPrefix } = {}) {
214
316
  const socket = providerForModel(model);
215
317
  if (socket.provider.kind === "openai") {
216
- return attemptOpenAI({ messages, model: socket.model, provider: socket.provider, tools, onDelta, onReasoning, signal });
318
+ return attemptOpenAI({ messages, model: socket.model, provider: socket.provider, tools, onDelta, onReasoning, signal, skipPrefix });
217
319
  }
218
320
 
219
321
  const ready = await ensureReady({ model });
@@ -235,11 +337,11 @@ async function attemptChat({ messages, model, tools, onDelta, onReasoning, signa
235
337
  signal,
236
338
  });
237
339
  } catch (error) {
238
- if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
340
+ if (error?.name === "AbortError" && signal?.aborted) return { ok: false, code: "aborted", error: "Cancelado." };
239
341
  return {
240
342
  ok: false,
241
- error: `Falha de rede: ${error.message}`,
242
- retryable: retryableMessage(error.message),
343
+ error: error?.name === "AbortError" ? `Falha de rede: ${error.message || "conexao caiu"}` : `Falha de rede: ${error.message}`,
344
+ retryable: error?.name === "AbortError" ? true : retryableMessage(error.message),
243
345
  keyCreated: ready.keyCreated,
244
346
  };
245
347
  }
@@ -254,7 +356,7 @@ async function attemptChat({ messages, model, tools, onDelta, onReasoning, signa
254
356
  };
255
357
  }
256
358
 
257
- const result = await readStream({ response, started, onDelta, onReasoning });
359
+ const result = await readStream({ response, started, onDelta, onReasoning, skipPrefix, signal });
258
360
  return { ...result, model: ready.model, keyCreated: ready.keyCreated };
259
361
  }
260
362
 
@@ -263,7 +365,7 @@ async function attemptChat({ messages, model, tools, onDelta, onReasoning, signa
263
365
  * `{baseUrl}/chat/completions` com a chave do usuario. Sem login/saldo da conta
264
366
  * kafox; o custo e cobrado pelo proprio provedor.
265
367
  */
266
- async function attemptOpenAI({ messages, model, provider, tools, onDelta, onReasoning, signal } = {}) {
368
+ async function attemptOpenAI({ messages, model, provider, tools, onDelta, onReasoning, signal, skipPrefix } = {}) {
267
369
  const apiKey = storage.getProviderKey(provider.id);
268
370
  if (!apiKey) {
269
371
  return {
@@ -292,11 +394,11 @@ async function attemptOpenAI({ messages, model, provider, tools, onDelta, onReas
292
394
  signal,
293
395
  });
294
396
  } catch (error) {
295
- if (error?.name === "AbortError") return { ok: false, code: "aborted", error: "Cancelado." };
397
+ if (error?.name === "AbortError" && signal?.aborted) return { ok: false, code: "aborted", error: "Cancelado." };
296
398
  return {
297
399
  ok: false,
298
- error: `Falha de rede: ${error.message}`,
299
- retryable: retryableMessage(error.message),
400
+ error: error?.name === "AbortError" ? `Falha de rede: ${error.message || "conexao caiu"}` : `Falha de rede: ${error.message}`,
401
+ retryable: error?.name === "AbortError" ? true : retryableMessage(error.message),
300
402
  };
301
403
  }
302
404
 
@@ -309,42 +411,93 @@ async function attemptOpenAI({ messages, model, provider, tools, onDelta, onReas
309
411
  };
310
412
  }
311
413
 
312
- const result = await readStream({ response, started, onDelta, onReasoning });
414
+ const result = await readStream({ response, started, onDelta, onReasoning, skipPrefix, signal });
313
415
  return { ...result, model, provider: provider.id };
314
416
  }
315
417
 
316
418
  /**
317
419
  * Chat completions com stream (SSE) via /api/chat, com retry em falha de rede.
318
420
  * onRetry avisa a TUI (tentativa, espera) para ela mostrar a contagem.
421
+ *
422
+ * Queda no meio da resposta (o pior caso: ja saiu texto e o stream morre) e
423
+ * reconectada por continuacao: reenvia a conversa com o trecho parcial como
424
+ * assistant e pede para seguir de onde parou. O `skipPrefix` faz o provedor
425
+ * repetir o inicio; o gateway descarta a repeticao, entao a TUI so ve o que
426
+ * e novo. Se a continuacao falhar, devolve o parcial para nao perder nada.
319
427
  */
320
428
  async function chat({ messages, model, tools, onDelta, onReasoning, onRetry, signal, retries = RETRY_MAX } = {}) {
321
429
  const maxAttempts = Math.max(1, Number(retries) || RETRY_MAX);
322
430
  let attempt = 0;
323
431
  let last = null;
324
432
  let clearedKey = false;
433
+ // Acumulado entre tentativas (modo continuacao).
434
+ let carried = "";
435
+ let working = messages;
436
+ let skipPrefix = "";
325
437
 
326
438
  while (attempt < maxAttempts) {
327
439
  attempt += 1;
328
- const result = await attemptChat({ messages, model, tools, onDelta, onReasoning, signal });
440
+ const result = await attemptChat({
441
+ messages: working,
442
+ model,
443
+ tools,
444
+ onDelta,
445
+ onReasoning,
446
+ signal,
447
+ skipPrefix,
448
+ });
329
449
 
330
- if (result.ok || result.code === "aborted") return result;
450
+ if (result.ok || result.code === "aborted") {
451
+ if (carried && result.ok) {
452
+ // Junta o trecho das tentativas anteriores ao texto final.
453
+ const emitted = result.emitted ?? "";
454
+ const fullText = carried + emitted;
455
+ // Se o provedor mandou texto novo menor que o esperado, ainda vale o
456
+ // que ja foi mostrado; devolve o maior dos dois para o consumidor.
457
+ return { ...result, text: fullText.length >= (result.text || "").length ? fullText : result.text };
458
+ }
459
+ return result;
460
+ }
331
461
  if (result.code === "invalid_api_key" && !clearedKey) {
332
462
  clearedKey = true;
333
463
  storage.clearApiKey();
334
464
  continue;
335
465
  }
336
- // Ja saiu texto: repetir duplicaria a resposta na tela.
337
- if (!result.retryable || result.text) return result;
466
+ // Erro nao recuperavel (saldo, chave, modelo): devolve como veio.
467
+ if (!result.retryable) return carried ? { ...result, text: result.text || carried } : result;
338
468
  last = result;
339
469
 
470
+ // Caiu ja com texto na tela: reconecta continuando de onde parou.
471
+ const partial = String(result.text || "");
472
+ if (partial.trim()) {
473
+ carried += partial;
474
+ skipPrefix = carried;
475
+ working = [
476
+ ...messages,
477
+ { role: "assistant", content: carried },
478
+ {
479
+ role: "user",
480
+ content:
481
+ "Sua resposta anterior foi interrompida por uma falha de conexao. " +
482
+ "Continue exatamente de onde parou, sem repetir nada do que ja foi escrito.",
483
+ },
484
+ ];
485
+ }
486
+
340
487
  if (attempt >= maxAttempts) break;
341
488
 
342
489
  const delay = backoffMs(attempt);
343
490
  onRetry?.({ attempt, maxAttempts, delay, error: result.error });
344
491
  const slept = await wait(delay, signal);
345
- if (!slept) return { ok: false, code: "aborted", error: "Cancelado.", keyCreated: result.keyCreated };
492
+ if (!slept) {
493
+ return { ok: false, code: "aborted", error: "Cancelado.", text: carried, keyCreated: result.keyCreated };
494
+ }
346
495
  }
347
496
 
497
+ // Esgotou as tentativas: se ja tinha texto, entrega o parcial (melhor que erro).
498
+ if (carried.trim()) {
499
+ return { ...last, ok: true, text: carried, attempts: attempt, partial: true };
500
+ }
348
501
  return { ...last, attempts: attempt };
349
502
  }
350
503
 
package/src/tui.js CHANGED
@@ -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) {
@@ -1636,7 +1756,7 @@ function buildFrame(state, cols, rows, commands = {}) {
1636
1756
  const bodyHeight = Math.max(0, rows - head.length - chrome.length);
1637
1757
  state.pageSize = Math.max(1, bodyHeight);
1638
1758
 
1639
- let body = hero ? [] : messageLines(state, inner);
1759
+ let body = hero ? [] : messageLines(state, inner, bodyHeight + state.scroll);
1640
1760
  const maxScroll = Math.max(0, body.length - bodyHeight);
1641
1761
  state.scroll = clamp(state.scroll, 0, maxScroll);
1642
1762
  const end = body.length - state.scroll;
@@ -1755,19 +1875,35 @@ async function runTUI({
1755
1875
  renderTimer.unref?.();
1756
1876
  }
1757
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
+
1758
1892
  const mouseMode = () => (state.mouse ? "\x1b[?1000h\x1b[?1006h" : "\x1b[?1000l\x1b[?1006l");
1759
1893
  const enterScreen = () =>
1760
1894
  write(`\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J${mouseMode()}\x1b[?2004h`);
1761
1895
  const leaveScreen = () =>
1762
1896
  write("\x1b[?2004l\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l");
1763
1897
 
1764
- async function refreshAccount() {
1898
+ async function refreshAccount({ fresh = false } = {}) {
1765
1899
  const token = await ensureSession();
1766
1900
  if (!token) return;
1767
- const response = await fetchApi("/api/bootstrap").catch(() => null);
1901
+ const response = await fetchApi(`/api/bootstrap${fresh ? "?fresh=1" : ""}`).catch(() => null);
1768
1902
  if (!response || !response.ok) return;
1769
1903
  const data = await response.json().catch(() => null);
1770
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 : [];
1771
1907
 
1772
1908
  if (!state.model) {
1773
1909
  const pricing = await fetchApi("/api/public/pricing")
@@ -1777,6 +1913,19 @@ async function runTUI({
1777
1913
  }
1778
1914
  }
1779
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
+
1780
1929
  function applyMcp() {
1781
1930
  state.mcp.statuses = mcp.statuses();
1782
1931
  state.mcp.tools = state.mcp.statuses.reduce((total, item) => total + item.tools, 0);
@@ -2617,7 +2766,7 @@ async function runTUI({
2617
2766
  state.cursor = cursor == null ? value.length : clamp(cursor, 0, value.length);
2618
2767
  state.menuIndex = 0;
2619
2768
  state.menuDismissed = false;
2620
- render();
2769
+ scheduleInputRender();
2621
2770
  }
2622
2771
 
2623
2772
  function moveApproval(delta) {
@@ -2682,6 +2831,7 @@ async function runTUI({
2682
2831
 
2683
2832
  if (key.name === "f2") return toggleMouse();
2684
2833
  if (key.ctrl && key.name === "y") return copyLastAnswer();
2834
+ if (key.ctrl && key.name === "r") return refreshBalance();
2685
2835
 
2686
2836
  // Modal de texto: qualquer tecla fecha — e se for texto, ele vai para o
2687
2837
  // input em vez de ser engolido (quem digita nao perde o que escreveu).