kafox-cli 0.9.9 → 0.10.1
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/bin/kafox.js +78 -0
- package/package.json +1 -1
- package/src/agent.js +24 -0
- package/src/auth.js +25 -6
- package/src/cli.js +3 -0
- package/src/compact.js +386 -0
- package/src/gateway.js +119 -53
- package/src/mobile/auth.js +474 -0
- package/src/mobile/bridge.js +296 -0
- package/src/mobile/captcha.js +96 -0
- package/src/mobile/history.js +71 -0
- package/src/mobile/index.js +556 -0
- package/src/mobile/protocol.js +51 -0
- package/src/mobile/qr.js +487 -0
- package/src/mobile/ratelimit.js +79 -0
- package/src/mobile/server.js +399 -0
- package/src/mobile/tui-bridge.js +290 -0
- package/src/mobile/view.js +943 -0
- package/src/models.js +19 -0
- package/src/providers-cli.js +82 -0
- package/src/providers.js +87 -0
- package/src/storage.js +39 -0
- package/src/tui.js +554 -34
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, trimHistory } = 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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
398
|
+
const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
|
234
399
|
if (bullet) {
|
|
235
|
-
const
|
|
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 ?
|
|
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));
|
|
@@ -934,10 +1211,12 @@ function commitFinalText(state, text, ms) {
|
|
|
934
1211
|
return true;
|
|
935
1212
|
}
|
|
936
1213
|
|
|
937
|
-
function estimateTokens(state) {
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1214
|
+
function estimateTokens(state) {
|
|
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;
|
|
941
1220
|
}
|
|
942
1221
|
|
|
943
1222
|
function sidebarLines(state, width) {
|
|
@@ -986,12 +1265,36 @@ function sidebarLines(state, width) {
|
|
|
986
1265
|
|
|
987
1266
|
/* --------------------------------------------------------------------- chrome */
|
|
988
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
|
+
|
|
989
1292
|
/** Mensagens esperando a vez: aparecem acima do input, sem interromper a digitacao. */
|
|
990
1293
|
function queueLines(state, width) {
|
|
991
1294
|
const pending = state.queue || [];
|
|
992
1295
|
if (!pending.length) return [];
|
|
993
1296
|
|
|
994
|
-
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`)];
|
|
995
1298
|
pending.slice(0, 3).forEach((line, index) => {
|
|
996
1299
|
rows.push(indent(3) + chalk.dim(truncate(`${index + 1}. ${line}`, width - 4)));
|
|
997
1300
|
});
|
|
@@ -1063,7 +1366,7 @@ function hintsLines(state, width, { bare = false } = {}) {
|
|
|
1063
1366
|
const attempt = state.retry.maxAttempts
|
|
1064
1367
|
? ` (tentativa ${state.retry.attempt}/${state.retry.maxAttempts})`
|
|
1065
1368
|
: "";
|
|
1066
|
-
const label = truncate(`⚠ ${state.retry.error || "fetch failed"} ·
|
|
1369
|
+
const label = truncate(`⚠ ${state.retry.error || "fetch failed"} · tentando reconectar em ${left}s${attempt}`, width);
|
|
1067
1370
|
return [chalk.red(label)];
|
|
1068
1371
|
}
|
|
1069
1372
|
|
|
@@ -1105,6 +1408,29 @@ function box(state, { title, body, hint, width }) {
|
|
|
1105
1408
|
|
|
1106
1409
|
function approvalBody(state, width) {
|
|
1107
1410
|
const { name, args, change } = state.approval;
|
|
1411
|
+
|
|
1412
|
+
// Pedido de pareamento do celular: mesmo widget de opcoes, conteudo proprio.
|
|
1413
|
+
if (name === "__pair__") {
|
|
1414
|
+
const body = [];
|
|
1415
|
+
const who = change?.device || "celular";
|
|
1416
|
+
body.push(chalk.bold("Um celular quer entrar nesta conversa."));
|
|
1417
|
+
body.push("");
|
|
1418
|
+
if (change?.title) body.push(truncate("conversa: " + change.title, width - 4));
|
|
1419
|
+
if (change?.base) body.push(chalk.dim(truncate(change.base, width - 4)));
|
|
1420
|
+
body.push(chalk.dim(truncate("dispositivo: " + who, width - 4)));
|
|
1421
|
+
body.push("");
|
|
1422
|
+
const optionsStart = body.length;
|
|
1423
|
+
const opts = state.approval.options || APPROVAL_OPTIONS;
|
|
1424
|
+
for (const [index, option] of opts.entries()) {
|
|
1425
|
+
const selected = index === state.approval.index;
|
|
1426
|
+
const line = padRight(`${selected ? "▸ " : " "}${option.label}`, width - 6);
|
|
1427
|
+
if (selected) body.push(chalk.inverse(chalk.bold(line)));
|
|
1428
|
+
else if (option.id === "deny") body.push(chalk.dim(line));
|
|
1429
|
+
else body.push(line);
|
|
1430
|
+
}
|
|
1431
|
+
return { title: "Pareamento do celular", body, optionsStart };
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1108
1434
|
const meta = toolMeta(name);
|
|
1109
1435
|
const body = [];
|
|
1110
1436
|
|
|
@@ -1213,7 +1539,14 @@ function buildOverlay(state, cols, rows) {
|
|
|
1213
1539
|
const gap = Math.max(1, space - visible(left) - visible(right));
|
|
1214
1540
|
line = padRight(left + " ".repeat(gap) + right, width - 4);
|
|
1215
1541
|
} else {
|
|
1216
|
-
|
|
1542
|
+
const right = item.cost
|
|
1543
|
+
? chalk.dim(`in ${fmtModelPrice(item.cost.in)} · out ${fmtModelPrice(item.cost.out)}`)
|
|
1544
|
+
: "";
|
|
1545
|
+
const space = width - 6;
|
|
1546
|
+
const room = Math.max(16, space - visible(right));
|
|
1547
|
+
const left = ` ${caret} ${truncate(item.id, room)}`;
|
|
1548
|
+
const gap = Math.max(1, space - visible(left) - visible(right));
|
|
1549
|
+
line = padRight(left + " ".repeat(gap) + right, width - 4);
|
|
1217
1550
|
}
|
|
1218
1551
|
body.push(real === picker.index ? chalk.inverse(line) : line);
|
|
1219
1552
|
}
|
|
@@ -1363,6 +1696,7 @@ async function runTUI({
|
|
|
1363
1696
|
interactiveCommands = new Set(),
|
|
1364
1697
|
autoApprove = false,
|
|
1365
1698
|
resume = null,
|
|
1699
|
+
mobile = null,
|
|
1366
1700
|
} = {}) {
|
|
1367
1701
|
const stdin = process.stdin;
|
|
1368
1702
|
const stdout = process.stdout;
|
|
@@ -1586,7 +1920,16 @@ async function runTUI({
|
|
|
1586
1920
|
const pricing = await fetchApi("/api/public/pricing")
|
|
1587
1921
|
.then((res) => res.json())
|
|
1588
1922
|
.catch(() => null);
|
|
1589
|
-
|
|
1923
|
+
const gateway = (pricing?.pricing || []).map((row) => ({ id: row.model }));
|
|
1924
|
+
// Provedores alternativos (OpenAI-compatible): id prefixado `provedor/modelo`
|
|
1925
|
+
// para o gateway saber rotear; preco em BRL por 1M tokens.
|
|
1926
|
+
const external = [];
|
|
1927
|
+
for (const provider of listProviders()) {
|
|
1928
|
+
for (const model of provider.models || []) {
|
|
1929
|
+
external.push({ id: `${provider.id}/${model.id}`, cost: model.brl, tools: model.tools });
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
state.models = [...gateway, ...external];
|
|
1590
1933
|
return state.models;
|
|
1591
1934
|
}
|
|
1592
1935
|
|
|
@@ -1658,6 +2001,7 @@ async function runTUI({
|
|
|
1658
2001
|
state.files = [];
|
|
1659
2002
|
state.tokens = estimateTokens(state);
|
|
1660
2003
|
state.scroll = 0;
|
|
2004
|
+
startMobile();
|
|
1661
2005
|
return render();
|
|
1662
2006
|
}
|
|
1663
2007
|
|
|
@@ -1776,6 +2120,26 @@ async function runTUI({
|
|
|
1776
2120
|
return openMcpView();
|
|
1777
2121
|
}
|
|
1778
2122
|
|
|
2123
|
+
if (name === "/queue") {
|
|
2124
|
+
const pending = state.queue || [];
|
|
2125
|
+
if (!pending.length) {
|
|
2126
|
+
state.modal = { title: "Fila", text: "Nada na fila — a mensagem atual continua rodando." };
|
|
2127
|
+
return render();
|
|
2128
|
+
}
|
|
2129
|
+
// `/queue` sozinho mostra a fila; `/queue clear` (ou `limpar`) desiste dela.
|
|
2130
|
+
const arg = String(parts.slice(1).join(" ") || "").trim().toLowerCase();
|
|
2131
|
+
const clearing = ["clear", "limpar", "off", "drop", "cancel", "cancelar"].includes(arg);
|
|
2132
|
+
if (!clearing) {
|
|
2133
|
+
const rows = pending.map((item, index) => `${index + 1}. ${item}`).join("\n");
|
|
2134
|
+
state.modal = {
|
|
2135
|
+
title: `Fila (${pending.length})`,
|
|
2136
|
+
text: `${rows}\n\nDesista de todas com /queue clear ou Ctrl+Q.`,
|
|
2137
|
+
};
|
|
2138
|
+
return render();
|
|
2139
|
+
}
|
|
2140
|
+
return clearQueue();
|
|
2141
|
+
}
|
|
2142
|
+
|
|
1779
2143
|
if (name === "/clear") {
|
|
1780
2144
|
state.messages = [];
|
|
1781
2145
|
state.context = [];
|
|
@@ -1789,16 +2153,9 @@ async function runTUI({
|
|
|
1789
2153
|
state.modal = { title: "Contexto", text: "Nada para compactar." };
|
|
1790
2154
|
return render();
|
|
1791
2155
|
}
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
{ role: "system", text: `Contexto compactado: ${total} mensagens resumidas.` },
|
|
1796
|
-
...state.messages.slice(-6),
|
|
1797
|
-
];
|
|
1798
|
-
// O system prompt do agente (indice 0) continua valendo.
|
|
1799
|
-
state.context = state.context.length ? [state.context[0], ...kept] : [];
|
|
1800
|
-
state.scroll = 0;
|
|
1801
|
-
return render();
|
|
2156
|
+
// Usa o mesmo resumo estruturado da compactacao automatica: descartar
|
|
2157
|
+
// mensagens (comportamento antigo) fazia o agente "esquecer" o trabalho.
|
|
2158
|
+
return compactNow();
|
|
1802
2159
|
}
|
|
1803
2160
|
|
|
1804
2161
|
if (interactiveCommands.has(name)) return runOutside(line);
|
|
@@ -1815,14 +2172,107 @@ async function runTUI({
|
|
|
1815
2172
|
render();
|
|
1816
2173
|
}
|
|
1817
2174
|
|
|
2175
|
+
/**
|
|
2176
|
+
* Desiste das mensagens enfileiradas.
|
|
2177
|
+
*
|
|
2178
|
+
* So mexe na fila: a mensagem que ja esta rodando continua ate o fim (para
|
|
2179
|
+
* parar essa, e Esc duas vezes). Devolve quantas saíram para o aviso.
|
|
2180
|
+
*/
|
|
2181
|
+
/**
|
|
2182
|
+
* `/compact` manual: roda o mesmo resumo da compactacao automatica.
|
|
2183
|
+
*
|
|
2184
|
+
* Forca o corte mesmo abaixo do limiar (o usuario pediu), mas `keepMessages`
|
|
2185
|
+
* garante que as trocas recentes fiquem intactas.
|
|
2186
|
+
*/
|
|
2187
|
+
async function compactNow() {
|
|
2188
|
+
const antes = state.context.length;
|
|
2189
|
+
if (antes <= 2) {
|
|
2190
|
+
state.modal = { title: "Contexto", text: "Conversa curta demais para compactar." };
|
|
2191
|
+
return render();
|
|
2192
|
+
}
|
|
2193
|
+
state.busy = true;
|
|
2194
|
+
state.modal = { title: "Compactando", text: "Resumindo o historico…" };
|
|
2195
|
+
render();
|
|
2196
|
+
try {
|
|
2197
|
+
const result = await compactIfNeeded(state.context, {
|
|
2198
|
+
model: state.model,
|
|
2199
|
+
// 0 forca o resumo: quem digitou /compact quer compactar agora.
|
|
2200
|
+
thresholdTokens: 0,
|
|
2201
|
+
keepMessages: 6,
|
|
2202
|
+
});
|
|
2203
|
+
if (!result.compacted) {
|
|
2204
|
+
state.modal = { title: "Contexto", text: `Nao deu para compactar: ${result.error || "sem conteudo"}` };
|
|
2205
|
+
return render();
|
|
2206
|
+
}
|
|
2207
|
+
state.context = result.messages;
|
|
2208
|
+
// A conversa visivel tambem encolhe, mantendo as ultimas trocas.
|
|
2209
|
+
const visiveis = state.messages.filter((m) => m.role !== "compaction");
|
|
2210
|
+
state.messages = [
|
|
2211
|
+
...visiveis.slice(0, 1),
|
|
2212
|
+
{ role: "compaction", text: result.summary, compaction: { before: result.before, after: result.after } },
|
|
2213
|
+
...visiveis.slice(-6),
|
|
2214
|
+
];
|
|
2215
|
+
state.modal = null;
|
|
2216
|
+
state.scroll = 0;
|
|
2217
|
+
return render();
|
|
2218
|
+
} finally {
|
|
2219
|
+
state.busy = false;
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function clearQueue() {
|
|
2224
|
+
const removed = dropQueue(state);
|
|
2225
|
+
if (removed) render();
|
|
2226
|
+
return removed;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
1818
2229
|
function startMessageText(text) {
|
|
1819
2230
|
state.messages.push({ role: "user", text });
|
|
1820
2231
|
}
|
|
1821
2232
|
|
|
2233
|
+
/**
|
|
2234
|
+
* A conexao voltou: some com o aviso de reconexao do rodape e com as
|
|
2235
|
+
* mensagens de erro de rede pendentes no historico. Sem isso o alerta fica
|
|
2236
|
+
* preso para sempre na tela, mesmo depois do gateway responder de novo.
|
|
2237
|
+
*/
|
|
2238
|
+
function clearRetry() {
|
|
2239
|
+
let changed = false;
|
|
2240
|
+
if (state.retry) {
|
|
2241
|
+
state.retry = null;
|
|
2242
|
+
changed = true;
|
|
2243
|
+
}
|
|
2244
|
+
const before = state.messages.length;
|
|
2245
|
+
state.messages = state.messages.filter((message) => !message.connectionError);
|
|
2246
|
+
if (state.messages.length !== before) changed = true;
|
|
2247
|
+
return changed;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
1822
2250
|
/**
|
|
1823
2251
|
* Enter coloca a linha na fila e a fila anda sozinha: da para mandar
|
|
1824
2252
|
* quantas mensagens quiser enquanto a IA ainda esta respondendo.
|
|
1825
2253
|
*/
|
|
2254
|
+
/**
|
|
2255
|
+
* Enfileira um turno vindo de outro cliente (celular). Mesmo caminho do
|
|
2256
|
+
* teclado: entra na `state.queue` e o `pump` roda. Nao mexe em input/history
|
|
2257
|
+
* do terminal do PC (evita "sujeira" na linha de digitacao do usuario).
|
|
2258
|
+
*/
|
|
2259
|
+
function submitText(text) {
|
|
2260
|
+
const line = String(text || "").trim();
|
|
2261
|
+
if (!line) return false;
|
|
2262
|
+
if (line === "/exit" || line === "/quit") return true;
|
|
2263
|
+
state.queue.push(line);
|
|
2264
|
+
render();
|
|
2265
|
+
pump();
|
|
2266
|
+
return true;
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
/** Aborta o turno corrente (equivalente ao Esc). */
|
|
2270
|
+
function abortTurn() {
|
|
2271
|
+
if (state.approval) settleApproval("deny");
|
|
2272
|
+
state.abort?.abort();
|
|
2273
|
+
render();
|
|
2274
|
+
}
|
|
2275
|
+
|
|
1826
2276
|
function submit() {
|
|
1827
2277
|
const line = state.input.trim();
|
|
1828
2278
|
if (!line) return;
|
|
@@ -1886,10 +2336,12 @@ async function runTUI({
|
|
|
1886
2336
|
mcp,
|
|
1887
2337
|
signal: controller.signal,
|
|
1888
2338
|
onText: (chunk) => {
|
|
2339
|
+
clearRetry();
|
|
1889
2340
|
state.streaming.text += chunk;
|
|
1890
2341
|
scheduleRender();
|
|
1891
2342
|
},
|
|
1892
2343
|
onReasoning: (chunk) => {
|
|
2344
|
+
clearRetry();
|
|
1893
2345
|
if (!state.streaming.thinkingStartedAt) state.streaming.thinkingStartedAt = Date.now();
|
|
1894
2346
|
state.streaming.reasoning += chunk;
|
|
1895
2347
|
scheduleRender();
|
|
@@ -1917,6 +2369,19 @@ async function runTUI({
|
|
|
1917
2369
|
state.retry = { ...info, endsAt: Date.now() + info.delay };
|
|
1918
2370
|
scheduleRender();
|
|
1919
2371
|
},
|
|
2372
|
+
onCompact: ({ before, after, summary }) => {
|
|
2373
|
+
// O historico antigo virou resumo: mostra na conversa para o usuario
|
|
2374
|
+
// saber que o contexto encolheu (e por que a resposta "esqueceu").
|
|
2375
|
+
finishStreaming();
|
|
2376
|
+
state.messages.push({
|
|
2377
|
+
role: "compaction",
|
|
2378
|
+
text: summary || "",
|
|
2379
|
+
compaction: { before, after, at: Date.now() },
|
|
2380
|
+
});
|
|
2381
|
+
state.tokenWarned = false;
|
|
2382
|
+
state.scroll = 0;
|
|
2383
|
+
render();
|
|
2384
|
+
},
|
|
1920
2385
|
onSubagentEvent: (event) => {
|
|
1921
2386
|
const card = [...state.messages].reverse().find((message) => {
|
|
1922
2387
|
if (message.role !== "tool" || !message.tool?.id) return false;
|
|
@@ -1978,19 +2443,27 @@ async function runTUI({
|
|
|
1978
2443
|
text: "Interrompido por você (Esc).",
|
|
1979
2444
|
interrupted: true,
|
|
1980
2445
|
});
|
|
1981
|
-
if (result.messages) state.context = result.messages;
|
|
2446
|
+
if (result.messages) state.context = trimHistory(result.messages);
|
|
1982
2447
|
} else if (result.ok) {
|
|
1983
2448
|
const text = String(result.text || "").trim();
|
|
1984
2449
|
// O stream ja foi commitado em finishStreaming(): so empurra aqui se for
|
|
1985
2450
|
// texto novo (ou se nao houve streaming, ex.: resposta sem onText).
|
|
1986
2451
|
if (text) commitFinalText(state, text, result.ms);
|
|
1987
2452
|
state.streamedText = "";
|
|
1988
|
-
if (result.messages) state.context = result.messages;
|
|
2453
|
+
if (result.messages) state.context = trimHistory(result.messages);
|
|
2454
|
+
// Reconectou de verdade: limpa o alerta e os erros de rede pendentes.
|
|
2455
|
+
clearRetry();
|
|
1989
2456
|
} else {
|
|
2457
|
+
// Erro de rede/servidor entra marcado como transitório: some sozinho
|
|
2458
|
+
// quando a próxima resposta chegar. Erros de verdade (saldo, chave)
|
|
2459
|
+
// continuam fixos porque exigem ação do usuário.
|
|
2460
|
+
const transient = isConnectionError(result.error);
|
|
2461
|
+
if (transient) clearRetry();
|
|
1990
2462
|
state.messages.push({
|
|
1991
2463
|
role: "system",
|
|
1992
2464
|
text: result.error || "Falha ao responder.",
|
|
1993
2465
|
error: true,
|
|
2466
|
+
connectionError: transient,
|
|
1994
2467
|
});
|
|
1995
2468
|
}
|
|
1996
2469
|
state.retry = null;
|
|
@@ -2060,10 +2533,15 @@ async function runTUI({
|
|
|
2060
2533
|
const pending = state.approval;
|
|
2061
2534
|
if (!pending) return;
|
|
2062
2535
|
state.approval = null;
|
|
2063
|
-
|
|
2064
|
-
|
|
2536
|
+
// O pedido de pareamento NAO mexe no autoApprove de ferramentas: "lembrar"
|
|
2537
|
+
// ali significa confiar naquele navegador, tratado por quem criou o pedido.
|
|
2538
|
+
const isPair = pending.name === "__pair__";
|
|
2539
|
+
if (decision === "always" && !isPair) state.autoApprove = true;
|
|
2540
|
+
if (decision === "always" && !isPair) persist();
|
|
2065
2541
|
render();
|
|
2066
|
-
|
|
2542
|
+
// Pareamento precisa do texto da decisao (once/always/deny); ferramentas
|
|
2543
|
+
// so querem o booleano "aprovado?".
|
|
2544
|
+
pending.resolve(isPair ? decision : decision !== "deny");
|
|
2067
2545
|
}
|
|
2068
2546
|
|
|
2069
2547
|
// Com captura de mouse ligada o terminal entrega a roda para a TUI, mas em
|
|
@@ -2143,7 +2621,8 @@ async function runTUI({
|
|
|
2143
2621
|
}
|
|
2144
2622
|
|
|
2145
2623
|
function moveApproval(delta) {
|
|
2146
|
-
const
|
|
2624
|
+
const opts = state.approval.options || APPROVAL_OPTIONS;
|
|
2625
|
+
const count = opts.length;
|
|
2147
2626
|
state.approval.index = (state.approval.index + delta + count) % count;
|
|
2148
2627
|
render();
|
|
2149
2628
|
}
|
|
@@ -2152,12 +2631,19 @@ async function runTUI({
|
|
|
2152
2631
|
if (state.suspended) return;
|
|
2153
2632
|
if (key.ctrl && key.name === "c") return quit();
|
|
2154
2633
|
|
|
2634
|
+
// Ctrl+Q: desiste da fila (a mensagem em andamento continua).
|
|
2635
|
+
if (key.ctrl && key.name === "q") {
|
|
2636
|
+
if (!state.approval && !state.modal) clearQueue();
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2155
2640
|
// Aprovacao de ferramenta: navega e confirma.
|
|
2156
2641
|
if (state.approval) {
|
|
2642
|
+
const opts = state.approval.options || APPROVAL_OPTIONS;
|
|
2157
2643
|
if (key.name === "up" || key.name === "down" || key.name === "tab") {
|
|
2158
2644
|
return moveApproval(key.name === "up" ? -1 : 1);
|
|
2159
2645
|
}
|
|
2160
|
-
if (key.name === "return") return settleApproval(
|
|
2646
|
+
if (key.name === "return") return settleApproval(opts[state.approval.index].id);
|
|
2161
2647
|
if (key.name === "escape") return settleApproval("deny");
|
|
2162
2648
|
if (key.name === "a") return settleApproval("always");
|
|
2163
2649
|
if (key.name === "y") return settleApproval("once");
|
|
@@ -2384,6 +2870,36 @@ async function runTUI({
|
|
|
2384
2870
|
const savedBackground = storage.getConfig().background;
|
|
2385
2871
|
if (savedBackground) setBackground(savedBackground, { quiet: true }).catch(() => {});
|
|
2386
2872
|
|
|
2873
|
+
// `kafox mobile`: a TUI continua sendo o "dono" da sessao; o celular entra
|
|
2874
|
+
// como mais um cliente. O QR aparece sobre a interface, em modal.
|
|
2875
|
+
//
|
|
2876
|
+
// IMPORTANTE (fluxo): se o seletor de conversas foi pedido (`resume: "pick"`),
|
|
2877
|
+
// NAO subimos o servidor agora — so depois que a conversa for escolhida em
|
|
2878
|
+
// resumeChat(). Caso contrario o modal do QR cobriria o seletor e o celular
|
|
2879
|
+
// parearia com a sessao errada/vazia.
|
|
2880
|
+
let mobileStarted = false;
|
|
2881
|
+
function startMobile() {
|
|
2882
|
+
if (!mobile || mobileStarted) return;
|
|
2883
|
+
mobileStarted = true;
|
|
2884
|
+
try {
|
|
2885
|
+
const { attachMobile } = require("./mobile");
|
|
2886
|
+
attachMobile({
|
|
2887
|
+
state,
|
|
2888
|
+
api: { submitText, settleApproval, abortTurn },
|
|
2889
|
+
options: mobile,
|
|
2890
|
+
print: (lines) => {
|
|
2891
|
+
state.modal = { title: "kafox mobile", text: lines.join("\n") };
|
|
2892
|
+
render();
|
|
2893
|
+
},
|
|
2894
|
+
});
|
|
2895
|
+
} catch (error) {
|
|
2896
|
+
state.modal = { title: "kafox mobile", text: `Nao subiu o servidor mobile: ${error.message}` };
|
|
2897
|
+
render();
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
if (!resumePick) startMobile();
|
|
2902
|
+
|
|
2387
2903
|
spinner = setInterval(() => {
|
|
2388
2904
|
if (!state.busy || closed) return;
|
|
2389
2905
|
state.frame += 1;
|
|
@@ -2391,7 +2907,7 @@ async function runTUI({
|
|
|
2391
2907
|
}, 180);
|
|
2392
2908
|
spinner.unref?.();
|
|
2393
2909
|
|
|
2394
|
-
return { state, quit, render, submit };
|
|
2910
|
+
return { state, quit, render, submit, submitText, abortTurn };
|
|
2395
2911
|
}
|
|
2396
2912
|
|
|
2397
2913
|
module.exports = {
|
|
@@ -2411,4 +2927,8 @@ module.exports = {
|
|
|
2411
2927
|
layoutFor,
|
|
2412
2928
|
commitStreamedText,
|
|
2413
2929
|
commitFinalText,
|
|
2930
|
+
dropQueue,
|
|
2931
|
+
queueLines,
|
|
2932
|
+
compactionLines,
|
|
2933
|
+
isConnectionError,
|
|
2414
2934
|
};
|