kafox-cli 0.9.8 → 0.10.0

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,6 +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
20
  const { ensureSession, fetchApi } = require("./auth");
20
21
  const { runAgent } = require("./agent");
21
22
  const { THEMES, themeColors, paintLine, paintPanel, borderColor } = require("./theme");
@@ -25,12 +26,22 @@ const { toolMeta, parseCommandOutput } = require("./tools");
25
26
  const { McpHub } = require("./mcp");
26
27
  const sessions = require("./sessions");
27
28
  const wallpaper = require("./wallpaper");
29
+ const { listProviders } = require("./providers");
28
30
 
29
31
  const ANSI = /\x1b\[[0-9;]*m/g;
30
32
  const stripAnsi = (value) => String(value).replace(ANSI, "");
31
33
  const visible = (value) => stripAnsi(value).length;
32
34
  const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
33
35
 
36
+ // Falha de rede/servidor: o alerta correspondente é transitório e some quando
37
+ // a conexão volta. Mesmo padrão usado no retry do gateway (src/gateway.js).
38
+ const CONNECTION_ERROR =
39
+ /fetch failed|failed to fetch|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ECONNREFUSED|ENOTFOUND|socket hang up|network|networkerror|stream interrompido|terminated|other side closed|falha de rede|timeout|502|503|504/i;
40
+
41
+ function isConnectionError(message) {
42
+ return CONNECTION_ERROR.test(String(message || ""));
43
+ }
44
+
34
45
  const SIDEBAR_MIN_COLS = 100;
35
46
  const MENU_HEIGHT = 6;
36
47
  const SYNC_START = "\x1b[?2026h";
@@ -100,6 +111,12 @@ function truncate(text, width) {
100
111
  return plain.slice(0, width - 1) + "…";
101
112
  }
102
113
 
114
+ /** Preco em BRL por 1M tokens, curto para caber na linha do seletor. */
115
+ function fmtModelPrice(value) {
116
+ if (value === null || value === undefined) return "—";
117
+ return `R$${Number(value).toFixed(2)}`;
118
+ }
119
+
103
120
  function padRight(text, width) {
104
121
  const missing = width - visible(text);
105
122
  return missing > 0 ? text + " ".repeat(missing) : truncate(text, width);
@@ -120,17 +137,35 @@ function accentFor(theme) {
120
137
  return chalk.hex(accent);
121
138
  }
122
139
 
123
- const INLINE = /(\*\*[^*]+\*\*|`[^`]+`|\*[^*\n]+\*)/g;
140
+ // Ordem importa: link antes de negrito/italico, senao o `*` do `[...](...)`
141
+ // casaria dentro da URL. `~~riscado~~` tambem entrava como texto literal.
142
+ const INLINE = /(\[[^\]]+\]\([^)\s]+\)|`[^`]+`|\*\*[^*]+\*\*|~~[^~]+~~|\*[^*\n]+\*)/g;
124
143
 
125
144
  function parseInline(text) {
126
145
  const parts = String(text).split(INLINE);
127
146
  const segments = [];
128
147
  for (const part of parts) {
129
148
  if (!part) continue;
130
- if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
131
- segments.push({ text: part.slice(2, -2), bold: true });
149
+ const link = part.match(/^\[([^\]]+)\]\(([^)\s]+)\)$/);
150
+ if (link) {
151
+ // No terminal o link nao e clicavel: mostra o rotulo e a URL ao lado,
152
+ // porque esconder a URL deixaria o usuario sem como abrir.
153
+ // O rotulo ainda pode ter marcacao (`[**forte**](url)`): resolvemos.
154
+ const rotulo = parseInline(link[1])
155
+ .map((inner) => inner.text)
156
+ .join("");
157
+ const url = link[2];
158
+ segments.push({ text: rotulo === url ? url : `${rotulo} (${url})`, link: true });
159
+ } else if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
160
+ // `**[texto](url)**`: a marcacao de dentro ainda tem que ser resolvida,
161
+ // senao o link sai literal no terminal.
162
+ const inner = parseInline(part.slice(2, -2));
163
+ if (inner.length === 1) segments.push({ ...inner[0], bold: true });
164
+ else segments.push({ text: part.slice(2, -2), bold: true, nested: inner });
132
165
  } else if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
133
166
  segments.push({ text: part.slice(1, -1), code: true });
167
+ } else if (part.startsWith("~~") && part.endsWith("~~") && part.length > 4) {
168
+ segments.push({ text: part.slice(2, -2), strike: true });
134
169
  } else if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
135
170
  segments.push({ text: part.slice(1, -1), italic: true });
136
171
  } else {
@@ -184,15 +219,103 @@ function renderSegments(segments, theme = "default") {
184
219
  const accent = accentFor(theme);
185
220
  return segments
186
221
  .map((segment) => {
187
- let text = segment.text;
222
+ // Segmento com marcacao interna (ex.: link dentro de negrito): resolve
223
+ // primeiro o conteudo e depois aplica o estilo de fora.
224
+ let text = segment.nested
225
+ ? renderSegments(segment.nested, theme)
226
+ : segment.text;
188
227
  if (segment.code) text = chalk.cyan(text);
189
228
  if (segment.italic) text = chalk.italic(text);
229
+ if (segment.strike) text = chalk.dim(chalk.strikethrough(text));
230
+ if (segment.link) text = chalk.underline.cyan(text);
190
231
  if (segment.bold) text = accent(chalk.bold(text));
191
232
  return text;
192
233
  })
193
234
  .join("");
194
235
  }
195
236
 
237
+ /**
238
+ * Texto de uma celula sem a marcacao inline (`**bold**`, `` `code` ``, `*it*`).
239
+ * E o que ocupa coluna de verdade — medir o bruto erra a largura da tabela.
240
+ */
241
+ function plainOf(text) {
242
+ return parseInline(String(text))
243
+ .map((segment) =>
244
+ segment.nested
245
+ ? segment.nested.map((inner) => plainOf(inner.text)).join("")
246
+ : segment.text
247
+ )
248
+ .join("");
249
+ }
250
+
251
+ /**
252
+ * Desenha uma tabela Markdown como grade no terminal.
253
+ *
254
+ * A largura total e limitada a `width`: as colunas encolhem proporcionalmente
255
+ * e o conteudo que nao couber e truncado com `…`, para a mensagem nunca
256
+ * quebrar o layout da TUI. Colunas numericas tendem a alinhar a direita.
257
+ */
258
+ function tableLines(header, aligns = [], body = [], width = 80, theme = "default") {
259
+ const accent = accentFor(theme);
260
+ const columns = Math.max(header.length, ...body.map((row) => row.length));
261
+ const pad = Array.from({ length: columns }, (_, i) => header[i] || "");
262
+ const rowsBody = body.map((row) => Array.from({ length: columns }, (_, i) => row[i] || ""));
263
+
264
+ // `│ a │ b │` = 3 caracteres de moldura por coluna + 1 de borda.
265
+ const gutter = width - 2; // indent(2)
266
+ const available = Math.max(columns * 6, gutter - columns * 3 - 1);
267
+
268
+ const natural = Array.from({ length: columns }, (_, i) =>
269
+ Math.max(
270
+ 3,
271
+ ...rowsBody.map((row) => stripAnsi(plainOf(row[i])).length),
272
+ stripAnsi(plainOf(pad[i])).length
273
+ )
274
+ );
275
+
276
+ // Se o conteudo cabe, cada coluna fica do tamanho natural (tabela compacta).
277
+ // Se nao cabe, encolhe proporcionalmente, com piso de 3 para nao sumir.
278
+ let widths;
279
+ const total = natural.reduce((sum, n) => sum + n, 0);
280
+ if (total <= available) {
281
+ widths = natural;
282
+ } else {
283
+ widths = natural.map((n) => Math.max(3, Math.floor((n * available) / total)));
284
+ // Sobra do arredondamento volta para a maior coluna, evitando buraco a direita.
285
+ let used = widths.reduce((sum, n) => sum + n, 0);
286
+ while (used < available) {
287
+ const widest = widths.indexOf(Math.max(...widths));
288
+ widths[widest] += 1;
289
+ used += 1;
290
+ }
291
+ }
292
+
293
+ const cell = (text, col) => {
294
+ const plain = truncate(stripAnsi(plainOf(String(text))), widths[col]);
295
+ const fill = widths[col] - plain.length;
296
+ const align = aligns[col] || "left";
297
+ if (align === "right") return " ".repeat(fill) + plain;
298
+ if (align === "center") {
299
+ const left = Math.floor(fill / 2);
300
+ return " ".repeat(left) + plain + " ".repeat(fill - left);
301
+ }
302
+ return plain + " ".repeat(fill);
303
+ };
304
+
305
+ const lines = [];
306
+ const head = pad.map((text, col) => accent(chalk.bold(cell(text, col))));
307
+ lines.push(indent(2) + chalk.dim("│ ") + head.join(chalk.dim(" │ ")) + chalk.dim(" │"));
308
+
309
+ const ruleWidths = widths.map((w) => "─".repeat(w));
310
+ lines.push(indent(2) + chalk.dim("├─" + ruleWidths.join("─┼─") + "─┤"));
311
+
312
+ for (const row of rowsBody) {
313
+ const cells = row.map((text, col) => renderSegments(parseInline(cell(text, col)), theme));
314
+ lines.push(indent(2) + chalk.dim("│ ") + cells.join(chalk.dim(" │ ")) + chalk.dim(" │"));
315
+ }
316
+ return lines;
317
+ }
318
+
196
319
  /** Converte Markdown simples em linhas prontas para o terminal. */
197
320
  function markdownLines(text, width, theme = "default") {
198
321
  const accent = accentFor(theme);
@@ -200,7 +323,22 @@ function markdownLines(text, width, theme = "default") {
200
323
  const rows = String(text).replace(/\r/g, "").split("\n");
201
324
  let inCode = false;
202
325
 
203
- for (const raw of rows) {
326
+ /** Linha de tabela? Tem que ter os pipes externos. */
327
+ const isTableRow = (line) => /^\s*\|.*\|\s*$/.test(line);
328
+ /** Separador `|---|:--:|` — define alinhamento e a largura das colunas. */
329
+ const isTableRule = (line) =>
330
+ /^\s*\|(\s*:?-{2,}:?\s*\|)+\s*$/.test(line);
331
+
332
+ const splitRow = (line) =>
333
+ line
334
+ .trim()
335
+ .replace(/^\|/, "")
336
+ .replace(/\|$/, "")
337
+ .split("|")
338
+ .map((cell) => cell.trim());
339
+
340
+ for (let index = 0; index < rows.length; index += 1) {
341
+ const raw = rows[index];
204
342
  const line = raw.replace(/\s+$/, "");
205
343
  if (/^\s*```/.test(line)) {
206
344
  inCode = !inCode;
@@ -211,6 +349,33 @@ function markdownLines(text, width, theme = "default") {
211
349
  out.push(chalk.dim(indent(2) + "│ ") + chalk.cyan(truncate(line, width - 4)));
212
350
  continue;
213
351
  }
352
+
353
+ // Tabela: cabecalho + regra + linhas. Precisa das duas primeiras linhas
354
+ // para saber que e tabela, senao um `|` solto viraria grade.
355
+ if (isTableRow(line) && isTableRule(rows[index + 1] || "")) {
356
+ const header = splitRow(line);
357
+ const aligns = splitRow(rows[index + 1]).map((cell) => {
358
+ const left = cell.startsWith(":");
359
+ const right = cell.endsWith(":");
360
+ if (left && right) return "center";
361
+ if (right) return "right";
362
+ return "left";
363
+ });
364
+
365
+ const body = [];
366
+ let cursor = index + 2;
367
+ while (cursor < rows.length && isTableRow(rows[cursor])) {
368
+ body.push(splitRow(rows[cursor]));
369
+ cursor += 1;
370
+ }
371
+ index = cursor - 1;
372
+
373
+ out.push("");
374
+ out.push(...tableLines(header, aligns, body, width, theme));
375
+ out.push("");
376
+ continue;
377
+ }
378
+
214
379
  if (!line.trim()) {
215
380
  out.push("");
216
381
  continue;
@@ -230,11 +395,21 @@ function markdownLines(text, width, theme = "default") {
230
395
  continue;
231
396
  }
232
397
 
233
- const bullet = line.match(/^\s*[-*+]\s+(.*)$/);
398
+ const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
234
399
  if (bullet) {
235
- const wrapped = wrapSegments(parseInline(bullet[1]), width - 5);
400
+ const depth = Math.min(3, Math.floor(bullet[1].length / 2));
401
+ let corpo = bullet[2];
402
+ // Checkbox: o marcador vira simbolo, nao texto literal "- [x] feito".
403
+ let marcador = "•";
404
+ const check = corpo.match(/^\[([ xX])\]\s+(.*)$/);
405
+ if (check) {
406
+ marcador = check[1].trim() ? "✓" : "○";
407
+ corpo = check[2];
408
+ }
409
+ const base = indent(2 + depth * 2);
410
+ const wrapped = wrapSegments(parseInline(corpo), width - 5 - depth * 2);
236
411
  wrapped.forEach((part, index) => {
237
- out.push((index === 0 ? indent(2) + "• " : indent(4)) + renderSegments(part, theme));
412
+ out.push((index === 0 ? base + marcador + " " : base + " ") + renderSegments(part, theme));
238
413
  });
239
414
  continue;
240
415
  }
@@ -306,6 +481,8 @@ const CONTROL_KEYS = {
306
481
  12: { name: "l", ctrl: true },
307
482
  13: { name: "return" },
308
483
  21: { name: "u", ctrl: true },
484
+ // Ctrl+Q: desiste das mensagens que estao na fila (nao cancela a atual).
485
+ 17: { name: "q", ctrl: true },
309
486
  23: { name: "w", ctrl: true },
310
487
  25: { name: "y", ctrl: true },
311
488
  127: { name: "backspace" },
@@ -807,6 +984,104 @@ function thinkingLines(message, width) {
807
984
  return lines;
808
985
  }
809
986
 
987
+ /**
988
+ * Card da compactacao, no formato do opencode:
989
+ *
990
+ * ───────────────── Compaction ─────────────────
991
+ * Objective
992
+ * ...
993
+ * Work State
994
+ * Completed / Active / Blocked
995
+ * Next Move
996
+ * 1. ...
997
+ *
998
+ * O resumo chega como secoes nomeadas; aqui so agrupamos logicamente
999
+ * ("Work State" junta Completed, Active e Blocked) e aplicamos cor.
1000
+ */
1001
+ function compactionLines(message, width, state) {
1002
+ const accent = accentFor(state?.theme || "default");
1003
+ const meta = message.compaction || {};
1004
+ const inner = Math.max(20, width - 4);
1005
+ const lines = [""];
1006
+
1007
+ const label = " Compaction ";
1008
+ const rule = Math.max(0, inner - label.length);
1009
+ const left = Math.floor(rule / 2);
1010
+ lines.push(
1011
+ indent(2) +
1012
+ chalk.dim("─".repeat(left)) +
1013
+ accent(chalk.bold(label)) +
1014
+ chalk.dim("─".repeat(rule - left))
1015
+ );
1016
+
1017
+ // Agrupa as secoes: Completed/Active/Blocked viram "Work State".
1018
+ const WORK_KEYS = new Set(["completed", "active", "blocked"]);
1019
+ const blocks = [];
1020
+ let work = null;
1021
+ for (const raw of String(message.text || "").split("\n")) {
1022
+ const line = raw.replace(/\s+$/, "");
1023
+ if (!line.trim()) continue;
1024
+ const heading = line.match(/^\s*#{0,3}\s*(Objective|Important Details|Work State|Completed|Active|Blocked|Next Move|Relevant Files)\s*:?\s*$/i);
1025
+ if (heading) {
1026
+ const title = heading[1];
1027
+ const key = title.toLowerCase();
1028
+ // "Work State" agrupa as tres subsecoes; se ele vier explicito, so abre o
1029
+ // grupo (sem criar bloco duplicado quando Completed/Active aparecerem).
1030
+ if (key === "work state") {
1031
+ if (!work) {
1032
+ work = { title: "Work State", items: [{ sub: null, lines: [] }] };
1033
+ blocks.push(work);
1034
+ }
1035
+ continue;
1036
+ }
1037
+ if (WORK_KEYS.has(key)) {
1038
+ if (!work) {
1039
+ work = { title: "Work State", items: [{ sub: null, lines: [] }] };
1040
+ blocks.push(work);
1041
+ }
1042
+ // Descarta o item vazio criado ao abrir o grupo por "Work State".
1043
+ work.items = work.items.filter((item) => item.sub || item.lines.length);
1044
+ work.items.push({ sub: title, lines: [] });
1045
+ continue;
1046
+ }
1047
+ work = null;
1048
+ blocks.push({ title, items: [{ sub: null, lines: [] }] });
1049
+ continue;
1050
+ }
1051
+ // Conteudo solto antes de qualquer secao: guarda em "Summary".
1052
+ if (!blocks.length) blocks.push({ title: "Summary", items: [{ sub: null, lines: [] }] });
1053
+ const lastBlock = blocks[blocks.length - 1];
1054
+ const lastItem = lastBlock.items[lastBlock.items.length - 1];
1055
+ // `Sub:` e o cabecalho de subsecao dentro de Work State.
1056
+ const sub = line.match(/^\s*(Completed|Active|Blocked)\s*:?\s*$/i);
1057
+ if (sub) {
1058
+ lastBlock.items.push({ sub: sub[1], lines: [] });
1059
+ continue;
1060
+ }
1061
+ lastItem.lines.push(line);
1062
+ }
1063
+
1064
+ for (const block of blocks) {
1065
+ lines.push(indent(2) + accent(chalk.bold(block.title)));
1066
+ for (const item of block.items) {
1067
+ if (item.sub) lines.push(indent(4) + chalk.bold(item.sub));
1068
+ const pad = item.sub ? 6 : 4;
1069
+ for (const raw of item.lines) {
1070
+ const text = raw.replace(/^\s*[-*•]\s+/, "• ").replace(/^\s+/, "");
1071
+ for (const row of wrap(text, inner - pad + 2)) {
1072
+ lines.push(indent(pad) + row);
1073
+ }
1074
+ }
1075
+ }
1076
+ }
1077
+
1078
+ const antes = Number(meta.before || 0).toLocaleString("pt-BR");
1079
+ const depois = Number(meta.after || 0).toLocaleString("pt-BR");
1080
+ lines.push("");
1081
+ lines.push(indent(2) + chalk.dim(`${antes} → ${depois} tokens`));
1082
+ return lines;
1083
+ }
1084
+
810
1085
  function messageLines(state, width) {
811
1086
  const lines = [];
812
1087
  for (const message of state.messages) {
@@ -817,6 +1092,8 @@ function messageLines(state, width) {
817
1092
  for (let index = 1; index < body.length; index += 1) {
818
1093
  lines.push(indent(4) + chalk.bold(body[index]));
819
1094
  }
1095
+ } else if (message.role === "compaction") {
1096
+ lines.push(...compactionLines(message, width, state));
820
1097
  } else if (message.role === "assistant") {
821
1098
  lines.push("");
822
1099
  lines.push(...markdownLines(message.text, width - 2, state.theme));
@@ -905,11 +1182,41 @@ function formatTokens(count) {
905
1182
  return `${(count / 1_000_000).toFixed(1)}M`;
906
1183
  }
907
1184
 
1185
+ /**
1186
+ * Commita o texto que chegou pelo stream como mensagem `assistant`.
1187
+ * Nao duplica quando o mesmo trecho ja foi commitado neste turno (retry do
1188
+ * gateway repetindo tokens, `onToolStart` chamado duas vezes, etc.).
1189
+ */
1190
+ function commitStreamedText(state, text) {
1191
+ const value = String(text || "").trim();
1192
+ if (!value) return false;
1193
+ if (state.streamedText.includes(value)) return false;
1194
+ const previous = [...state.messages].reverse().find((message) => message.role === "assistant");
1195
+ if (previous && previous.text === value) return false;
1196
+ state.messages.push({ role: "assistant", text: value });
1197
+ state.streamedText = `${state.streamedText}${value}`;
1198
+ return true;
1199
+ }
1200
+
1201
+ /**
1202
+ * Ultimo texto devolvido por `runAgent`. Quando o stream ja mostrou esse texto
1203
+ * (commit em `finishStreaming`), nao empurra de novo: era isso que fazia a
1204
+ * resposta aparecer duas vezes na tela.
1205
+ */
1206
+ function commitFinalText(state, text, ms) {
1207
+ const value = String(text || "").trim();
1208
+ if (!value) return false;
1209
+ if (state.streamedText.includes(value)) return false;
1210
+ state.messages.push({ role: "assistant", text: value, ms });
1211
+ return true;
1212
+ }
1213
+
908
1214
  function estimateTokens(state) {
909
- let chars = 0;
910
- for (const message of state.context) chars += String(message.content || "").length;
911
- if (state.streaming) chars += String(state.streaming.text || "").length;
912
- return Math.round(chars / 4);
1215
+ // `tool_calls` (nome + arguments) tambem vai no payload, entao conta: sem
1216
+ // isso o sidebar mostrava um contexto bem menor do que o provedor recebia.
1217
+ let tokens = countContextTokens(state.context || []);
1218
+ if (state.streaming) tokens += Math.round(String(state.streaming.text || "").length / 4);
1219
+ return tokens;
913
1220
  }
914
1221
 
915
1222
  function sidebarLines(state, width) {
@@ -958,12 +1265,36 @@ function sidebarLines(state, width) {
958
1265
 
959
1266
  /* --------------------------------------------------------------------- chrome */
960
1267
 
1268
+ /**
1269
+ * Desiste das mensagens enfileiradas.
1270
+ *
1271
+ * Pura de proposito: `state.queue` e `state.messages` sao mutados no lugar
1272
+ * porque a TUI e dona deles, mas a decisao (quantas sairam, o que avisar)
1273
+ * fica testavel sem terminal. A mensagem em andamento nao e tocada: quem
1274
+ * para a atual e o Esc duas vezes.
1275
+ */
1276
+ function dropQueue(state) {
1277
+ const pending = state.queue || [];
1278
+ const count = pending.length;
1279
+ if (!count) return 0;
1280
+ state.queue.length = 0;
1281
+ state.messages.push({
1282
+ role: "system",
1283
+ text:
1284
+ count === 1
1285
+ ? "1 mensagem removida da fila."
1286
+ : `${count} mensagens removidas da fila.`,
1287
+ });
1288
+ state.scroll = 0;
1289
+ return count;
1290
+ }
1291
+
961
1292
  /** Mensagens esperando a vez: aparecem acima do input, sem interromper a digitacao. */
962
1293
  function queueLines(state, width) {
963
1294
  const pending = state.queue || [];
964
1295
  if (!pending.length) return [];
965
1296
 
966
- const rows = [indent(1) + chalk.dim(`⏳ ${pending.length} na fila · Esc 2× para parar a atual`)];
1297
+ const rows = [indent(1) + chalk.dim(`⏳ ${pending.length} na fila · ctrl+q descarta · Esc 2× para parar a atual`)];
967
1298
  pending.slice(0, 3).forEach((line, index) => {
968
1299
  rows.push(indent(3) + chalk.dim(truncate(`${index + 1}. ${line}`, width - 4)));
969
1300
  });
@@ -1035,7 +1366,7 @@ function hintsLines(state, width, { bare = false } = {}) {
1035
1366
  const attempt = state.retry.maxAttempts
1036
1367
  ? ` (tentativa ${state.retry.attempt}/${state.retry.maxAttempts})`
1037
1368
  : "";
1038
- const label = truncate(`⚠ ${state.retry.error || "fetch failed"} · reconectando em ${left}s${attempt}`, width);
1369
+ const label = truncate(`⚠ ${state.retry.error || "fetch failed"} · tentando reconectar em ${left}s${attempt}`, width);
1039
1370
  return [chalk.red(label)];
1040
1371
  }
1041
1372
 
@@ -1185,7 +1516,14 @@ function buildOverlay(state, cols, rows) {
1185
1516
  const gap = Math.max(1, space - visible(left) - visible(right));
1186
1517
  line = padRight(left + " ".repeat(gap) + right, width - 4);
1187
1518
  } else {
1188
- line = padRight(` ${caret} ${item.id} `, width - 4);
1519
+ const right = item.cost
1520
+ ? chalk.dim(`in ${fmtModelPrice(item.cost.in)} · out ${fmtModelPrice(item.cost.out)}`)
1521
+ : "";
1522
+ const space = width - 6;
1523
+ const room = Math.max(16, space - visible(right));
1524
+ const left = ` ${caret} ${truncate(item.id, room)}`;
1525
+ const gap = Math.max(1, space - visible(left) - visible(right));
1526
+ line = padRight(left + " ".repeat(gap) + right, width - 4);
1189
1527
  }
1190
1528
  body.push(real === picker.index ? chalk.inverse(line) : line);
1191
1529
  }
@@ -1335,6 +1673,7 @@ async function runTUI({
1335
1673
  interactiveCommands = new Set(),
1336
1674
  autoApprove = false,
1337
1675
  resume = null,
1676
+ mobile = null,
1338
1677
  } = {}) {
1339
1678
  const stdin = process.stdin;
1340
1679
  const stdout = process.stdout;
@@ -1558,7 +1897,16 @@ async function runTUI({
1558
1897
  const pricing = await fetchApi("/api/public/pricing")
1559
1898
  .then((res) => res.json())
1560
1899
  .catch(() => null);
1561
- state.models = (pricing?.pricing || []).map((row) => ({ id: row.model }));
1900
+ const gateway = (pricing?.pricing || []).map((row) => ({ id: row.model }));
1901
+ // Provedores alternativos (OpenAI-compatible): id prefixado `provedor/modelo`
1902
+ // para o gateway saber rotear; preco em BRL por 1M tokens.
1903
+ const external = [];
1904
+ for (const provider of listProviders()) {
1905
+ for (const model of provider.models || []) {
1906
+ external.push({ id: `${provider.id}/${model.id}`, cost: model.brl, tools: model.tools });
1907
+ }
1908
+ }
1909
+ state.models = [...gateway, ...external];
1562
1910
  return state.models;
1563
1911
  }
1564
1912
 
@@ -1630,6 +1978,7 @@ async function runTUI({
1630
1978
  state.files = [];
1631
1979
  state.tokens = estimateTokens(state);
1632
1980
  state.scroll = 0;
1981
+ startMobile();
1633
1982
  return render();
1634
1983
  }
1635
1984
 
@@ -1748,6 +2097,26 @@ async function runTUI({
1748
2097
  return openMcpView();
1749
2098
  }
1750
2099
 
2100
+ if (name === "/queue") {
2101
+ const pending = state.queue || [];
2102
+ if (!pending.length) {
2103
+ state.modal = { title: "Fila", text: "Nada na fila — a mensagem atual continua rodando." };
2104
+ return render();
2105
+ }
2106
+ // `/queue` sozinho mostra a fila; `/queue clear` (ou `limpar`) desiste dela.
2107
+ const arg = String(parts.slice(1).join(" ") || "").trim().toLowerCase();
2108
+ const clearing = ["clear", "limpar", "off", "drop", "cancel", "cancelar"].includes(arg);
2109
+ if (!clearing) {
2110
+ const rows = pending.map((item, index) => `${index + 1}. ${item}`).join("\n");
2111
+ state.modal = {
2112
+ title: `Fila (${pending.length})`,
2113
+ text: `${rows}\n\nDesista de todas com /queue clear ou Ctrl+Q.`,
2114
+ };
2115
+ return render();
2116
+ }
2117
+ return clearQueue();
2118
+ }
2119
+
1751
2120
  if (name === "/clear") {
1752
2121
  state.messages = [];
1753
2122
  state.context = [];
@@ -1761,16 +2130,9 @@ async function runTUI({
1761
2130
  state.modal = { title: "Contexto", text: "Nada para compactar." };
1762
2131
  return render();
1763
2132
  }
1764
- const total = state.messages.length;
1765
- const kept = state.context.slice(-6);
1766
- state.messages = [
1767
- { role: "system", text: `Contexto compactado: ${total} mensagens resumidas.` },
1768
- ...state.messages.slice(-6),
1769
- ];
1770
- // O system prompt do agente (indice 0) continua valendo.
1771
- state.context = state.context.length ? [state.context[0], ...kept] : [];
1772
- state.scroll = 0;
1773
- return render();
2133
+ // Usa o mesmo resumo estruturado da compactacao automatica: descartar
2134
+ // mensagens (comportamento antigo) fazia o agente "esquecer" o trabalho.
2135
+ return compactNow();
1774
2136
  }
1775
2137
 
1776
2138
  if (interactiveCommands.has(name)) return runOutside(line);
@@ -1787,14 +2149,107 @@ async function runTUI({
1787
2149
  render();
1788
2150
  }
1789
2151
 
2152
+ /**
2153
+ * Desiste das mensagens enfileiradas.
2154
+ *
2155
+ * So mexe na fila: a mensagem que ja esta rodando continua ate o fim (para
2156
+ * parar essa, e Esc duas vezes). Devolve quantas saíram para o aviso.
2157
+ */
2158
+ /**
2159
+ * `/compact` manual: roda o mesmo resumo da compactacao automatica.
2160
+ *
2161
+ * Forca o corte mesmo abaixo do limiar (o usuario pediu), mas `keepMessages`
2162
+ * garante que as trocas recentes fiquem intactas.
2163
+ */
2164
+ async function compactNow() {
2165
+ const antes = state.context.length;
2166
+ if (antes <= 2) {
2167
+ state.modal = { title: "Contexto", text: "Conversa curta demais para compactar." };
2168
+ return render();
2169
+ }
2170
+ state.busy = true;
2171
+ state.modal = { title: "Compactando", text: "Resumindo o historico…" };
2172
+ render();
2173
+ try {
2174
+ const result = await compactIfNeeded(state.context, {
2175
+ model: state.model,
2176
+ // 0 forca o resumo: quem digitou /compact quer compactar agora.
2177
+ thresholdTokens: 0,
2178
+ keepMessages: 6,
2179
+ });
2180
+ if (!result.compacted) {
2181
+ state.modal = { title: "Contexto", text: `Nao deu para compactar: ${result.error || "sem conteudo"}` };
2182
+ return render();
2183
+ }
2184
+ state.context = result.messages;
2185
+ // A conversa visivel tambem encolhe, mantendo as ultimas trocas.
2186
+ const visiveis = state.messages.filter((m) => m.role !== "compaction");
2187
+ state.messages = [
2188
+ ...visiveis.slice(0, 1),
2189
+ { role: "compaction", text: result.summary, compaction: { before: result.before, after: result.after } },
2190
+ ...visiveis.slice(-6),
2191
+ ];
2192
+ state.modal = null;
2193
+ state.scroll = 0;
2194
+ return render();
2195
+ } finally {
2196
+ state.busy = false;
2197
+ }
2198
+ }
2199
+
2200
+ function clearQueue() {
2201
+ const removed = dropQueue(state);
2202
+ if (removed) render();
2203
+ return removed;
2204
+ }
2205
+
1790
2206
  function startMessageText(text) {
1791
2207
  state.messages.push({ role: "user", text });
1792
2208
  }
1793
2209
 
2210
+ /**
2211
+ * A conexao voltou: some com o aviso de reconexao do rodape e com as
2212
+ * mensagens de erro de rede pendentes no historico. Sem isso o alerta fica
2213
+ * preso para sempre na tela, mesmo depois do gateway responder de novo.
2214
+ */
2215
+ function clearRetry() {
2216
+ let changed = false;
2217
+ if (state.retry) {
2218
+ state.retry = null;
2219
+ changed = true;
2220
+ }
2221
+ const before = state.messages.length;
2222
+ state.messages = state.messages.filter((message) => !message.connectionError);
2223
+ if (state.messages.length !== before) changed = true;
2224
+ return changed;
2225
+ }
2226
+
1794
2227
  /**
1795
2228
  * Enter coloca a linha na fila e a fila anda sozinha: da para mandar
1796
2229
  * quantas mensagens quiser enquanto a IA ainda esta respondendo.
1797
2230
  */
2231
+ /**
2232
+ * Enfileira um turno vindo de outro cliente (celular). Mesmo caminho do
2233
+ * teclado: entra na `state.queue` e o `pump` roda. Nao mexe em input/history
2234
+ * do terminal do PC (evita "sujeira" na linha de digitacao do usuario).
2235
+ */
2236
+ function submitText(text) {
2237
+ const line = String(text || "").trim();
2238
+ if (!line) return false;
2239
+ if (line === "/exit" || line === "/quit") return true;
2240
+ state.queue.push(line);
2241
+ render();
2242
+ pump();
2243
+ return true;
2244
+ }
2245
+
2246
+ /** Aborta o turno corrente (equivalente ao Esc). */
2247
+ function abortTurn() {
2248
+ if (state.approval) settleApproval("deny");
2249
+ state.abort?.abort();
2250
+ render();
2251
+ }
2252
+
1798
2253
  function submit() {
1799
2254
  const line = state.input.trim();
1800
2255
  if (!line) return;
@@ -1858,10 +2313,12 @@ async function runTUI({
1858
2313
  mcp,
1859
2314
  signal: controller.signal,
1860
2315
  onText: (chunk) => {
2316
+ clearRetry();
1861
2317
  state.streaming.text += chunk;
1862
2318
  scheduleRender();
1863
2319
  },
1864
2320
  onReasoning: (chunk) => {
2321
+ clearRetry();
1865
2322
  if (!state.streaming.thinkingStartedAt) state.streaming.thinkingStartedAt = Date.now();
1866
2323
  state.streaming.reasoning += chunk;
1867
2324
  scheduleRender();
@@ -1889,6 +2346,19 @@ async function runTUI({
1889
2346
  state.retry = { ...info, endsAt: Date.now() + info.delay };
1890
2347
  scheduleRender();
1891
2348
  },
2349
+ onCompact: ({ before, after, summary }) => {
2350
+ // O historico antigo virou resumo: mostra na conversa para o usuario
2351
+ // saber que o contexto encolheu (e por que a resposta "esqueceu").
2352
+ finishStreaming();
2353
+ state.messages.push({
2354
+ role: "compaction",
2355
+ text: summary || "",
2356
+ compaction: { before, after, at: Date.now() },
2357
+ });
2358
+ state.tokenWarned = false;
2359
+ state.scroll = 0;
2360
+ render();
2361
+ },
1892
2362
  onSubagentEvent: (event) => {
1893
2363
  const card = [...state.messages].reverse().find((message) => {
1894
2364
  if (message.role !== "tool" || !message.tool?.id) return false;
@@ -1955,20 +2425,22 @@ async function runTUI({
1955
2425
  const text = String(result.text || "").trim();
1956
2426
  // O stream ja foi commitado em finishStreaming(): so empurra aqui se for
1957
2427
  // texto novo (ou se nao houve streaming, ex.: resposta sem onText).
1958
- if (text && !state.streamedText.includes(text)) {
1959
- state.messages.push({
1960
- role: "assistant",
1961
- text,
1962
- ms: result.ms,
1963
- });
1964
- }
2428
+ if (text) commitFinalText(state, text, result.ms);
1965
2429
  state.streamedText = "";
1966
2430
  if (result.messages) state.context = result.messages;
2431
+ // Reconectou de verdade: limpa o alerta e os erros de rede pendentes.
2432
+ clearRetry();
1967
2433
  } else {
2434
+ // Erro de rede/servidor entra marcado como transitório: some sozinho
2435
+ // quando a próxima resposta chegar. Erros de verdade (saldo, chave)
2436
+ // continuam fixos porque exigem ação do usuário.
2437
+ const transient = isConnectionError(result.error);
2438
+ if (transient) clearRetry();
1968
2439
  state.messages.push({
1969
2440
  role: "system",
1970
2441
  text: result.error || "Falha ao responder.",
1971
2442
  error: true,
2443
+ connectionError: transient,
1972
2444
  });
1973
2445
  }
1974
2446
  state.retry = null;
@@ -1989,16 +2461,7 @@ async function runTUI({
1989
2461
  ms: streaming.thinkingStartedAt ? Date.now() - streaming.thinkingStartedAt : 0,
1990
2462
  });
1991
2463
  }
1992
- if (streaming.text.trim()) {
1993
- const text = streaming.text.trim();
1994
- // Evita repetir o texto quando o mesmo trecho ja foi commitado (retry
1995
- // que repetiu tokens ou onToolStart chamado duas vezes).
1996
- const previous = [...state.messages].reverse().find((message) => message.role === "assistant");
1997
- if (!previous || previous.text !== text) {
1998
- state.messages.push({ role: "assistant", text });
1999
- state.streamedText += text;
2000
- }
2001
- }
2464
+ if (streaming.text.trim()) commitStreamedText(state, streaming.text.trim());
2002
2465
  state.streaming = { text: "", reasoning: "", thinkingStartedAt: 0 };
2003
2466
  }
2004
2467
 
@@ -2139,6 +2602,12 @@ async function runTUI({
2139
2602
  if (state.suspended) return;
2140
2603
  if (key.ctrl && key.name === "c") return quit();
2141
2604
 
2605
+ // Ctrl+Q: desiste da fila (a mensagem em andamento continua).
2606
+ if (key.ctrl && key.name === "q") {
2607
+ if (!state.approval && !state.modal) clearQueue();
2608
+ return;
2609
+ }
2610
+
2142
2611
  // Aprovacao de ferramenta: navega e confirma.
2143
2612
  if (state.approval) {
2144
2613
  if (key.name === "up" || key.name === "down" || key.name === "tab") {
@@ -2371,6 +2840,36 @@ async function runTUI({
2371
2840
  const savedBackground = storage.getConfig().background;
2372
2841
  if (savedBackground) setBackground(savedBackground, { quiet: true }).catch(() => {});
2373
2842
 
2843
+ // `kafox mobile`: a TUI continua sendo o "dono" da sessao; o celular entra
2844
+ // como mais um cliente. O QR aparece sobre a interface, em modal.
2845
+ //
2846
+ // IMPORTANTE (fluxo): se o seletor de conversas foi pedido (`resume: "pick"`),
2847
+ // NAO subimos o servidor agora — so depois que a conversa for escolhida em
2848
+ // resumeChat(). Caso contrario o modal do QR cobriria o seletor e o celular
2849
+ // parearia com a sessao errada/vazia.
2850
+ let mobileStarted = false;
2851
+ function startMobile() {
2852
+ if (!mobile || mobileStarted) return;
2853
+ mobileStarted = true;
2854
+ try {
2855
+ const { attachMobile } = require("./mobile");
2856
+ attachMobile({
2857
+ state,
2858
+ api: { submitText, settleApproval, abortTurn },
2859
+ options: mobile,
2860
+ print: (lines) => {
2861
+ state.modal = { title: "kafox mobile", text: lines.join("\n") };
2862
+ render();
2863
+ },
2864
+ });
2865
+ } catch (error) {
2866
+ state.modal = { title: "kafox mobile", text: `Nao subiu o servidor mobile: ${error.message}` };
2867
+ render();
2868
+ }
2869
+ }
2870
+
2871
+ if (!resumePick) startMobile();
2872
+
2374
2873
  spinner = setInterval(() => {
2375
2874
  if (!state.busy || closed) return;
2376
2875
  state.frame += 1;
@@ -2378,7 +2877,7 @@ async function runTUI({
2378
2877
  }, 180);
2379
2878
  spinner.unref?.();
2380
2879
 
2381
- return { state, quit, render, submit };
2880
+ return { state, quit, render, submit, submitText, abortTurn };
2382
2881
  }
2383
2882
 
2384
2883
  module.exports = {
@@ -2396,4 +2895,10 @@ module.exports = {
2396
2895
  planRepaint,
2397
2896
  APPROVAL_OPTIONS,
2398
2897
  layoutFor,
2898
+ commitStreamedText,
2899
+ commitFinalText,
2900
+ dropQueue,
2901
+ queueLines,
2902
+ compactionLines,
2903
+ isConnectionError,
2399
2904
  };