kafox-cli 0.3.0 → 0.7.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/README.md +130 -11
- package/bin/kafox.js +73 -2
- package/package.json +4 -3
- package/src/agent.js +385 -0
- package/src/auth.js +77 -30
- package/src/cli.js +83 -10
- package/src/diff.js +193 -0
- package/src/gateway.js +235 -48
- package/src/login-screen.js +203 -0
- package/src/logo.js +108 -0
- package/src/mascot.js +3 -31
- package/src/mcp.js +557 -0
- package/src/models.js +1 -4
- package/src/runner.js +98 -0
- package/src/sessions.js +138 -0
- package/src/storage.js +2 -1
- package/src/subagents.js +156 -0
- package/src/theme.js +327 -3
- package/src/tools.js +371 -0
- package/src/tui.js +1707 -213
package/src/tui.js
CHANGED
|
@@ -1,22 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Interface de terminal do kafox, no estilo opencode.
|
|
3
3
|
*
|
|
4
|
-
* -
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
4
|
+
* - primeiro frame: logo, pergunta e caixa de input
|
|
5
|
+
* - conversa rolavel (PgUp/PgDn e roda do mouse), com cards de ferramenta
|
|
6
|
+
* mostrando o diff do que foi editado
|
|
7
|
+
* - "Thinking" aparece quando o modelo manda o raciocinio
|
|
8
|
+
* - aprovacao de ferramenta com tres opcoes (uma vez / sempre / negar),
|
|
9
|
+
* navegavel por teclado ou mouse
|
|
10
|
+
* - o desenho e incremental: so as linhas que mudaram sao reescritas, entao
|
|
11
|
+
* modal e streaming nao piscam
|
|
7
12
|
*/
|
|
8
|
-
const
|
|
13
|
+
const { spawnSync } = require("child_process");
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const os = require("os");
|
|
16
|
+
const path = require("path");
|
|
9
17
|
const chalk = require("chalk");
|
|
10
18
|
const storage = require("./storage");
|
|
11
|
-
const { mascot } = require("./mascot");
|
|
12
19
|
const { ensureSession, fetchApi } = require("./auth");
|
|
13
|
-
const {
|
|
20
|
+
const { runAgent } = require("./agent");
|
|
21
|
+
const { THEMES, themeColors, paintLine, paintPanel, borderColor } = require("./theme");
|
|
22
|
+
const { logoLines, LOGO_WIDTH, LOGO_HEIGHT } = require("./logo");
|
|
23
|
+
const { diffBlocks } = require("./diff");
|
|
24
|
+
const { toolMeta, parseCommandOutput } = require("./tools");
|
|
25
|
+
const { McpHub } = require("./mcp");
|
|
26
|
+
const sessions = require("./sessions");
|
|
14
27
|
|
|
15
28
|
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
16
29
|
const stripAnsi = (value) => String(value).replace(ANSI, "");
|
|
17
30
|
const visible = (value) => stripAnsi(value).length;
|
|
18
31
|
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
19
32
|
|
|
33
|
+
const SIDEBAR_MIN_COLS = 100;
|
|
34
|
+
const MENU_HEIGHT = 6;
|
|
35
|
+
const SYNC_START = "\x1b[?2026h";
|
|
36
|
+
const SYNC_END = "\x1b[?2026l";
|
|
37
|
+
|
|
38
|
+
const APPROVAL_OPTIONS = [
|
|
39
|
+
{ id: "once", label: "Permitir uma vez" },
|
|
40
|
+
{ id: "always", label: "Permitir sempre (rodar tudo nesta sessão)" },
|
|
41
|
+
{ id: "deny", label: "Negar" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const KIND_COLORS = {
|
|
45
|
+
read: chalk.cyan,
|
|
46
|
+
list: chalk.cyanBright,
|
|
47
|
+
write: chalk.green,
|
|
48
|
+
edit: chalk.yellow,
|
|
49
|
+
bash: chalk.blueBright,
|
|
50
|
+
mcp: chalk.magenta,
|
|
51
|
+
tool: chalk.white,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const KIND_LABELS = {
|
|
55
|
+
read: "Read",
|
|
56
|
+
list: "List",
|
|
57
|
+
write: "Write",
|
|
58
|
+
edit: "Edit",
|
|
59
|
+
bash: "Bash",
|
|
60
|
+
mcp: "MCP",
|
|
61
|
+
tool: "Tool",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Spinner do "rodando" dentro da janela de terminal da ferramenta.
|
|
65
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸"];
|
|
66
|
+
const TERM_INDENT = 4;
|
|
67
|
+
const MAX_CARD_OUTPUT = 6;
|
|
68
|
+
|
|
20
69
|
function wrap(text, width) {
|
|
21
70
|
const max = Math.max(10, width);
|
|
22
71
|
const lines = [];
|
|
@@ -46,8 +95,8 @@ function wrap(text, width) {
|
|
|
46
95
|
function truncate(text, width) {
|
|
47
96
|
const plain = stripAnsi(text);
|
|
48
97
|
if (plain.length <= width) return text;
|
|
49
|
-
if (width <= 3) return plain.slice(0, width);
|
|
50
|
-
return plain.slice(0, width -
|
|
98
|
+
if (width <= 3) return plain.slice(0, Math.max(0, width));
|
|
99
|
+
return plain.slice(0, width - 1) + "…";
|
|
51
100
|
}
|
|
52
101
|
|
|
53
102
|
function padRight(text, width) {
|
|
@@ -60,8 +109,16 @@ function center(text, width) {
|
|
|
60
109
|
return " ".repeat(pad) + text;
|
|
61
110
|
}
|
|
62
111
|
|
|
112
|
+
const indent = (size) => " ".repeat(Math.max(0, size));
|
|
113
|
+
|
|
63
114
|
/* ----------------------------------------------------------------- markdown */
|
|
64
115
|
|
|
116
|
+
function accentFor(theme) {
|
|
117
|
+
const { accent } = themeColors(theme);
|
|
118
|
+
if (typeof chalk[accent] === "function") return chalk[accent];
|
|
119
|
+
return chalk.hex(accent);
|
|
120
|
+
}
|
|
121
|
+
|
|
65
122
|
const INLINE = /(\*\*[^*]+\*\*|`[^`]+`|\*[^*\n]+\*)/g;
|
|
66
123
|
|
|
67
124
|
function parseInline(text) {
|
|
@@ -122,20 +179,22 @@ function wrapSegments(segments, width) {
|
|
|
122
179
|
return lines;
|
|
123
180
|
}
|
|
124
181
|
|
|
125
|
-
function renderSegments(segments) {
|
|
182
|
+
function renderSegments(segments, theme = "default") {
|
|
183
|
+
const accent = accentFor(theme);
|
|
126
184
|
return segments
|
|
127
185
|
.map((segment) => {
|
|
128
186
|
let text = segment.text;
|
|
129
187
|
if (segment.code) text = chalk.cyan(text);
|
|
130
188
|
if (segment.italic) text = chalk.italic(text);
|
|
131
|
-
if (segment.bold) text = chalk.bold(text);
|
|
189
|
+
if (segment.bold) text = accent(chalk.bold(text));
|
|
132
190
|
return text;
|
|
133
191
|
})
|
|
134
192
|
.join("");
|
|
135
193
|
}
|
|
136
194
|
|
|
137
195
|
/** Converte Markdown simples em linhas prontas para o terminal. */
|
|
138
|
-
function markdownLines(text, width) {
|
|
196
|
+
function markdownLines(text, width, theme = "default") {
|
|
197
|
+
const accent = accentFor(theme);
|
|
139
198
|
const out = [];
|
|
140
199
|
const rows = String(text).replace(/\r/g, "").split("\n");
|
|
141
200
|
let inCode = false;
|
|
@@ -144,11 +203,11 @@ function markdownLines(text, width) {
|
|
|
144
203
|
const line = raw.replace(/\s+$/, "");
|
|
145
204
|
if (/^\s*```/.test(line)) {
|
|
146
205
|
inCode = !inCode;
|
|
147
|
-
out.push(chalk.dim(
|
|
206
|
+
out.push(chalk.dim(indent(2) + "─".repeat(Math.min(width - 2, 24))));
|
|
148
207
|
continue;
|
|
149
208
|
}
|
|
150
209
|
if (inCode) {
|
|
151
|
-
out.push(chalk.
|
|
210
|
+
out.push(chalk.dim(indent(2) + "│ ") + chalk.cyan(truncate(line, width - 4)));
|
|
152
211
|
continue;
|
|
153
212
|
}
|
|
154
213
|
if (!line.trim()) {
|
|
@@ -156,7 +215,7 @@ function markdownLines(text, width) {
|
|
|
156
215
|
continue;
|
|
157
216
|
}
|
|
158
217
|
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
159
|
-
out.push(chalk.dim(
|
|
218
|
+
out.push(chalk.dim(indent(2) + "─".repeat(Math.min(width - 2, 40))));
|
|
160
219
|
continue;
|
|
161
220
|
}
|
|
162
221
|
|
|
@@ -164,7 +223,7 @@ function markdownLines(text, width) {
|
|
|
164
223
|
if (heading) {
|
|
165
224
|
if (out.length) out.push("");
|
|
166
225
|
for (const wrapped of wrapSegments(parseInline(heading[1]), width - 2)) {
|
|
167
|
-
out.push(
|
|
226
|
+
out.push(indent(2) + accent(chalk.bold(renderSegments(wrapped, theme))));
|
|
168
227
|
}
|
|
169
228
|
out.push("");
|
|
170
229
|
continue;
|
|
@@ -172,9 +231,9 @@ function markdownLines(text, width) {
|
|
|
172
231
|
|
|
173
232
|
const bullet = line.match(/^\s*[-*+]\s+(.*)$/);
|
|
174
233
|
if (bullet) {
|
|
175
|
-
const wrapped = wrapSegments(parseInline(bullet[1]), width -
|
|
234
|
+
const wrapped = wrapSegments(parseInline(bullet[1]), width - 5);
|
|
176
235
|
wrapped.forEach((part, index) => {
|
|
177
|
-
out.push((index === 0 ? "
|
|
236
|
+
out.push((index === 0 ? indent(2) + "• " : indent(4)) + renderSegments(part, theme));
|
|
178
237
|
});
|
|
179
238
|
continue;
|
|
180
239
|
}
|
|
@@ -182,16 +241,16 @@ function markdownLines(text, width) {
|
|
|
182
241
|
const numbered = line.match(/^\s*(\d+)[.)]\s+(.*)$/);
|
|
183
242
|
if (numbered) {
|
|
184
243
|
const wrapped = wrapSegments(parseInline(numbered[2]), width - 6);
|
|
185
|
-
wrapped.forEach((part
|
|
186
|
-
out.push((
|
|
244
|
+
wrapped.forEach((part) => {
|
|
245
|
+
out.push(indent(2) + `${numbered[1]}. ` + renderSegments(part, theme));
|
|
187
246
|
});
|
|
188
247
|
continue;
|
|
189
248
|
}
|
|
190
249
|
|
|
191
250
|
const quote = line.match(/^\s*>\s?(.*)$/);
|
|
192
251
|
if (quote) {
|
|
193
|
-
for (const part of wrapSegments(parseInline(quote[1]), width -
|
|
194
|
-
out.push(chalk.dim("
|
|
252
|
+
for (const part of wrapSegments(parseInline(quote[1]), width - 5)) {
|
|
253
|
+
out.push(chalk.dim(indent(2) + "│ " + stripAnsi(renderSegments(part, theme))));
|
|
195
254
|
}
|
|
196
255
|
continue;
|
|
197
256
|
}
|
|
@@ -199,13 +258,225 @@ function markdownLines(text, width) {
|
|
|
199
258
|
// Modelos costumam indentar paragrafos; no terminal isso vira ruim.
|
|
200
259
|
const clean = line.replace(/^\s+(?=\S)/, "");
|
|
201
260
|
for (const part of wrapSegments(parseInline(clean), width - 2)) {
|
|
202
|
-
out.push(
|
|
261
|
+
out.push(indent(2) + renderSegments(part, theme));
|
|
203
262
|
}
|
|
204
263
|
}
|
|
205
264
|
return out;
|
|
206
265
|
}
|
|
207
266
|
|
|
208
|
-
/*
|
|
267
|
+
/* -------------------------------------------------------------------- teclado */
|
|
268
|
+
|
|
269
|
+
const KEY_MAP = {
|
|
270
|
+
"\x1b[A": "up",
|
|
271
|
+
"\x1b[B": "down",
|
|
272
|
+
"\x1b[C": "right",
|
|
273
|
+
"\x1b[D": "left",
|
|
274
|
+
"\x1bOA": "up",
|
|
275
|
+
"\x1bOB": "down",
|
|
276
|
+
"\x1bOC": "right",
|
|
277
|
+
"\x1bOD": "left",
|
|
278
|
+
"\x1b[H": "home",
|
|
279
|
+
"\x1bOH": "home",
|
|
280
|
+
"\x1b[F": "end",
|
|
281
|
+
"\x1bOF": "end",
|
|
282
|
+
"\x1b[1~": "home",
|
|
283
|
+
"\x1b[4~": "end",
|
|
284
|
+
"\x1b[3~": "delete",
|
|
285
|
+
"\x1b[5~": "pageup",
|
|
286
|
+
"\x1b[6~": "pagedown",
|
|
287
|
+
"\x1b[Z": "tab",
|
|
288
|
+
"\x1bOP": "f1",
|
|
289
|
+
"\x1bOQ": "f2",
|
|
290
|
+
"\x1bOR": "f3",
|
|
291
|
+
"\x1bOS": "f4",
|
|
292
|
+
"\x1b[11~": "f1",
|
|
293
|
+
"\x1b[12~": "f2",
|
|
294
|
+
"\x1b[13~": "f3",
|
|
295
|
+
"\x1b[14~": "f4",
|
|
296
|
+
};
|
|
297
|
+
const KEY_SEQUENCES = Object.keys(KEY_MAP).sort((a, b) => b.length - a.length);
|
|
298
|
+
|
|
299
|
+
const CONTROL_KEYS = {
|
|
300
|
+
3: { name: "c", ctrl: true },
|
|
301
|
+
4: { name: "d", ctrl: true },
|
|
302
|
+
8: { name: "backspace" },
|
|
303
|
+
9: { name: "tab" },
|
|
304
|
+
10: { name: "return" },
|
|
305
|
+
12: { name: "l", ctrl: true },
|
|
306
|
+
13: { name: "return" },
|
|
307
|
+
21: { name: "u", ctrl: true },
|
|
308
|
+
23: { name: "w", ctrl: true },
|
|
309
|
+
25: { name: "y", ctrl: true },
|
|
310
|
+
127: { name: "backspace" },
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]/;
|
|
314
|
+
const PARTIAL_MOUSE_RE = /^\x1b\[<[\d;]*$/;
|
|
315
|
+
|
|
316
|
+
/** Decodifica o que chegou do stdin no formato (str, key) usado pela TUI. */
|
|
317
|
+
function decodeKeys(data) {
|
|
318
|
+
const events = [];
|
|
319
|
+
let index = 0;
|
|
320
|
+
|
|
321
|
+
while (index < data.length) {
|
|
322
|
+
const rest = data.slice(index);
|
|
323
|
+
|
|
324
|
+
const mouse = rest.match(MOUSE_RE);
|
|
325
|
+
if (mouse) {
|
|
326
|
+
events.push({ str: mouse[0], key: { sequence: mouse[0] } });
|
|
327
|
+
index += mouse[0].length;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (PARTIAL_MOUSE_RE.test(rest)) break;
|
|
331
|
+
|
|
332
|
+
const sequence = KEY_SEQUENCES.find((candidate) => rest.startsWith(candidate));
|
|
333
|
+
if (sequence) {
|
|
334
|
+
events.push({ str: undefined, key: { name: KEY_MAP[sequence], sequence } });
|
|
335
|
+
index += sequence.length;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
// Sequencia incompleta: espera o resto chegar.
|
|
339
|
+
if (KEY_SEQUENCES.some((candidate) => candidate.startsWith(rest))) break;
|
|
340
|
+
if (rest === "\x1b") break;
|
|
341
|
+
|
|
342
|
+
if (rest[0] === "\x1b") {
|
|
343
|
+
events.push({ str: undefined, key: { name: "escape", sequence: "\x1b" } });
|
|
344
|
+
index += 1;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const code = rest.charCodeAt(0);
|
|
349
|
+
if (code < 32 || code === 127) {
|
|
350
|
+
const entry = CONTROL_KEYS[code];
|
|
351
|
+
events.push({
|
|
352
|
+
str: entry?.ctrl ? "" : undefined,
|
|
353
|
+
key: { name: entry?.name, ctrl: !!entry?.ctrl, sequence: rest[0] },
|
|
354
|
+
});
|
|
355
|
+
index += 1;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let end = index;
|
|
360
|
+
while (end < data.length) {
|
|
361
|
+
const charCode = data.charCodeAt(end);
|
|
362
|
+
if (charCode < 32 || charCode === 127 || data[end] === "\x1b") break;
|
|
363
|
+
end += 1;
|
|
364
|
+
}
|
|
365
|
+
events.push({ str: data.slice(index, end), key: { sequence: data.slice(index, end) } });
|
|
366
|
+
index = end;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { events, rest: data.slice(index) };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const PASTE_START = "\x1b[200~";
|
|
373
|
+
const PASTE_END = "\x1b[201~";
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Leitor do stdin cru: junta pedacos de escape sequence, trata colagem
|
|
377
|
+
* (bracketed paste) como texto e entrega eventos (str, key) para a TUI.
|
|
378
|
+
*/
|
|
379
|
+
function createKeyReader(onEvent, { escapeDelay = 40 } = {}) {
|
|
380
|
+
let pending = "";
|
|
381
|
+
let pasting = false;
|
|
382
|
+
let timer = null;
|
|
383
|
+
|
|
384
|
+
const armEscape = () => {
|
|
385
|
+
if (timer) {
|
|
386
|
+
clearTimeout(timer);
|
|
387
|
+
timer = null;
|
|
388
|
+
}
|
|
389
|
+
if (pending !== "\x1b") return;
|
|
390
|
+
timer = setTimeout(() => {
|
|
391
|
+
timer = null;
|
|
392
|
+
if (pending !== "\x1b") return;
|
|
393
|
+
pending = "";
|
|
394
|
+
onEvent({ str: undefined, key: { name: "escape", sequence: "\x1b" } });
|
|
395
|
+
}, escapeDelay);
|
|
396
|
+
timer.unref?.();
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
feed(chunk) {
|
|
401
|
+
pending += String(chunk);
|
|
402
|
+
let guard = 0;
|
|
403
|
+
|
|
404
|
+
while (pending.length && guard < 4096) {
|
|
405
|
+
guard += 1;
|
|
406
|
+
|
|
407
|
+
if (pasting) {
|
|
408
|
+
const end = pending.indexOf(PASTE_END);
|
|
409
|
+
if (end === -1) {
|
|
410
|
+
if (pending.length > PASTE_END.length) {
|
|
411
|
+
const cut = pending.length - PASTE_END.length;
|
|
412
|
+
onEvent({ str: pending.slice(0, cut).replace(/[\r\n]+/g, " "), key: {} });
|
|
413
|
+
pending = pending.slice(cut);
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
onEvent({ str: pending.slice(0, end).replace(/[\r\n]+/g, " "), key: {} });
|
|
418
|
+
pending = pending.slice(end + PASTE_END.length);
|
|
419
|
+
pasting = false;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const start = pending.indexOf(PASTE_START);
|
|
424
|
+
const { events, rest } = decodeKeys(start === -1 ? pending : pending.slice(0, start));
|
|
425
|
+
for (const event of events) onEvent(event);
|
|
426
|
+
|
|
427
|
+
if (start === -1) {
|
|
428
|
+
pending = rest;
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
pending = pending.slice(start + PASTE_START.length);
|
|
432
|
+
pasting = true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
armEscape();
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/* --------------------------------------------------------------- area de transferencia */
|
|
441
|
+
|
|
442
|
+
function copyToClipboard(text) {
|
|
443
|
+
try {
|
|
444
|
+
if (process.platform === "win32") {
|
|
445
|
+
// clip.exe depende do code page do console (estraga acentos); o arquivo
|
|
446
|
+
// temporario em UTF-8 resolve isso sem limite de tamanho.
|
|
447
|
+
const file = path.join(os.tmpdir(), `kafox-copy-${process.pid}-${Date.now()}.txt`);
|
|
448
|
+
try {
|
|
449
|
+
fs.writeFileSync(file, text, "utf8");
|
|
450
|
+
const script = `Set-Clipboard -Value (Get-Content -Raw -Encoding UTF8 '${file}')`;
|
|
451
|
+
const result = spawnSync(
|
|
452
|
+
"powershell",
|
|
453
|
+
["-NoProfile", "-NonInteractive", "-Command", script],
|
|
454
|
+
{ timeout: 10000, windowsHide: true }
|
|
455
|
+
);
|
|
456
|
+
return result.status === 0;
|
|
457
|
+
} finally {
|
|
458
|
+
try {
|
|
459
|
+
fs.unlinkSync(file);
|
|
460
|
+
} catch {}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const attempts =
|
|
464
|
+
process.platform === "darwin"
|
|
465
|
+
? [["pbcopy", []]]
|
|
466
|
+
: [
|
|
467
|
+
["wl-copy", []],
|
|
468
|
+
["xclip", ["-selection", "clipboard"]],
|
|
469
|
+
["xsel", ["--clipboard", "--input"]],
|
|
470
|
+
];
|
|
471
|
+
for (const [command, args] of attempts) {
|
|
472
|
+
const result = spawnSync(command, args, { input: text, timeout: 5000 });
|
|
473
|
+
if (result.status === 0) return true;
|
|
474
|
+
}
|
|
475
|
+
} catch {}
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/* ------------------------------------------------------------------- estado */
|
|
209
480
|
|
|
210
481
|
function initialState() {
|
|
211
482
|
const cfg = storage.getConfig();
|
|
@@ -215,16 +486,32 @@ function initialState() {
|
|
|
215
486
|
history: [],
|
|
216
487
|
historyIndex: -1,
|
|
217
488
|
messages: [],
|
|
489
|
+
context: [],
|
|
490
|
+
streaming: null,
|
|
491
|
+
approval: null,
|
|
492
|
+
autoApprove: false,
|
|
218
493
|
busy: false,
|
|
219
494
|
frame: 0,
|
|
495
|
+
queue: [],
|
|
496
|
+
draining: false,
|
|
497
|
+
escArmed: false,
|
|
498
|
+
abort: null,
|
|
499
|
+
|
|
220
500
|
menuIndex: 0,
|
|
221
501
|
menuDismissed: false,
|
|
222
502
|
picker: null,
|
|
223
503
|
modal: null,
|
|
224
504
|
models: [],
|
|
225
505
|
balance: null,
|
|
506
|
+
tokens: 0,
|
|
507
|
+
files: [],
|
|
508
|
+
mcp: { loading: false, statuses: [], tools: 0 },
|
|
226
509
|
scroll: 0,
|
|
227
510
|
pageSize: 10,
|
|
511
|
+
mouse: true,
|
|
512
|
+
retry: null,
|
|
513
|
+
sessionId: null,
|
|
514
|
+
sessionStartedAt: null,
|
|
228
515
|
model: cfg.model || null,
|
|
229
516
|
theme: cfg.theme || "default",
|
|
230
517
|
suspended: false,
|
|
@@ -232,27 +519,287 @@ function initialState() {
|
|
|
232
519
|
};
|
|
233
520
|
}
|
|
234
521
|
|
|
235
|
-
|
|
236
|
-
|
|
522
|
+
/* ------------------------------------------------------------------- layout */
|
|
523
|
+
|
|
524
|
+
function layoutFor(state, cols) {
|
|
525
|
+
const sidebar =
|
|
526
|
+
cols >= SIDEBAR_MIN_COLS && (state.messages.length > 0 || state.streaming) ? Math.min(34, Math.floor(cols * 0.3)) : 0;
|
|
527
|
+
const main = cols - sidebar;
|
|
528
|
+
return { sidebar, main, inner: Math.max(20, main - 2) };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function border(state, text) {
|
|
532
|
+
return borderColor(state.theme)(text);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function rule(state, width) {
|
|
536
|
+
return border(state, "─".repeat(Math.max(0, width)));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function dot(state, status) {
|
|
540
|
+
if (status === "connected") return chalk.green("●");
|
|
541
|
+
if (status === "error") return chalk.red("●");
|
|
542
|
+
if (status === "connecting") return chalk.yellow("●");
|
|
543
|
+
return chalk.dim("○");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function heroActive(state) {
|
|
547
|
+
return state.messages.length === 0 && !state.streaming && !state.picker && !state.modal && !state.approval;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function heroLines(state, width, available) {
|
|
551
|
+
const sections = [];
|
|
552
|
+
const logo = logoLines(state.theme);
|
|
553
|
+
const logoWidth = Math.max(...logo.map((line) => visible(line)));
|
|
554
|
+
|
|
555
|
+
if (width >= logoWidth + 2 && available >= LOGO_HEIGHT + 9) {
|
|
556
|
+
sections.push({ gap: true, lines: logo.map((line) => center(line, width)) });
|
|
557
|
+
}
|
|
558
|
+
sections.push({ lines: [""] });
|
|
559
|
+
sections.push({
|
|
560
|
+
lines: [
|
|
561
|
+
center(chalk.bold("O que voce quer fazer?"), width),
|
|
562
|
+
center(chalk.dim('"cria um hello world em python"'), width),
|
|
563
|
+
],
|
|
564
|
+
});
|
|
565
|
+
sections.push({
|
|
566
|
+
lines: [
|
|
567
|
+
"",
|
|
568
|
+
center(
|
|
569
|
+
chalk.dim("tab") +
|
|
570
|
+
" comandos " +
|
|
571
|
+
chalk.dim("enter") +
|
|
572
|
+
" envia " +
|
|
573
|
+
chalk.dim("/") +
|
|
574
|
+
" menu " +
|
|
575
|
+
chalk.dim("ctrl+c") +
|
|
576
|
+
" sai",
|
|
577
|
+
width
|
|
578
|
+
),
|
|
579
|
+
],
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const out = [];
|
|
583
|
+
for (const section of sections) {
|
|
584
|
+
if (available && out.length + section.lines.length > available) break;
|
|
585
|
+
out.push(...section.lines);
|
|
586
|
+
if (section.gap) out.push("");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Um respiro em cima deixa o bloco no meio em vez de colado na primeira linha.
|
|
590
|
+
const grow = Math.max(0, available - out.length);
|
|
591
|
+
const top = Math.min(Math.floor(grow / 3), Math.max(0, Math.floor((available - 1) / 4)));
|
|
592
|
+
return [...Array(top).fill(""), ...out].slice(0, Math.max(0, available || out.length));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/* --------------------------------------------------------------------- cards */
|
|
596
|
+
|
|
597
|
+
function diffCardLines(change, width) {
|
|
598
|
+
const lines = [];
|
|
599
|
+
if (!change || change.error) return lines;
|
|
600
|
+
const before = change.before ?? "";
|
|
601
|
+
const after = change.after ?? "";
|
|
602
|
+
|
|
603
|
+
if (change.kind === "create") {
|
|
604
|
+
const rows = String(after).replace(/\n$/, "").split("\n");
|
|
605
|
+
rows.slice(0, 5).forEach((row, index) => {
|
|
606
|
+
lines.push(
|
|
607
|
+
indent(4) +
|
|
608
|
+
chalk.dim(String(index + 1).padStart(4)) +
|
|
609
|
+
" " +
|
|
610
|
+
chalk.green("+ " + truncate(row, width - 12))
|
|
611
|
+
);
|
|
612
|
+
});
|
|
613
|
+
if (rows.length > 5) lines.push(indent(8) + chalk.dim(`... +${rows.length - 5} linhas`));
|
|
614
|
+
return lines;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const { blocks, added, removed, truncated } = diffBlocks(before, after, {
|
|
618
|
+
context: 1,
|
|
619
|
+
maxLines: 14,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
for (const block of blocks) {
|
|
623
|
+
for (const entry of block) {
|
|
624
|
+
if (entry.type === "gap") {
|
|
625
|
+
lines.push(indent(8) + chalk.dim("⋯"));
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
const number = entry.afterNo ?? entry.beforeNo ?? "";
|
|
629
|
+
const gutter = String(number).padStart(4);
|
|
630
|
+
if (entry.type === "add") {
|
|
631
|
+
lines.push(indent(4) + chalk.dim(gutter) + " " + chalk.green("+ " + truncate(entry.text, width - 12)));
|
|
632
|
+
} else if (entry.type === "del") {
|
|
633
|
+
lines.push(indent(4) + chalk.dim(gutter) + " " + chalk.red("- " + truncate(entry.text, width - 12)));
|
|
634
|
+
} else {
|
|
635
|
+
lines.push(indent(4) + chalk.dim(gutter + " " + truncate(entry.text, width - 12)));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
lines.push(
|
|
641
|
+
indent(6) +
|
|
642
|
+
chalk.green(`+${added}`) +
|
|
643
|
+
" " +
|
|
644
|
+
chalk.red(`-${removed}`) +
|
|
645
|
+
(truncated ? chalk.dim(" (diff simplificado)") : "")
|
|
646
|
+
);
|
|
647
|
+
return lines;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/* ------------------------------- janela de terminal (ferramentas que rodam) */
|
|
651
|
+
|
|
652
|
+
function termWidth(width) {
|
|
653
|
+
return Math.max(24, width - TERM_INDENT);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function termTop(state, label, width) {
|
|
657
|
+
const box = termWidth(width);
|
|
658
|
+
const head = `─ ${label} `;
|
|
659
|
+
return (
|
|
660
|
+
indent(TERM_INDENT) +
|
|
661
|
+
border(state, `╭${head}${"─".repeat(Math.max(0, box - 2 - visible(head)))}╮`)
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function termBottom(state, status, width) {
|
|
666
|
+
const box = termWidth(width);
|
|
667
|
+
const head = `─ ${status} `;
|
|
668
|
+
return (
|
|
669
|
+
indent(TERM_INDENT) +
|
|
670
|
+
border(state, `╰${head}${"─".repeat(Math.max(0, box - 2 - visible(head)))}╯`)
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function termRow(state, text, width) {
|
|
675
|
+
const box = termWidth(width);
|
|
676
|
+
const inner = box - 4;
|
|
677
|
+
return indent(TERM_INDENT) + border(state, "│ ") + padRight(text, inner) + border(state, " │");
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function elapsedLabel(tool) {
|
|
681
|
+
if (!tool.startedAt) return "";
|
|
682
|
+
const seconds = (Date.now() - tool.startedAt) / 1000;
|
|
683
|
+
return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
|
|
684
|
+
}
|
|
237
685
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
686
|
+
function termStatus(state, tool, parsed) {
|
|
687
|
+
if (tool.status === "running") {
|
|
688
|
+
const spinner = chalk.yellow(SPINNER[state.frame % SPINNER.length]);
|
|
689
|
+
const seconds = elapsedLabel(tool);
|
|
690
|
+
return `${spinner} ${chalk.yellow("rodando")}${seconds ? chalk.dim(` ${seconds}`) : ""}`;
|
|
691
|
+
}
|
|
692
|
+
if (tool.skipped) return chalk.dim("negado por você");
|
|
693
|
+
const seconds = tool.ms ? ` · ${(tool.ms / 1000).toFixed(1)}s` : "";
|
|
694
|
+
if (parsed?.timedOut) return chalk.red("timeout") + chalk.dim(seconds);
|
|
695
|
+
if (parsed && parsed.exit != null) {
|
|
696
|
+
const code = String(parsed.exit);
|
|
697
|
+
return (parsed.exit === 0 && tool.ok ? chalk.green : chalk.red)(`exit ${code}`) + chalk.dim(seconds);
|
|
698
|
+
}
|
|
699
|
+
return (tool.ok ? chalk.green : chalk.red)(tool.ok ? "ok" : "erro") + chalk.dim(seconds);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function outputRows(state, tool, width) {
|
|
703
|
+
const parsed = tool.change?.kind === "command" ? parseCommandOutput(tool.output || "") : null;
|
|
704
|
+
const rows = [];
|
|
705
|
+
|
|
706
|
+
if (parsed) {
|
|
707
|
+
for (const line of parsed.stdout) rows.push({ text: line });
|
|
708
|
+
for (const line of parsed.stderr) rows.push({ text: line, stderr: true });
|
|
709
|
+
} else {
|
|
710
|
+
for (const line of String(tool.output || "").split("\n")) rows.push({ text: line });
|
|
711
|
+
}
|
|
245
712
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
713
|
+
const body = rows.filter((row, index) => row.text.length || index < rows.length - 1);
|
|
714
|
+
const shown = body.slice(0, MAX_CARD_OUTPUT);
|
|
715
|
+
const lines = shown.map((row) => termRow(state, row.stderr ? chalk.red(row.text) : row.text, width));
|
|
716
|
+
if (body.length > shown.length) {
|
|
717
|
+
lines.push(termRow(state, chalk.dim(`… +${body.length - shown.length} linhas`), width));
|
|
249
718
|
}
|
|
250
|
-
|
|
251
|
-
|
|
719
|
+
return { lines, parsed };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Bash: janela de terminal com o comando, a saida e o status. */
|
|
723
|
+
function commandCardLines(state, tool, width) {
|
|
724
|
+
const command = tool.change?.command || tool.label || "";
|
|
725
|
+
const lines = [termTop(state, KIND_COLORS.bash("bash"), width)];
|
|
726
|
+
const commandWidth = termWidth(width) - 6;
|
|
727
|
+
|
|
728
|
+
wrap(command, commandWidth)
|
|
729
|
+
.slice(0, 3)
|
|
730
|
+
.forEach((row, index) => {
|
|
731
|
+
lines.push(termRow(state, chalk.bold((index === 0 ? "$ " : " ") + row), width));
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
const { lines: output, parsed } = outputRows(state, tool, width);
|
|
735
|
+
if (output.length) {
|
|
736
|
+
lines.push(termRow(state, "", width));
|
|
737
|
+
lines.push(...output);
|
|
252
738
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return
|
|
739
|
+
|
|
740
|
+
lines.push(termBottom(state, termStatus(state, tool, parsed), width));
|
|
741
|
+
return lines;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** MCP: mesma janela, com os argumentos e o resultado da ferramenta remota. */
|
|
745
|
+
function mcpCardLines(state, tool, width) {
|
|
746
|
+
const meta = tool.meta || {};
|
|
747
|
+
const title = truncate(`${meta.server || "mcp"} / ${meta.tool || tool.name}`, termWidth(width) - 8);
|
|
748
|
+
const lines = [termTop(state, KIND_COLORS.mcp(`mcp · ${title}`), width)];
|
|
749
|
+
const args = JSON.stringify(tool.args || {});
|
|
750
|
+
|
|
751
|
+
wrap(`$ ${args}`, termWidth(width) - 6)
|
|
752
|
+
.slice(0, 2)
|
|
753
|
+
.forEach((row) => lines.push(termRow(state, chalk.bold(row), width)));
|
|
754
|
+
|
|
755
|
+
const { lines: output, parsed } = outputRows(state, tool, width);
|
|
756
|
+
if (output.length) {
|
|
757
|
+
lines.push(termRow(state, "", width));
|
|
758
|
+
lines.push(...output);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
lines.push(termBottom(state, termStatus(state, tool, parsed), width));
|
|
762
|
+
return lines;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function toolCardLines(message, width, state) {
|
|
766
|
+
const tool = message.tool || {};
|
|
767
|
+
const meta = tool.meta || { kind: "tool", label: KIND_LABELS[tool.kind] || tool.name || "Tool" };
|
|
768
|
+
|
|
769
|
+
if (meta.kind === "bash") return ["", ...commandCardLines(state, tool, width)];
|
|
770
|
+
if (meta.kind === "mcp") return ["", ...mcpCardLines(state, tool, width)];
|
|
771
|
+
|
|
772
|
+
const paint = KIND_COLORS[meta.kind] || chalk.white;
|
|
773
|
+
const label = paint((meta.label || KIND_LABELS[meta.kind] || "Tool").padEnd(5));
|
|
774
|
+
const target = tool.label || "";
|
|
775
|
+
|
|
776
|
+
const lines = ["", indent(2) + chalk.dim("• ") + label + " " + chalk.bold(truncate(target, width - 12))];
|
|
777
|
+
|
|
778
|
+
if (tool.change) {
|
|
779
|
+
lines.push(...diffCardLines(tool.change, width));
|
|
780
|
+
if (tool.change.error) lines.push(indent(8) + chalk.red(truncate(tool.change.error, width - 10)));
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (tool.status === "running") {
|
|
784
|
+
lines.push(indent(8) + chalk.dim("rodando…"));
|
|
785
|
+
} else if (tool.status === "done") {
|
|
786
|
+
const summary = tool.summary || "(sem saida)";
|
|
787
|
+
lines.push(
|
|
788
|
+
indent(8) +
|
|
789
|
+
(tool.skipped ? chalk.dim("⎿ ") : tool.ok ? chalk.dim("⎿ ") : chalk.red("⎿ ")) +
|
|
790
|
+
(tool.ok ? chalk.dim(summary) : chalk.red(summary))
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return lines;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function thinkingLines(message, width) {
|
|
798
|
+
const lines = ["", indent(2) + chalk.dim("▾ Thinking") + chalk.dim(message.ms ? ` ${(message.ms / 1000).toFixed(1)}s` : "")];
|
|
799
|
+
const rows = wrap(String(message.text || "").trim(), width - 6);
|
|
800
|
+
for (const row of rows.slice(0, 8)) lines.push(indent(6) + chalk.dim.italic(row));
|
|
801
|
+
if (rows.length > 8) lines.push(indent(6) + chalk.dim(`... +${rows.length - 8} linhas`));
|
|
802
|
+
return lines;
|
|
256
803
|
}
|
|
257
804
|
|
|
258
805
|
function messageLines(state, width) {
|
|
@@ -261,23 +808,54 @@ function messageLines(state, width) {
|
|
|
261
808
|
if (message.role === "user") {
|
|
262
809
|
lines.push("");
|
|
263
810
|
const body = wrap(message.text, width - 2);
|
|
264
|
-
lines.push(chalk.cyan("
|
|
811
|
+
lines.push(indent(2) + chalk.cyan("▌ ") + chalk.bold(body[0] || ""));
|
|
265
812
|
for (let index = 1; index < body.length; index += 1) {
|
|
266
|
-
lines.push(
|
|
813
|
+
lines.push(indent(4) + chalk.bold(body[index]));
|
|
267
814
|
}
|
|
268
815
|
} else if (message.role === "assistant") {
|
|
269
816
|
lines.push("");
|
|
270
|
-
lines.push(...markdownLines(message.text, width - 2));
|
|
271
|
-
|
|
817
|
+
lines.push(...markdownLines(message.text, width - 2, state.theme));
|
|
818
|
+
const meta = [];
|
|
819
|
+
if (message.interrupted) meta.push("interrompido");
|
|
820
|
+
if (message.ms) meta.push(`${(message.ms / 1000).toFixed(1)}s`);
|
|
821
|
+
if (message.provider) meta.push(message.provider);
|
|
822
|
+
if (meta.length) lines.push("");
|
|
823
|
+
if (meta.length) lines.push(indent(2) + chalk.dim(meta.join(" · ")));
|
|
824
|
+
} else if (message.role === "thinking") {
|
|
825
|
+
lines.push(...thinkingLines(message, width));
|
|
826
|
+
} else if (message.role === "tool") {
|
|
827
|
+
lines.push(...toolCardLines(message, width, state));
|
|
272
828
|
} else {
|
|
273
829
|
lines.push("");
|
|
274
|
-
const color = message.error ? chalk.red : chalk.dim;
|
|
275
|
-
|
|
830
|
+
const color = message.error ? chalk.red : message.interrupted ? chalk.yellow : chalk.dim;
|
|
831
|
+
const mark = message.interrupted ? "⏹" : "!";
|
|
832
|
+
for (const line of wrap(message.text, width - 4)) lines.push(color(indent(2) + mark + " " + line));
|
|
276
833
|
}
|
|
277
834
|
}
|
|
278
|
-
|
|
835
|
+
|
|
836
|
+
// Resposta chegando token a token (e o "thinking" do modelo, se houver).
|
|
837
|
+
const streaming = state.streaming;
|
|
838
|
+
if (streaming && (streaming.reasoning || streaming.text)) {
|
|
839
|
+
if (streaming.reasoning) {
|
|
840
|
+
const elapsed = streaming.thinkingStartedAt
|
|
841
|
+
? (Date.now() - streaming.thinkingStartedAt) / 1000
|
|
842
|
+
: 0;
|
|
843
|
+
lines.push("");
|
|
844
|
+
lines.push(
|
|
845
|
+
indent(2) + chalk.dim("▾ Thinking") + chalk.dim(elapsed ? ` ${elapsed.toFixed(1)}s` : "")
|
|
846
|
+
);
|
|
847
|
+
const rows = wrap(streaming.reasoning.trim(), width - 6);
|
|
848
|
+
for (const row of rows.slice(-8)) lines.push(indent(6) + chalk.dim.italic(row));
|
|
849
|
+
}
|
|
850
|
+
if (streaming.text) {
|
|
851
|
+
lines.push("");
|
|
852
|
+
lines.push(...markdownLines(streaming.text, width - 2, state.theme));
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (state.busy && !streaming?.text) {
|
|
279
857
|
lines.push("");
|
|
280
|
-
lines.push(chalk.dim(
|
|
858
|
+
lines.push(indent(2) + chalk.dim("trabalhando" + ".".repeat(state.frame % 4)));
|
|
281
859
|
}
|
|
282
860
|
return lines;
|
|
283
861
|
}
|
|
@@ -295,164 +873,518 @@ function filteredCommands(state, commands) {
|
|
|
295
873
|
}
|
|
296
874
|
|
|
297
875
|
function menuLines(state, commands, width) {
|
|
298
|
-
const items = filteredCommands(state, commands)
|
|
876
|
+
const items = filteredCommands(state, commands);
|
|
299
877
|
if (!items.length) return [];
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
878
|
+
|
|
879
|
+
// Janela acompanhando o item selecionado: com 15 comandos a lista fixa
|
|
880
|
+
// empurrava input e rodape para fora da tela (e /logout ficava inalcancavel).
|
|
881
|
+
const height = Math.min(MENU_HEIGHT, items.length);
|
|
882
|
+
const index = state.menuIndex % items.length;
|
|
883
|
+
const start = clamp(index - Math.floor(height / 2), 0, Math.max(0, items.length - height));
|
|
884
|
+
const rows = [];
|
|
885
|
+
|
|
886
|
+
if (items.length > height) {
|
|
887
|
+
rows.push(chalk.dim(indent(2) + `${index + 1}/${items.length} ↑/↓ para navegar`));
|
|
888
|
+
}
|
|
889
|
+
items.slice(start, start + height).forEach(([name, description], offset) => {
|
|
890
|
+
const selected = start + offset === index;
|
|
891
|
+
const text = padRight(` ${name}`.padEnd(16) + description, width - 1);
|
|
892
|
+
rows.push(indent(1) + (selected ? chalk.inverse(text) : chalk.dim(text)));
|
|
893
|
+
});
|
|
894
|
+
return rows;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function formatTokens(count) {
|
|
898
|
+
if (count < 1000) return String(count);
|
|
899
|
+
if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k`;
|
|
900
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function estimateTokens(state) {
|
|
904
|
+
let chars = 0;
|
|
905
|
+
for (const message of state.context) chars += String(message.content || "").length;
|
|
906
|
+
if (state.streaming) chars += String(state.streaming.text || "").length;
|
|
907
|
+
return Math.round(chars / 4);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function sidebarLines(state, width) {
|
|
911
|
+
const lines = [];
|
|
912
|
+
const tokens = state.tokens || estimateTokens(state);
|
|
913
|
+
|
|
914
|
+
lines.push(border(state, "Context"));
|
|
915
|
+
const messages = state.context.length;
|
|
916
|
+
lines.push(chalk.dim(`${formatTokens(tokens)} tokens · ${messages} msg${messages === 1 ? "" : "s"}`));
|
|
917
|
+
lines.push(chalk.dim(state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`));
|
|
918
|
+
|
|
919
|
+
lines.push("");
|
|
920
|
+
lines.push(border(state, "MCP"));
|
|
921
|
+
if (state.mcp.loading) {
|
|
922
|
+
lines.push(chalk.dim("conectando…"));
|
|
923
|
+
} else if (!state.mcp.statuses.length) {
|
|
924
|
+
lines.push(chalk.dim("nenhum servidor"));
|
|
925
|
+
lines.push(chalk.dim("/mcp para configurar"));
|
|
926
|
+
} else {
|
|
927
|
+
for (const status of state.mcp.statuses) {
|
|
928
|
+
const label = truncate(status.name, width - 7);
|
|
929
|
+
lines.push(`${dot(state, status.status)} ${chalk.bold(label)} ${chalk.dim(String(status.tools))}`);
|
|
930
|
+
if (status.status === "error") {
|
|
931
|
+
lines.push(chalk.dim(indent(2) + truncate(status.error || "erro", width - 4)));
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (state.files.length) {
|
|
937
|
+
lines.push("");
|
|
938
|
+
lines.push(border(state, "Arquivos"));
|
|
939
|
+
for (const file of state.files.slice(-8)) {
|
|
940
|
+
const mark = file.kind === "create" ? "A" : "M";
|
|
941
|
+
lines.push(chalk.dim(`${mark} `) + truncate(file.path, width - 3));
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (state.autoApprove) {
|
|
946
|
+
lines.push("");
|
|
947
|
+
lines.push(chalk.yellow("● rodando tudo"));
|
|
948
|
+
lines.push(chalk.dim("sem pedir aprovacao"));
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return lines;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/* --------------------------------------------------------------------- chrome */
|
|
955
|
+
|
|
956
|
+
/** Mensagens esperando a vez: aparecem acima do input, sem interromper a digitacao. */
|
|
957
|
+
function queueLines(state, width) {
|
|
958
|
+
const pending = state.queue || [];
|
|
959
|
+
if (!pending.length) return [];
|
|
960
|
+
|
|
961
|
+
const rows = [indent(1) + chalk.dim(`⏳ ${pending.length} na fila · Esc 2× para parar a atual`)];
|
|
962
|
+
pending.slice(0, 3).forEach((line, index) => {
|
|
963
|
+
rows.push(indent(3) + chalk.dim(truncate(`${index + 1}. ${line}`, width - 4)));
|
|
304
964
|
});
|
|
965
|
+
if (pending.length > 3) rows.push(indent(3) + chalk.dim(`… +${pending.length - 3}`));
|
|
966
|
+
return rows;
|
|
305
967
|
}
|
|
306
968
|
|
|
307
|
-
function
|
|
308
|
-
const
|
|
309
|
-
|
|
969
|
+
function modelLine(state, width) {
|
|
970
|
+
const model = state.model || "auto";
|
|
971
|
+
// O aviso do momento tem prioridade sobre a versao: com nome de modelo longo
|
|
972
|
+
// a linha trunca e a mensagem util precisa aparecer primeiro.
|
|
973
|
+
let label = `${model} · kafox v${state.version}`;
|
|
974
|
+
if (state.escArmed) {
|
|
975
|
+
label = `${model} · Esc de novo para parar`;
|
|
976
|
+
} else if (state.retry) {
|
|
977
|
+
const left = Math.max(1, Math.ceil(((state.retry.endsAt || 0) - Date.now()) / 1000));
|
|
978
|
+
label = `${model} · reconectando em ${left}s (tentativa ${state.retry.attempt}/${state.retry.maxAttempts})`;
|
|
979
|
+
}
|
|
980
|
+
return paintPanel(
|
|
981
|
+
state.theme,
|
|
982
|
+
padRight(indent(2) + chalk.dim(truncate(label, width - 2)), width)
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function inputBlock(state, width, { hero = false, tight = false } = {}) {
|
|
987
|
+
const available = width - 4;
|
|
988
|
+
const start =
|
|
989
|
+
state.input.length > available
|
|
990
|
+
? clamp(state.cursor - (available - 2), 0, Math.max(0, state.input.length - available))
|
|
991
|
+
: 0;
|
|
992
|
+
const shown = state.input.slice(start, start + available);
|
|
993
|
+
|
|
994
|
+
const prompt = chalk.cyan("▌ ");
|
|
995
|
+
let body;
|
|
996
|
+
if (state.input) {
|
|
997
|
+
body = prompt + shown;
|
|
998
|
+
} else {
|
|
999
|
+
// Enquanto a IA trabalha os pontinhos correm; parada, a caixa fica limpa.
|
|
1000
|
+
const placeholder = state.busy ? `trabalhando${".".repeat(state.frame % 4)}` : "";
|
|
1001
|
+
body = prompt + chalk.dim(truncate(placeholder, available));
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const lines = [
|
|
1005
|
+
rule(state, width),
|
|
1006
|
+
paintPanel(state.theme, padRight(body, width)),
|
|
1007
|
+
rule(state, width),
|
|
1008
|
+
];
|
|
1009
|
+
|
|
1010
|
+
return {
|
|
1011
|
+
lines,
|
|
1012
|
+
cursorRow: 1,
|
|
1013
|
+
cursorCol: 4 + (state.cursor - start),
|
|
1014
|
+
// O modelo fica fora da caixa de texto, logo abaixo dela.
|
|
1015
|
+
footer: tight ? null : modelLine(state, width),
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function statusLines(state, width, { bare = false } = {}) {
|
|
1020
|
+
if (bare) return [];
|
|
1021
|
+
const cwd = truncate(process.cwd(), Math.max(10, width - 16));
|
|
1022
|
+
const left = chalk.dim(cwd);
|
|
310
1023
|
const right = chalk.green(
|
|
311
1024
|
state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`
|
|
312
1025
|
);
|
|
313
1026
|
const gap = " ".repeat(Math.max(1, width - visible(left) - visible(right)));
|
|
314
|
-
|
|
315
|
-
return [
|
|
316
|
-
left + gap + right,
|
|
317
|
-
chalk.dim(truncate(process.cwd(), width)),
|
|
318
|
-
chalk.dim(truncate(`${scroll}PgUp/PgDn rola | / comandos | Ctrl+C sai`, width)),
|
|
319
|
-
];
|
|
1027
|
+
return [left + gap + right];
|
|
320
1028
|
}
|
|
321
1029
|
|
|
322
|
-
function
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
1030
|
+
function hintsLines(state, width, { bare = false } = {}) {
|
|
1031
|
+
if (bare) return [];
|
|
1032
|
+
const parts = [];
|
|
1033
|
+
if (state.scroll > 0) parts.push(`↑ ${state.scroll} linhas`);
|
|
1034
|
+
if (state.busy) parts.push("Enter enfileira · Esc 2× para parar");
|
|
1035
|
+
parts.push("PgUp/PgDn rola");
|
|
1036
|
+
parts.push("Ctrl+Y copia");
|
|
1037
|
+
parts.push(`F2 ${state.mouse ? "seleção" : "mouse"}`);
|
|
1038
|
+
parts.push("/ comandos");
|
|
1039
|
+
return [chalk.dim(truncate(parts.join(" · "), width))];
|
|
1040
|
+
}
|
|
327
1041
|
|
|
328
|
-
|
|
329
|
-
const footer = compact
|
|
330
|
-
? state.scroll > 0
|
|
331
|
-
? [footerAll[0], footerAll[2]]
|
|
332
|
-
: [footerAll[0]]
|
|
333
|
-
: footerAll;
|
|
334
|
-
const panel = state.picker || state.modal ? [] : menuLines(state, commands, width);
|
|
335
|
-
const header = headerLines(state, width, rows, footer.length, compact);
|
|
1042
|
+
/* ------------------------------------------------------------------ overlay */
|
|
336
1043
|
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
1044
|
+
function overlayWidth(state, cols) {
|
|
1045
|
+
const base = state.modal ? 84 : 78;
|
|
1046
|
+
const target = state.approval ? 88 : base;
|
|
1047
|
+
return clamp(Math.min(target, cols - 4), Math.min(38, cols - 2), cols - 2);
|
|
1048
|
+
}
|
|
340
1049
|
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const inputWidth = width - 2;
|
|
349
|
-
const start = state.input.length > inputWidth
|
|
350
|
-
? clamp(state.cursor - (inputWidth - 2), 0, Math.max(0, state.input.length - inputWidth))
|
|
351
|
-
: 0;
|
|
352
|
-
const shown = state.input.slice(start, start + inputWidth);
|
|
353
|
-
const inputLine = state.input
|
|
354
|
-
? chalk.cyan("> ") + shown
|
|
355
|
-
: chalk.cyan("> ") + chalk.dim(truncate("Pergunte alguma coisa...", inputWidth));
|
|
356
|
-
const rule = chalk.dim("-".repeat(width));
|
|
357
|
-
|
|
358
|
-
const middle = compact ? [] : [""];
|
|
359
|
-
const raw = [...header, ...body, ...middle, ...panel, inputLine, rule, ...footer];
|
|
360
|
-
while (raw.length < rows) raw.push("");
|
|
361
|
-
const lines = raw.slice(0, rows).map((line) => gutter + padRight(line, width));
|
|
362
|
-
|
|
363
|
-
const inputRow = header.length + available + middle.length + panel.length + 1;
|
|
364
|
-
const cursorCol = Math.max(1, margin + 2 + (state.cursor - start) + 1);
|
|
365
|
-
return { lines, cursorRow: Math.min(rows, inputRow), cursorCol };
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/* -------------------------------------------------------------- modal/overlay */
|
|
369
|
-
|
|
370
|
-
function box(title, body, hint, width) {
|
|
371
|
-
const inner = width - 4;
|
|
372
|
-
const lines = [];
|
|
373
|
-
lines.push(chalk.dim("┌" + "─".repeat(width - 2) + "┐"));
|
|
1050
|
+
function box(state, { title, body, hint, width }) {
|
|
1051
|
+
const inner = Math.max(10, width - 2);
|
|
1052
|
+
const out = [];
|
|
1053
|
+
const edge = (text) => border(state, text);
|
|
1054
|
+
const row = (content) => edge("│") + paintPanel(state.theme, padRight(content, inner)) + edge("│");
|
|
1055
|
+
|
|
1056
|
+
out.push(edge("┌" + "─".repeat(inner) + "┐"));
|
|
374
1057
|
if (title) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
for (const line of body) {
|
|
379
|
-
lines.push(chalk.dim("│") + " " + padRight(line, inner) + " " + chalk.dim("│"));
|
|
1058
|
+
out.push(row(" " + chalk.bold(truncate(title, inner - 2))));
|
|
1059
|
+
out.push(row(""));
|
|
380
1060
|
}
|
|
1061
|
+
const bodyFirst = out.length;
|
|
1062
|
+
for (const line of body) out.push(row(" " + line));
|
|
381
1063
|
if (hint) {
|
|
382
|
-
|
|
383
|
-
|
|
1064
|
+
out.push(row(""));
|
|
1065
|
+
out.push(row(" " + chalk.dim(truncate(hint, inner - 2))));
|
|
384
1066
|
}
|
|
385
|
-
|
|
386
|
-
return lines;
|
|
1067
|
+
out.push(edge("└" + "─".repeat(inner) + "┘"));
|
|
1068
|
+
return { lines: out.map((line) => padRight(line, width)), bodyFirst };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function approvalBody(state, width) {
|
|
1072
|
+
const { name, args, change } = state.approval;
|
|
1073
|
+
const meta = toolMeta(name);
|
|
1074
|
+
const body = [];
|
|
1075
|
+
|
|
1076
|
+
if (meta.kind === "bash" || change?.kind === "command") {
|
|
1077
|
+
body.push(chalk.bold("$ ") + truncate(change?.command || String(args.command || ""), width - 4));
|
|
1078
|
+
} else {
|
|
1079
|
+
body.push(chalk.bold(truncate(change?.path || String(args.path || meta.label), width - 4)));
|
|
1080
|
+
if (change?.error) {
|
|
1081
|
+
body.push(chalk.red(truncate(change.error, width - 4)));
|
|
1082
|
+
} else if (change) {
|
|
1083
|
+
body.push(...diffCardLines(change, width - 2));
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
body.push("");
|
|
1088
|
+
const optionsStart = body.length;
|
|
1089
|
+
for (const [index, option] of APPROVAL_OPTIONS.entries()) {
|
|
1090
|
+
const selected = index === state.approval.index;
|
|
1091
|
+
const line = padRight(`${selected ? "▸ " : " "}${option.label}`, width - 6);
|
|
1092
|
+
if (selected) {
|
|
1093
|
+
body.push(chalk.inverse(chalk.bold(line)));
|
|
1094
|
+
} else if (option.id === "deny") {
|
|
1095
|
+
body.push(chalk.dim(line));
|
|
1096
|
+
} else {
|
|
1097
|
+
body.push(line);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
return {
|
|
1102
|
+
title:
|
|
1103
|
+
meta.kind === "bash"
|
|
1104
|
+
? "Executar comando"
|
|
1105
|
+
: meta.kind === "mcp"
|
|
1106
|
+
? `Chamar ${meta.label} · ${meta.server}`
|
|
1107
|
+
: change?.kind === "create"
|
|
1108
|
+
? "Criar arquivo"
|
|
1109
|
+
: "Editar arquivo",
|
|
1110
|
+
body,
|
|
1111
|
+
optionsStart,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function themeSwatch(theme) {
|
|
1116
|
+
const { bg, fg } = themeColors(theme);
|
|
1117
|
+
if (!bg) return chalk.dim("terminal");
|
|
1118
|
+
const sample = "███";
|
|
1119
|
+
const painted = fg ? chalk.bgHex(bg).hex(fg)(sample) : chalk.bgHex(bg)(sample);
|
|
1120
|
+
return painted + " " + chalk.dim(bg);
|
|
387
1121
|
}
|
|
388
1122
|
|
|
389
1123
|
function buildOverlay(state, cols, rows) {
|
|
1124
|
+
if (!state.approval && !state.picker && !state.modal) return null;
|
|
1125
|
+
|
|
1126
|
+
const width = overlayWidth(state, cols);
|
|
1127
|
+
const maxBody = Math.max(3, rows - 9);
|
|
1128
|
+
|
|
390
1129
|
let title = "";
|
|
391
|
-
let body = [];
|
|
392
1130
|
let hint = "";
|
|
393
|
-
|
|
394
|
-
|
|
1131
|
+
let body = [];
|
|
1132
|
+
let hit = null;
|
|
1133
|
+
|
|
1134
|
+
if (state.approval) {
|
|
1135
|
+
const content = approvalBody(state, width);
|
|
1136
|
+
title = content.title;
|
|
1137
|
+
hint = "↑/↓ ou clique · Enter confirma · Esc nega · a = sempre";
|
|
1138
|
+
body = content.body;
|
|
1139
|
+
hit = { optionsStart: content.optionsStart, count: APPROVAL_OPTIONS.length, kind: "approval" };
|
|
1140
|
+
} else if (state.picker) {
|
|
395
1141
|
const picker = state.picker;
|
|
396
|
-
|
|
397
|
-
|
|
1142
|
+
const themePicker = picker.kind === "theme";
|
|
1143
|
+
const chatPicker = picker.kind === "chat";
|
|
1144
|
+
title = themePicker
|
|
1145
|
+
? `Tema (${picker.index + 1}/${picker.items.length})`
|
|
1146
|
+
: chatPicker
|
|
1147
|
+
? `Conversas (${picker.index + 1}/${picker.items.length})`
|
|
1148
|
+
: "Escolha o modelo";
|
|
1149
|
+
hint = chatPicker
|
|
1150
|
+
? "↑/↓ ou clique · Enter retoma · Esc cancela"
|
|
1151
|
+
: "↑/↓ ou clique · Enter aplica · Esc cancela";
|
|
1152
|
+
|
|
398
1153
|
if (picker.loading) {
|
|
399
1154
|
body = [chalk.dim("carregando modelos...")];
|
|
400
1155
|
} else {
|
|
401
|
-
const height = 8;
|
|
402
|
-
const start = clamp(
|
|
403
|
-
|
|
1156
|
+
const height = Math.min(chatPicker ? 10 : themePicker ? 9 : 8, picker.items.length);
|
|
1157
|
+
const start = clamp(
|
|
1158
|
+
picker.index - Math.floor(height / 2),
|
|
1159
|
+
0,
|
|
1160
|
+
Math.max(0, picker.items.length - height)
|
|
1161
|
+
);
|
|
1162
|
+
for (let offset = 0; offset < height; offset += 1) {
|
|
404
1163
|
const real = start + offset;
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
1164
|
+
const item = picker.items[real];
|
|
1165
|
+
if (!item) continue;
|
|
1166
|
+
const caret = real === picker.index ? "❯" : " ";
|
|
1167
|
+
let line;
|
|
1168
|
+
if (themePicker) {
|
|
1169
|
+
line = padRight(` ${caret} ${padRight(item.id, 15)} ` + themeSwatch(item.id), width - 4);
|
|
1170
|
+
} else if (chatPicker) {
|
|
1171
|
+
const when = String(item.updatedAt || "").slice(0, 10);
|
|
1172
|
+
const right = chalk.dim(`${when} ${item.id}`);
|
|
1173
|
+
const space = width - 6;
|
|
1174
|
+
const room = Math.max(12, space - visible(right) - 2);
|
|
1175
|
+
const left = ` ${caret} ${truncate(item.title || item.id, room)}`;
|
|
1176
|
+
const gap = Math.max(1, space - visible(left) - visible(right));
|
|
1177
|
+
line = padRight(left + " ".repeat(gap) + right, width - 4);
|
|
1178
|
+
} else {
|
|
1179
|
+
line = padRight(` ${caret} ${item.id} `, width - 4);
|
|
1180
|
+
}
|
|
1181
|
+
body.push(real === picker.index ? chalk.inverse(line) : line);
|
|
1182
|
+
}
|
|
1183
|
+
hit = { start, count: height, kind: "picker" };
|
|
411
1184
|
}
|
|
412
|
-
} else if (state.modal) {
|
|
413
|
-
title = state.modal.title;
|
|
414
|
-
hint = state.modal.hint || "Enter ou qualquer tecla fecha";
|
|
415
|
-
body = [];
|
|
416
1185
|
} else {
|
|
417
|
-
|
|
1186
|
+
title = state.modal.title;
|
|
1187
|
+
hint = state.modal.hint || "qualquer tecla fecha";
|
|
1188
|
+
body = wrap(state.modal.text || "", width - 4);
|
|
418
1189
|
}
|
|
419
1190
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
if (state.modal) body = wrap(state.modal.text || "", inner);
|
|
425
|
-
|
|
426
|
-
let content = body;
|
|
427
|
-
const maxBody = Math.max(1, rows - 8);
|
|
428
|
-
if (content.length > maxBody) {
|
|
429
|
-
content = content.slice(0, maxBody - 1);
|
|
430
|
-
content.push(chalk.dim(`... (+${body.length - content.length} linhas)`));
|
|
1191
|
+
if (body.length > maxBody) {
|
|
1192
|
+
const cut = body.slice(0, maxBody - 1);
|
|
1193
|
+
cut.push(chalk.dim(`... (+${body.length - cut.length} linhas)`));
|
|
1194
|
+
body = cut;
|
|
431
1195
|
}
|
|
432
1196
|
|
|
433
|
-
const boxed = box(title,
|
|
434
|
-
const top = Math.max(1, Math.floor((rows - boxed.length) / 2) + 1);
|
|
1197
|
+
const boxed = box(state, { title, body, hint, width });
|
|
1198
|
+
const top = Math.max(1, Math.floor((rows - boxed.lines.length) / 2) + 1);
|
|
435
1199
|
const left = Math.max(1, Math.floor((cols - width) / 2) + 1);
|
|
1200
|
+
|
|
436
1201
|
return {
|
|
437
1202
|
top,
|
|
438
1203
|
left,
|
|
439
|
-
|
|
1204
|
+
width,
|
|
1205
|
+
lines: boxed.lines,
|
|
1206
|
+
bodyFirst: boxed.bodyFirst,
|
|
1207
|
+
hit: hit
|
|
1208
|
+
? {
|
|
1209
|
+
...hit,
|
|
1210
|
+
first: top + boxed.bodyFirst + (hit.optionsStart || 0),
|
|
1211
|
+
left,
|
|
1212
|
+
width,
|
|
1213
|
+
}
|
|
1214
|
+
: null,
|
|
440
1215
|
};
|
|
441
1216
|
}
|
|
442
1217
|
|
|
443
|
-
/* ---------------------------------------------------------------------
|
|
1218
|
+
/* --------------------------------------------------------------------- frame */
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Monta a string de repintura: apenas as linhas que mudaram, com o cursor no
|
|
1222
|
+
* fim. Tudo dentro de uma atualizacao sincronizada do terminal.
|
|
1223
|
+
*/
|
|
1224
|
+
function planRepaint(previousLines, frame, rows) {
|
|
1225
|
+
const out = ["\x1b[?25l"];
|
|
1226
|
+
|
|
1227
|
+
for (let row = frame.lines.length; row < previousLines.length; row += 1) {
|
|
1228
|
+
if (row < rows) out.push(`\x1b[${row + 1};1H\x1b[2K`);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
for (let row = 0; row < frame.lines.length; row += 1) {
|
|
1232
|
+
const line = frame.lines[row];
|
|
1233
|
+
if (previousLines[row] === line) continue;
|
|
1234
|
+
out.push(`\x1b[${row + 1};1H` + line);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (frame.cursorRow > 0) out.push(`\x1b[${frame.cursorRow};${frame.cursorCol}H\x1b[?25h`);
|
|
1238
|
+
return SYNC_START + out.join("") + SYNC_END;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function buildFrame(state, cols, rows, commands = {}) {
|
|
1242
|
+
const { sidebar, main, inner } = layoutFor(state, cols);
|
|
1243
|
+
const overlay = buildOverlay(state, cols, rows);
|
|
1244
|
+
const hero = heroActive(state);
|
|
1245
|
+
// Em terminal baixo a chrome encolhe: primeiro a linha do modelo, depois
|
|
1246
|
+
// o rodape, ate sobrar input + status.
|
|
1247
|
+
const tight = rows < 24;
|
|
1248
|
+
const bare = rows < 20;
|
|
1249
|
+
|
|
1250
|
+
const menu = overlay ? [] : menuLines(state, commands, inner);
|
|
1251
|
+
const queue = overlay ? [] : queueLines(state, inner);
|
|
1252
|
+
const input = inputBlock(state, inner, { hero, tight });
|
|
1253
|
+
const status = statusLines(state, inner, { bare });
|
|
1254
|
+
const hints = hintsLines(state, inner, { bare });
|
|
1255
|
+
const chrome = [
|
|
1256
|
+
...queue,
|
|
1257
|
+
...menu,
|
|
1258
|
+
...input.lines,
|
|
1259
|
+
...(input.footer ? [input.footer] : []),
|
|
1260
|
+
...status,
|
|
1261
|
+
...hints,
|
|
1262
|
+
];
|
|
1263
|
+
|
|
1264
|
+
const head = hero ? heroLines(state, inner, rows - chrome.length) : [];
|
|
1265
|
+
|
|
1266
|
+
const bodyHeight = Math.max(0, rows - head.length - chrome.length);
|
|
1267
|
+
state.pageSize = Math.max(1, bodyHeight);
|
|
444
1268
|
|
|
445
|
-
|
|
1269
|
+
let body = hero ? [] : messageLines(state, inner);
|
|
1270
|
+
const maxScroll = Math.max(0, body.length - bodyHeight);
|
|
1271
|
+
state.scroll = clamp(state.scroll, 0, maxScroll);
|
|
1272
|
+
const end = body.length - state.scroll;
|
|
1273
|
+
let visibleBody = body.slice(Math.max(0, end - bodyHeight), end);
|
|
1274
|
+
if (visibleBody.length < bodyHeight) {
|
|
1275
|
+
visibleBody = [...Array(bodyHeight - visibleBody.length).fill(""), ...visibleBody];
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const sideLines = sidebar ? sidebarLines(state, sidebar - 1) : [];
|
|
1279
|
+
|
|
1280
|
+
const total = [...head, ...visibleBody, ...chrome];
|
|
1281
|
+
while (total.length < rows) total.push("");
|
|
1282
|
+
const lines = new Array(rows);
|
|
1283
|
+
|
|
1284
|
+
for (let row = 0; row < rows; row += 1) {
|
|
1285
|
+
const content = total[row] ?? "";
|
|
1286
|
+
let line = " " + padRight(content, inner) + " ";
|
|
1287
|
+
if (sidebar) {
|
|
1288
|
+
line = padRight(line, main);
|
|
1289
|
+
const side = sideLines[row] ?? "";
|
|
1290
|
+
line += border(state, "│") + paintPanel(state.theme, padRight(" " + side, sidebar - 1));
|
|
1291
|
+
}
|
|
1292
|
+
lines[row] = paintLine(state.theme, padRight(line, cols));
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
if (overlay) {
|
|
1296
|
+
overlay.lines.forEach((boxLine, index) => {
|
|
1297
|
+
const row = overlay.top - 1 + index;
|
|
1298
|
+
if (row < 0 || row >= rows) return;
|
|
1299
|
+
const left = overlay.left - 1;
|
|
1300
|
+
lines[row] =
|
|
1301
|
+
paintLine(state.theme, " ".repeat(left)) +
|
|
1302
|
+
boxLine +
|
|
1303
|
+
paintLine(state.theme, " ".repeat(Math.max(0, cols - left - visible(boxLine))));
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const inputRow = head.length + bodyHeight + queue.length + menu.length + input.cursorRow;
|
|
1308
|
+
const cursorRow = overlay ? 0 : clamp(inputRow + 1, 1, rows);
|
|
1309
|
+
const cursorCol = overlay ? 0 : clamp(input.cursorCol, 1, cols);
|
|
1310
|
+
|
|
1311
|
+
return {
|
|
1312
|
+
lines,
|
|
1313
|
+
cursorRow,
|
|
1314
|
+
cursorCol,
|
|
1315
|
+
sidebar,
|
|
1316
|
+
overlay,
|
|
1317
|
+
bodyHeight,
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/* ----------------------------------------------------------------------- app */
|
|
1322
|
+
|
|
1323
|
+
async function runTUI({
|
|
1324
|
+
commands = {},
|
|
1325
|
+
onCommand,
|
|
1326
|
+
interactiveCommands = new Set(),
|
|
1327
|
+
autoApprove = false,
|
|
1328
|
+
resume = null,
|
|
1329
|
+
} = {}) {
|
|
446
1330
|
const stdin = process.stdin;
|
|
447
1331
|
const stdout = process.stdout;
|
|
448
1332
|
const state = initialState();
|
|
1333
|
+
|
|
1334
|
+
// resume: "pick" abre o seletor de conversas, "last" pega a mais recente e
|
|
1335
|
+
// qualquer outro valor e o codigo do chat que o usuario fechou no Ctrl+C.
|
|
1336
|
+
let resumeMissing = null;
|
|
1337
|
+
let resumePick = false;
|
|
1338
|
+
if (resume) {
|
|
1339
|
+
if (resume === "pick") {
|
|
1340
|
+
resumePick = true;
|
|
1341
|
+
} else {
|
|
1342
|
+
const id = resume === "last" || resume === true ? sessions.latest()?.id : String(resume);
|
|
1343
|
+
const saved = sessions.load(id);
|
|
1344
|
+
if (saved) {
|
|
1345
|
+
state.sessionId = saved.id;
|
|
1346
|
+
state.sessionStartedAt = saved.startedAt;
|
|
1347
|
+
state.messages = Array.isArray(saved.messages) ? saved.messages : [];
|
|
1348
|
+
state.context = Array.isArray(saved.context) ? saved.context : [];
|
|
1349
|
+
if (saved.model) state.model = saved.model;
|
|
1350
|
+
if (saved.theme) state.theme = saved.theme;
|
|
1351
|
+
state.tokens = estimateTokens(state);
|
|
1352
|
+
} else {
|
|
1353
|
+
resumeMissing = String(id || resume);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (!state.sessionId) {
|
|
1358
|
+
state.sessionId = sessions.newId();
|
|
1359
|
+
state.sessionStartedAt = new Date().toISOString();
|
|
1360
|
+
}
|
|
1361
|
+
// `kafox --allow-all` (ou KAFOX_ALLOW_ALL=1) ja comeca sem pedir aprovacao.
|
|
1362
|
+
state.autoApprove = Boolean(autoApprove) || process.env.KAFOX_ALLOW_ALL === "1";
|
|
1363
|
+
let mcp = new McpHub({ cwd: process.cwd() });
|
|
449
1364
|
let spinner = null;
|
|
1365
|
+
let escTimer = null;
|
|
1366
|
+
let saveTimer = null;
|
|
450
1367
|
let closed = false;
|
|
1368
|
+
let previousLines = [];
|
|
451
1369
|
|
|
452
1370
|
const write = (value) => stdout.write(value);
|
|
1371
|
+
|
|
1372
|
+
// O stream chega a cada token: redesenhar a cada um pisca demais.
|
|
1373
|
+
let renderTimer = null;
|
|
1374
|
+
function scheduleRender() {
|
|
1375
|
+
if (renderTimer || closed) return;
|
|
1376
|
+
renderTimer = setTimeout(() => {
|
|
1377
|
+
renderTimer = null;
|
|
1378
|
+
render();
|
|
1379
|
+
}, 60);
|
|
1380
|
+
renderTimer.unref?.();
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
const mouseMode = () => (state.mouse ? "\x1b[?1000h\x1b[?1006h" : "\x1b[?1000l\x1b[?1006l");
|
|
453
1384
|
const enterScreen = () =>
|
|
454
|
-
write(
|
|
455
|
-
const leaveScreen = () =>
|
|
1385
|
+
write(`\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J${mouseMode()}\x1b[?2004h`);
|
|
1386
|
+
const leaveScreen = () =>
|
|
1387
|
+
write("\x1b[?2004l\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l");
|
|
456
1388
|
|
|
457
1389
|
async function refreshAccount() {
|
|
458
1390
|
const token = await ensureSession();
|
|
@@ -470,22 +1402,75 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
470
1402
|
}
|
|
471
1403
|
}
|
|
472
1404
|
|
|
1405
|
+
function applyMcp() {
|
|
1406
|
+
state.mcp.statuses = mcp.statuses();
|
|
1407
|
+
state.mcp.tools = state.mcp.statuses.reduce((total, item) => total + item.tools, 0);
|
|
1408
|
+
state.mcp.loading = state.mcp.statuses.some(
|
|
1409
|
+
(item) => item.status === "connecting" || item.status === "idle"
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
async function connectMcp() {
|
|
1414
|
+
applyMcp();
|
|
1415
|
+
if (!mcp.size) {
|
|
1416
|
+
state.mcp.loading = false;
|
|
1417
|
+
return render();
|
|
1418
|
+
}
|
|
1419
|
+
state.mcp.loading = true;
|
|
1420
|
+
render();
|
|
1421
|
+
await mcp.connectAll();
|
|
1422
|
+
applyMcp();
|
|
1423
|
+
render();
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/** Grava a conversa em ~/.kafox/chats para o --resume achar depois. */
|
|
1427
|
+
function persist() {
|
|
1428
|
+
if (!state.sessionId || closed) return;
|
|
1429
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
1430
|
+
saveTimer = setTimeout(() => {
|
|
1431
|
+
saveTimer = null;
|
|
1432
|
+
saveNow();
|
|
1433
|
+
}, 400);
|
|
1434
|
+
saveTimer.unref?.();
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function saveNow() {
|
|
1438
|
+
if (!state.sessionId) return null;
|
|
1439
|
+
try {
|
|
1440
|
+
return sessions.save({
|
|
1441
|
+
id: state.sessionId,
|
|
1442
|
+
cwd: process.cwd(),
|
|
1443
|
+
model: state.model,
|
|
1444
|
+
theme: state.theme,
|
|
1445
|
+
messages: state.messages,
|
|
1446
|
+
context: state.context,
|
|
1447
|
+
startedAt: state.sessionStartedAt,
|
|
1448
|
+
});
|
|
1449
|
+
} catch {
|
|
1450
|
+
return null;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
473
1454
|
function render() {
|
|
474
1455
|
if (closed || state.suspended) return;
|
|
475
|
-
const cols = Math.max(
|
|
476
|
-
const rows = Math.max(
|
|
1456
|
+
const cols = Math.max(30, stdout.columns || 80);
|
|
1457
|
+
const rows = Math.max(14, stdout.rows || 24);
|
|
477
1458
|
const frame = buildFrame(state, cols, rows, commands);
|
|
478
|
-
|
|
1459
|
+
paintFrame(frame, { cols, rows });
|
|
1460
|
+
}
|
|
479
1461
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
1462
|
+
/**
|
|
1463
|
+
* Desenho incremental: manda so as linhas diferentes, dentro de uma unica
|
|
1464
|
+
* atualizacao sincronizada. E o que impede o piscar do modal e do streaming.
|
|
1465
|
+
*/
|
|
1466
|
+
function paintFrame(frame, { rows }) {
|
|
1467
|
+
write(planRepaint(previousLines, frame, rows));
|
|
1468
|
+
previousLines = frame.lines.slice();
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function clearScreen() {
|
|
1472
|
+
write("\x1b[H\x1b[2J");
|
|
1473
|
+
previousLines = [];
|
|
489
1474
|
}
|
|
490
1475
|
|
|
491
1476
|
async function capture(fn) {
|
|
@@ -507,17 +1492,83 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
507
1492
|
const pricing = await fetchApi("/api/public/pricing")
|
|
508
1493
|
.then((res) => res.json())
|
|
509
1494
|
.catch(() => null);
|
|
510
|
-
state.models = (pricing?.pricing || []).map((row) => ({
|
|
511
|
-
id: row.model,
|
|
512
|
-
input: row.input_micros_per_1m,
|
|
513
|
-
output: row.output_micros_per_1m,
|
|
514
|
-
}));
|
|
1495
|
+
state.models = (pricing?.pricing || []).map((row) => ({ id: row.model }));
|
|
515
1496
|
return state.models;
|
|
516
1497
|
}
|
|
517
1498
|
|
|
1499
|
+
// /theme abre um modal navegavel; cada parada ja pinta a tela com o tema.
|
|
1500
|
+
function openThemePicker() {
|
|
1501
|
+
state.menuDismissed = true;
|
|
1502
|
+
state.picker = {
|
|
1503
|
+
kind: "theme",
|
|
1504
|
+
items: THEMES.map((id) => ({ id })),
|
|
1505
|
+
index: Math.max(0, THEMES.indexOf(state.theme)),
|
|
1506
|
+
loading: false,
|
|
1507
|
+
previous: state.theme,
|
|
1508
|
+
};
|
|
1509
|
+
render();
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function previewTheme(index) {
|
|
1513
|
+
const item = state.picker?.items?.[index];
|
|
1514
|
+
if (item) state.theme = item.id;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function applyTheme() {
|
|
1518
|
+
const picker = state.picker;
|
|
1519
|
+
if (!picker) return;
|
|
1520
|
+
const chosen = picker.items[picker.index]?.id || state.theme;
|
|
1521
|
+
state.theme = chosen;
|
|
1522
|
+
storage.setConfig({ theme: chosen });
|
|
1523
|
+
state.picker = null;
|
|
1524
|
+
render();
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function cancelTheme() {
|
|
1528
|
+
const picker = state.picker;
|
|
1529
|
+
if (picker && picker.kind === "theme") state.theme = picker.previous;
|
|
1530
|
+
state.picker = null;
|
|
1531
|
+
render();
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/** Modal com as conversas salvas (setas/mouse + Enter), igual ao de modelos. */
|
|
1535
|
+
function openChatPicker() {
|
|
1536
|
+
const items = sessions.list();
|
|
1537
|
+
if (!items.length) {
|
|
1538
|
+
state.modal = {
|
|
1539
|
+
title: "Conversas",
|
|
1540
|
+
text: `Nenhuma conversa salva ainda.\nFicam em ${sessions.chatsDir()}.`,
|
|
1541
|
+
};
|
|
1542
|
+
return render();
|
|
1543
|
+
}
|
|
1544
|
+
state.menuDismissed = true;
|
|
1545
|
+
state.picker = { kind: "chat", items, index: 0, loading: false };
|
|
1546
|
+
render();
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/** Troca a sessao atual pela conversa escolhida no seletor. */
|
|
1550
|
+
function resumeChat(id) {
|
|
1551
|
+
const saved = sessions.load(id);
|
|
1552
|
+
state.picker = null;
|
|
1553
|
+
if (!saved) {
|
|
1554
|
+
state.modal = { title: "Resume", text: "Nao consegui abrir essa conversa." };
|
|
1555
|
+
return render();
|
|
1556
|
+
}
|
|
1557
|
+
state.sessionId = saved.id;
|
|
1558
|
+
state.sessionStartedAt = saved.startedAt;
|
|
1559
|
+
state.messages = Array.isArray(saved.messages) ? saved.messages : [];
|
|
1560
|
+
state.context = Array.isArray(saved.context) ? saved.context : [];
|
|
1561
|
+
if (saved.model) state.model = saved.model;
|
|
1562
|
+
if (saved.theme) state.theme = saved.theme;
|
|
1563
|
+
state.files = [];
|
|
1564
|
+
state.tokens = estimateTokens(state);
|
|
1565
|
+
state.scroll = 0;
|
|
1566
|
+
return render();
|
|
1567
|
+
}
|
|
1568
|
+
|
|
518
1569
|
async function openModelPicker() {
|
|
519
1570
|
state.menuDismissed = true;
|
|
520
|
-
state.picker = { items: state.models, index: 0, loading: true };
|
|
1571
|
+
state.picker = { kind: "model", items: state.models, index: 0, loading: true };
|
|
521
1572
|
render();
|
|
522
1573
|
const models = await loadModels();
|
|
523
1574
|
if (!models.length) {
|
|
@@ -526,7 +1577,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
526
1577
|
return render();
|
|
527
1578
|
}
|
|
528
1579
|
const current = models.findIndex((item) => item.id === state.model);
|
|
529
|
-
state.picker = { items: models, index: current >= 0 ? current : 0, loading: false };
|
|
1580
|
+
state.picker = { kind: "model", items: models, index: current >= 0 ? current : 0, loading: false };
|
|
530
1581
|
render();
|
|
531
1582
|
}
|
|
532
1583
|
|
|
@@ -538,7 +1589,33 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
538
1589
|
storage.setConfig({ model: item.id });
|
|
539
1590
|
state.model = item.id;
|
|
540
1591
|
state.picker = null;
|
|
541
|
-
|
|
1592
|
+
render();
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function openMcpView() {
|
|
1596
|
+
const lines = [];
|
|
1597
|
+
const statuses = state.mcp.statuses;
|
|
1598
|
+
if (!mcp.size) {
|
|
1599
|
+
lines.push("Nenhum servidor MCP configurado.");
|
|
1600
|
+
lines.push("");
|
|
1601
|
+
lines.push(`${path.join(os.homedir(), ".kafox", "mcp.json")}:`);
|
|
1602
|
+
lines.push('{ "mcp": { "playwright": {');
|
|
1603
|
+
lines.push(' "type": "local",');
|
|
1604
|
+
lines.push(' "command": ["npx", "-y", "@playwright/mcp@latest"] } } }');
|
|
1605
|
+
lines.push("");
|
|
1606
|
+
lines.push("Depois rode /mcp reload.");
|
|
1607
|
+
} else {
|
|
1608
|
+
for (const item of statuses) {
|
|
1609
|
+
lines.push(`${stripAnsi(dot(state, item.status))} ${item.name} (${item.kind}) ${item.status}`);
|
|
1610
|
+
if (item.error) lines.push(` ${item.error}`);
|
|
1611
|
+
for (const name of item.toolNames.slice(0, 6)) lines.push(` · ${name}`);
|
|
1612
|
+
if (item.toolNames.length > 6) lines.push(` ... +${item.toolNames.length - 6} ferramentas`);
|
|
1613
|
+
lines.push("");
|
|
1614
|
+
}
|
|
1615
|
+
lines.push(`${state.mcp.tools} ferramenta(s) de MCP disponiveis.`);
|
|
1616
|
+
lines.push("Config: ~/.kafox/mcp.json ou ./mcp.json · /mcp reload reconecta.");
|
|
1617
|
+
}
|
|
1618
|
+
state.modal = { title: "MCP", text: lines.join("\n") };
|
|
542
1619
|
render();
|
|
543
1620
|
}
|
|
544
1621
|
|
|
@@ -554,6 +1631,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
554
1631
|
stdin.setRawMode(true);
|
|
555
1632
|
state.suspended = false;
|
|
556
1633
|
enterScreen();
|
|
1634
|
+
previousLines = [];
|
|
557
1635
|
await refreshAccount();
|
|
558
1636
|
render();
|
|
559
1637
|
}
|
|
@@ -564,23 +1642,50 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
564
1642
|
const name = parts[0];
|
|
565
1643
|
if (name === "/exit" || name === "/quit") return quit();
|
|
566
1644
|
|
|
567
|
-
|
|
1645
|
+
// /models e /model sem argumento abrem o mesmo seletor navegavel.
|
|
1646
|
+
if ((name === "/model" || name === "/models") && !parts[1]) return openModelPicker();
|
|
1647
|
+
if (name === "/theme") return openThemePicker();
|
|
1648
|
+
if (name === "/allow") {
|
|
1649
|
+
const argument = (parts[1] || "on").toLowerCase();
|
|
1650
|
+
state.autoApprove = argument !== "off" && argument !== "ask";
|
|
1651
|
+
state.modal = {
|
|
1652
|
+
title: "Aprovacao",
|
|
1653
|
+
text: state.autoApprove
|
|
1654
|
+
? "Rodando tudo: escrever, editar, executar e MCP nao pedem mais aprovacao nesta sessao.\nUse /allow off para voltar a perguntar (comece com `kafox --allow-all` para ja abrir assim)."
|
|
1655
|
+
: "Voltou a pedir aprovacao para escrever, editar, executar e chamar MCP.",
|
|
1656
|
+
};
|
|
1657
|
+
return render();
|
|
1658
|
+
}
|
|
1659
|
+
if (name === "/mcp") {
|
|
1660
|
+
if (parts[1] === "reload") {
|
|
1661
|
+
mcp.close();
|
|
1662
|
+
mcp = new McpHub({ cwd: process.cwd() });
|
|
1663
|
+
return connectMcp();
|
|
1664
|
+
}
|
|
1665
|
+
return openMcpView();
|
|
1666
|
+
}
|
|
568
1667
|
|
|
569
1668
|
if (name === "/clear") {
|
|
570
1669
|
state.messages = [];
|
|
1670
|
+
state.context = [];
|
|
1671
|
+
state.streaming = null;
|
|
1672
|
+
state.files = [];
|
|
571
1673
|
state.scroll = 0;
|
|
572
1674
|
return render();
|
|
573
1675
|
}
|
|
574
1676
|
if (name === "/compact") {
|
|
575
|
-
if (!state.messages.length) {
|
|
1677
|
+
if (!state.messages.length && !state.context.length) {
|
|
576
1678
|
state.modal = { title: "Contexto", text: "Nada para compactar." };
|
|
577
1679
|
return render();
|
|
578
1680
|
}
|
|
579
1681
|
const total = state.messages.length;
|
|
1682
|
+
const kept = state.context.slice(-6);
|
|
580
1683
|
state.messages = [
|
|
581
1684
|
{ role: "system", text: `Contexto compactado: ${total} mensagens resumidas.` },
|
|
582
1685
|
...state.messages.slice(-6),
|
|
583
1686
|
];
|
|
1687
|
+
// O system prompt do agente (indice 0) continua valendo.
|
|
1688
|
+
state.context = state.context.length ? [state.context[0], ...kept] : [];
|
|
584
1689
|
state.scroll = 0;
|
|
585
1690
|
return render();
|
|
586
1691
|
}
|
|
@@ -588,19 +1693,28 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
588
1693
|
if (interactiveCommands.has(name)) return runOutside(line);
|
|
589
1694
|
|
|
590
1695
|
const output = stripAnsi(await capture(() => onCommand(line))).trim();
|
|
591
|
-
|
|
1696
|
+
const config = storage.getConfig();
|
|
1697
|
+
state.model = config.model || state.model;
|
|
1698
|
+
state.theme = config.theme || state.theme;
|
|
592
1699
|
state.modal = {
|
|
593
1700
|
title: name.replace(/^\//, ""),
|
|
594
1701
|
text: output || "(sem saida)",
|
|
595
1702
|
};
|
|
596
|
-
render();
|
|
597
1703
|
await refreshAccount();
|
|
598
1704
|
render();
|
|
599
1705
|
}
|
|
600
1706
|
|
|
601
|
-
|
|
1707
|
+
function startMessageText(text) {
|
|
1708
|
+
state.messages.push({ role: "user", text });
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* Enter coloca a linha na fila e a fila anda sozinha: da para mandar
|
|
1713
|
+
* quantas mensagens quiser enquanto a IA ainda esta respondendo.
|
|
1714
|
+
*/
|
|
1715
|
+
function submit() {
|
|
602
1716
|
const line = state.input.trim();
|
|
603
|
-
if (!line
|
|
1717
|
+
if (!line) return;
|
|
604
1718
|
state.history.push(line);
|
|
605
1719
|
state.historyIndex = state.history.length;
|
|
606
1720
|
state.input = "";
|
|
@@ -608,26 +1722,312 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
608
1722
|
state.menuIndex = 0;
|
|
609
1723
|
state.menuDismissed = false;
|
|
610
1724
|
|
|
611
|
-
if (line
|
|
1725
|
+
if (line === "/exit" || line === "/quit") return quit();
|
|
612
1726
|
|
|
613
|
-
state.
|
|
1727
|
+
state.queue.push(line);
|
|
1728
|
+
render();
|
|
1729
|
+
return pump();
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
async function pump() {
|
|
1733
|
+
if (state.draining) return;
|
|
1734
|
+
state.draining = true;
|
|
1735
|
+
try {
|
|
1736
|
+
while (state.queue.length) {
|
|
1737
|
+
const line = state.queue.shift();
|
|
1738
|
+
render();
|
|
1739
|
+
await runLine(line);
|
|
1740
|
+
}
|
|
1741
|
+
} finally {
|
|
1742
|
+
state.draining = false;
|
|
1743
|
+
state.busy = false;
|
|
1744
|
+
render();
|
|
1745
|
+
await refreshAccount();
|
|
1746
|
+
render();
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
async function runLine(line) {
|
|
1751
|
+
if (line.startsWith("/")) {
|
|
1752
|
+
state.busy = false;
|
|
1753
|
+
return runCommand(line);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const controller = new AbortController();
|
|
1757
|
+
state.abort = controller;
|
|
1758
|
+
state.escArmed = false;
|
|
1759
|
+
startMessageText(line);
|
|
1760
|
+
state.context.push({ role: "user", content: line });
|
|
614
1761
|
state.busy = true;
|
|
1762
|
+
state.streaming = { text: "", reasoning: "", thinkingStartedAt: 0 };
|
|
615
1763
|
state.scroll = 0;
|
|
616
1764
|
render();
|
|
617
1765
|
|
|
618
|
-
|
|
1766
|
+
let result;
|
|
1767
|
+
try {
|
|
1768
|
+
result = await runAgent({
|
|
1769
|
+
messages: state.context,
|
|
1770
|
+
model: state.model,
|
|
1771
|
+
cwd: process.cwd(),
|
|
1772
|
+
approve: requestApproval,
|
|
1773
|
+
autoApprove: () => state.autoApprove,
|
|
1774
|
+
extraTools: mcp.specs(),
|
|
1775
|
+
mcp,
|
|
1776
|
+
signal: controller.signal,
|
|
1777
|
+
onText: (chunk) => {
|
|
1778
|
+
state.streaming.text += chunk;
|
|
1779
|
+
scheduleRender();
|
|
1780
|
+
},
|
|
1781
|
+
onReasoning: (chunk) => {
|
|
1782
|
+
if (!state.streaming.thinkingStartedAt) state.streaming.thinkingStartedAt = Date.now();
|
|
1783
|
+
state.streaming.reasoning += chunk;
|
|
1784
|
+
scheduleRender();
|
|
1785
|
+
},
|
|
1786
|
+
onToolStart: ({ id, name, args, label, change, fromMcp, subagent }) => {
|
|
1787
|
+
finishStreaming();
|
|
1788
|
+
state.messages.push({
|
|
1789
|
+
role: "tool",
|
|
1790
|
+
tool: {
|
|
1791
|
+
id,
|
|
1792
|
+
name,
|
|
1793
|
+
args,
|
|
1794
|
+
label,
|
|
1795
|
+
change,
|
|
1796
|
+
subagent,
|
|
1797
|
+
meta: toolMeta(name, fromMcp ? mcp : null),
|
|
1798
|
+
status: "running",
|
|
1799
|
+
startedAt: Date.now(),
|
|
1800
|
+
},
|
|
1801
|
+
});
|
|
1802
|
+
state.scroll = 0;
|
|
1803
|
+
render();
|
|
1804
|
+
},
|
|
1805
|
+
onRetry: (info) => {
|
|
1806
|
+
state.retry = { ...info, endsAt: Date.now() + info.delay };
|
|
1807
|
+
scheduleRender();
|
|
1808
|
+
},
|
|
1809
|
+
onSubagentEvent: (event) => {
|
|
1810
|
+
const card = [...state.messages].reverse().find((message) => {
|
|
1811
|
+
if (message.role !== "tool" || !message.tool?.id) return false;
|
|
1812
|
+
return message.tool.id === event.id;
|
|
1813
|
+
});
|
|
1814
|
+
if (!card) return;
|
|
1815
|
+
const prefix = `subagente ${event.index}/${event.total}`;
|
|
1816
|
+
if (event.phase === "start") card.tool.summary = `${prefix}: ${event.objective}`;
|
|
1817
|
+
else if (event.phase === "tool") card.tool.summary = `${prefix}: ${event.tool} · ${event.text || ""}`;
|
|
1818
|
+
else if (event.phase === "tool-done") card.tool.summary = `${prefix}: ${event.tool} ${event.ok ? "ok" : "falhou"}`;
|
|
1819
|
+
else if (event.phase === "done")
|
|
1820
|
+
card.tool.summary = `${prefix} ${event.ok ? "concluído" : "falhou"} em ${event.ms}ms`;
|
|
1821
|
+
state.scroll = 0;
|
|
1822
|
+
scheduleRender();
|
|
1823
|
+
},
|
|
1824
|
+
onToolResult: ({ id, name, output, change, ok, skipped, summary, ms }) => {
|
|
1825
|
+
const card = [...state.messages]
|
|
1826
|
+
.reverse()
|
|
1827
|
+
.find(
|
|
1828
|
+
(message) =>
|
|
1829
|
+
message.role === "tool" &&
|
|
1830
|
+
message.tool.status === "running" &&
|
|
1831
|
+
(id ? message.tool.id === id : message.tool.name === name)
|
|
1832
|
+
);
|
|
1833
|
+
if (card) {
|
|
1834
|
+
card.tool.status = "done";
|
|
1835
|
+
card.tool.ok = ok;
|
|
1836
|
+
card.tool.skipped = skipped;
|
|
1837
|
+
card.tool.ms = ms;
|
|
1838
|
+
card.tool.output = String(output || "").slice(0, 6000);
|
|
1839
|
+
card.tool.summary = summary || (String(output || "").split("\n")[0] || "").slice(0, 200);
|
|
1840
|
+
if (change?.path && change.kind !== "command") {
|
|
1841
|
+
const kind = change.kind === "create" ? "create" : "update";
|
|
1842
|
+
const existing = state.files.find((file) => file.path === change.path);
|
|
1843
|
+
if (existing) existing.kind = existing.kind === "create" ? "create" : kind;
|
|
1844
|
+
else state.files.push({ path: change.path, kind });
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
state.scroll = 0;
|
|
1848
|
+
scheduleRender();
|
|
1849
|
+
},
|
|
1850
|
+
});
|
|
1851
|
+
} catch (error) {
|
|
1852
|
+
result = { ok: false, error: `Falha inesperada: ${error.message}` };
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
finishStreaming();
|
|
1856
|
+
state.abort = null;
|
|
619
1857
|
state.busy = false;
|
|
620
|
-
|
|
621
|
-
|
|
1858
|
+
state.streaming = null;
|
|
1859
|
+
|
|
1860
|
+
if (result.aborted || result.code === "aborted") {
|
|
1861
|
+
const partial = String(result.text || "").trim();
|
|
1862
|
+
if (partial) {
|
|
1863
|
+
state.messages.push({ role: "assistant", text: partial, ms: result.ms, interrupted: true });
|
|
1864
|
+
}
|
|
1865
|
+
state.messages.push({
|
|
1866
|
+
role: "system",
|
|
1867
|
+
text: "Interrompido por você (Esc).",
|
|
1868
|
+
interrupted: true,
|
|
1869
|
+
});
|
|
1870
|
+
if (result.messages) state.context = result.messages;
|
|
1871
|
+
} else if (result.ok) {
|
|
1872
|
+
const text = String(result.text || "").trim();
|
|
1873
|
+
if (text) {
|
|
1874
|
+
state.messages.push({
|
|
1875
|
+
role: "assistant",
|
|
1876
|
+
text,
|
|
1877
|
+
ms: result.ms,
|
|
1878
|
+
provider: result.provider || "",
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
if (result.messages) state.context = result.messages;
|
|
622
1882
|
} else {
|
|
623
|
-
state.messages.push({
|
|
1883
|
+
state.messages.push({
|
|
1884
|
+
role: "system",
|
|
1885
|
+
text: result.error || "Falha ao responder.",
|
|
1886
|
+
error: true,
|
|
1887
|
+
});
|
|
624
1888
|
}
|
|
1889
|
+
state.retry = null;
|
|
1890
|
+
state.tokens = estimateTokens(state);
|
|
625
1891
|
state.scroll = 0;
|
|
1892
|
+
persist();
|
|
626
1893
|
render();
|
|
627
|
-
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/** Fecha o bloco em streaming (thinking + texto) como mensagens normais. */
|
|
1897
|
+
function finishStreaming() {
|
|
1898
|
+
const streaming = state.streaming;
|
|
1899
|
+
if (!streaming) return;
|
|
1900
|
+
if (streaming.reasoning.trim()) {
|
|
1901
|
+
state.messages.push({
|
|
1902
|
+
role: "thinking",
|
|
1903
|
+
text: streaming.reasoning.trim(),
|
|
1904
|
+
ms: streaming.thinkingStartedAt ? Date.now() - streaming.thinkingStartedAt : 0,
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
if (streaming.text.trim()) {
|
|
1908
|
+
state.messages.push({ role: "assistant", text: streaming.text.trim() });
|
|
1909
|
+
}
|
|
1910
|
+
state.streaming = { text: "", reasoning: "", thinkingStartedAt: 0 };
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
/** Esc armado uma vez avisa; a segunda cancela o que esta rodando. */
|
|
1914
|
+
function armEscape() {
|
|
1915
|
+
state.escArmed = true;
|
|
1916
|
+
if (escTimer) clearTimeout(escTimer);
|
|
1917
|
+
escTimer = setTimeout(() => {
|
|
1918
|
+
escTimer = null;
|
|
1919
|
+
if (!state.escArmed) return;
|
|
1920
|
+
state.escArmed = false;
|
|
1921
|
+
if (!closed) render();
|
|
1922
|
+
}, 4000);
|
|
1923
|
+
escTimer.unref?.();
|
|
1924
|
+
render();
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
function cancelRun() {
|
|
1928
|
+
state.escArmed = false;
|
|
1929
|
+
if (escTimer) {
|
|
1930
|
+
clearTimeout(escTimer);
|
|
1931
|
+
escTimer = null;
|
|
1932
|
+
}
|
|
1933
|
+
if (state.approval) settleApproval("deny");
|
|
1934
|
+
state.abort?.abort();
|
|
1935
|
+
render();
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
function requestApproval(name, args, extra = {}) {
|
|
1939
|
+
// "Permitir sempre" (ou --allow-all) nao pode voltar a perguntar.
|
|
1940
|
+
if (state.autoApprove) return Promise.resolve(true);
|
|
1941
|
+
return new Promise((resolve) => {
|
|
1942
|
+
state.approval = {
|
|
1943
|
+
name,
|
|
1944
|
+
args,
|
|
1945
|
+
change: extra.change ?? null,
|
|
1946
|
+
fromMcp: !!extra.fromMcp,
|
|
1947
|
+
index: 0,
|
|
1948
|
+
resolve,
|
|
1949
|
+
};
|
|
1950
|
+
render();
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function settleApproval(decision) {
|
|
1955
|
+
const pending = state.approval;
|
|
1956
|
+
if (!pending) return;
|
|
1957
|
+
state.approval = null;
|
|
1958
|
+
if (decision === "always") state.autoApprove = true;
|
|
1959
|
+
render();
|
|
1960
|
+
pending.resolve(decision !== "deny");
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// Com captura de mouse ligada o terminal entrega a roda para a TUI, mas em
|
|
1964
|
+
// troca nao seleciona texto com o arrasto. F2 devolve o mouse ao terminal.
|
|
1965
|
+
function toggleMouse() {
|
|
1966
|
+
state.mouse = !state.mouse;
|
|
1967
|
+
write(mouseMode());
|
|
1968
|
+
state.modal = {
|
|
1969
|
+
title: state.mouse ? "Mouse" : "Selecao",
|
|
1970
|
+
text: state.mouse
|
|
1971
|
+
? "Roda do mouse rola a conversa.\nF2 desliga a captura para selecionar texto e copiar."
|
|
1972
|
+
: "Captura desligada: arraste para selecionar e copie com o terminal.\nF2 liga a roda do mouse de novo.",
|
|
1973
|
+
};
|
|
628
1974
|
render();
|
|
629
1975
|
}
|
|
630
1976
|
|
|
1977
|
+
function copyLastAnswer() {
|
|
1978
|
+
const last = [...state.messages].reverse().find((message) => message.role === "assistant");
|
|
1979
|
+
const text = last
|
|
1980
|
+
? last.text
|
|
1981
|
+
: state.messages
|
|
1982
|
+
.map((message) => message.text)
|
|
1983
|
+
.filter(Boolean)
|
|
1984
|
+
.join("\n\n");
|
|
1985
|
+
|
|
1986
|
+
if (!String(text || "").trim()) {
|
|
1987
|
+
state.modal = { title: "Copiar", text: "Nada para copiar ainda." };
|
|
1988
|
+
return render();
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
const ok = copyToClipboard(text);
|
|
1992
|
+
state.modal = {
|
|
1993
|
+
title: "Copiar",
|
|
1994
|
+
text: ok
|
|
1995
|
+
? `Ultima resposta copiada (${text.length} caracteres).`
|
|
1996
|
+
: "Nao consegui acessar a area de transferencia.\nUse F2 e arraste com o mouse para selecionar.",
|
|
1997
|
+
};
|
|
1998
|
+
render();
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// Clique esquerdo: acerta a linha do item dentro do modal aberto.
|
|
2002
|
+
function handleClick(x, y) {
|
|
2003
|
+
const cols = Math.max(30, stdout.columns || 80);
|
|
2004
|
+
const rows = Math.max(14, stdout.rows || 24);
|
|
2005
|
+
const overlay = buildOverlay(state, cols, rows);
|
|
2006
|
+
if (!overlay || !overlay.hit) return;
|
|
2007
|
+
|
|
2008
|
+
const { first, count, left, width, kind } = overlay.hit;
|
|
2009
|
+
if (x < left || x >= left + width) return;
|
|
2010
|
+
const offset = y - first;
|
|
2011
|
+
if (offset < 0 || offset >= count) return;
|
|
2012
|
+
|
|
2013
|
+
if (kind === "approval") {
|
|
2014
|
+
if (state.approval.index === offset) return settleApproval(APPROVAL_OPTIONS[offset].id);
|
|
2015
|
+
state.approval.index = offset;
|
|
2016
|
+
return render();
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
if (!state.picker) return;
|
|
2020
|
+
const index = overlay.hit.start + offset;
|
|
2021
|
+
if (!state.picker.items[index]) return;
|
|
2022
|
+
state.picker.index = index; if (state.picker.kind === "theme") {
|
|
2023
|
+
previewTheme(index);
|
|
2024
|
+
return applyTheme();
|
|
2025
|
+
}
|
|
2026
|
+
if (state.picker.kind === "chat") return resumeChat(state.picker.items[index]?.id);
|
|
2027
|
+
|
|
2028
|
+
return selectModel();
|
|
2029
|
+
}
|
|
2030
|
+
|
|
631
2031
|
function setInput(value, cursor) {
|
|
632
2032
|
state.input = value;
|
|
633
2033
|
state.cursor = cursor == null ? value.length : clamp(cursor, 0, value.length);
|
|
@@ -636,42 +2036,93 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
636
2036
|
render();
|
|
637
2037
|
}
|
|
638
2038
|
|
|
2039
|
+
function moveApproval(delta) {
|
|
2040
|
+
const count = APPROVAL_OPTIONS.length;
|
|
2041
|
+
state.approval.index = (state.approval.index + delta + count) % count;
|
|
2042
|
+
render();
|
|
2043
|
+
}
|
|
2044
|
+
|
|
639
2045
|
function onKey(str, key = {}) {
|
|
640
2046
|
if (state.suspended) return;
|
|
641
2047
|
if (key.ctrl && key.name === "c") return quit();
|
|
642
2048
|
|
|
643
|
-
//
|
|
2049
|
+
// Aprovacao de ferramenta: navega e confirma.
|
|
2050
|
+
if (state.approval) {
|
|
2051
|
+
if (key.name === "up" || key.name === "down" || key.name === "tab") {
|
|
2052
|
+
return moveApproval(key.name === "up" ? -1 : 1);
|
|
2053
|
+
}
|
|
2054
|
+
if (key.name === "return") return settleApproval(APPROVAL_OPTIONS[state.approval.index].id);
|
|
2055
|
+
if (key.name === "escape") return settleApproval("deny");
|
|
2056
|
+
if (key.name === "a") return settleApproval("always");
|
|
2057
|
+
if (key.name === "y") return settleApproval("once");
|
|
2058
|
+
if (key.name === "n") return settleApproval("deny");
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// Roda do mouse (SGR): 64 = cima, 65 = baixo; botao 0 = clique esquerdo.
|
|
644
2063
|
if (str && str.startsWith("\x1b[<")) {
|
|
645
|
-
const match = str.match(/<(\d+)
|
|
646
|
-
if (match)
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
2064
|
+
const match = str.match(/<(\d+);(\d+);(\d+)([Mm])/);
|
|
2065
|
+
if (!match) return;
|
|
2066
|
+
const button = Number(match[1]);
|
|
2067
|
+
const x = Number(match[2]);
|
|
2068
|
+
const y = Number(match[3]);
|
|
2069
|
+
|
|
2070
|
+
if (button === 0) {
|
|
2071
|
+
if (match[4] !== "M") return;
|
|
2072
|
+
return handleClick(x, y);
|
|
2073
|
+
}
|
|
2074
|
+
if (button !== 64 && button !== 65) return;
|
|
2075
|
+
const step = button === 64 ? 3 : -3;
|
|
2076
|
+
|
|
2077
|
+
if (state.modal) {
|
|
2078
|
+
state.modal = null;
|
|
2079
|
+
return render();
|
|
2080
|
+
}
|
|
2081
|
+
if (state.picker && !state.picker.loading) {
|
|
2082
|
+
const last = Math.max(0, state.picker.items.length - 1);
|
|
2083
|
+
state.picker.index = clamp(state.picker.index + (button === 64 ? -1 : 1), 0, last);
|
|
2084
|
+
if (state.picker.kind === "theme") previewTheme(state.picker.index);
|
|
651
2085
|
return render();
|
|
652
2086
|
}
|
|
2087
|
+
state.scroll = Math.max(0, state.scroll + step);
|
|
2088
|
+
return render();
|
|
653
2089
|
}
|
|
654
2090
|
|
|
655
|
-
|
|
2091
|
+
if (key.name === "f2") return toggleMouse();
|
|
2092
|
+
if (key.ctrl && key.name === "y") return copyLastAnswer();
|
|
2093
|
+
|
|
2094
|
+
// Modal de texto: qualquer tecla fecha — e se for texto, ele vai para o
|
|
2095
|
+
// input em vez de ser engolido (quem digita nao perde o que escreveu).
|
|
656
2096
|
if (state.modal) {
|
|
657
2097
|
state.modal = null;
|
|
2098
|
+
const text = str && !key.ctrl && !key.meta ? str.replace(/[\x00-\x1f\x7f]/g, "") : "";
|
|
2099
|
+
if (text) {
|
|
2100
|
+
return setInput(
|
|
2101
|
+
state.input.slice(0, state.cursor) + text + state.input.slice(state.cursor),
|
|
2102
|
+
state.cursor + text.length
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
658
2105
|
return render();
|
|
659
2106
|
}
|
|
660
2107
|
|
|
661
|
-
// Seletor de modelo.
|
|
2108
|
+
// Seletor de modelo / tema.
|
|
662
2109
|
if (state.picker) {
|
|
663
|
-
|
|
2110
|
+
const picker = state.picker;
|
|
2111
|
+
const themePicker = picker.kind === "theme";
|
|
2112
|
+
if (picker.loading) return;
|
|
664
2113
|
if (key.name === "up" || key.name === "down") {
|
|
665
2114
|
const delta = key.name === "up" ? -1 : 1;
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
0,
|
|
669
|
-
Math.max(0, state.picker.items.length - 1)
|
|
670
|
-
);
|
|
2115
|
+
picker.index = clamp(picker.index + delta, 0, Math.max(0, picker.items.length - 1));
|
|
2116
|
+
if (themePicker) previewTheme(picker.index);
|
|
671
2117
|
return render();
|
|
672
2118
|
}
|
|
673
|
-
if (key.name === "return")
|
|
2119
|
+
if (key.name === "return") {
|
|
2120
|
+
if (themePicker) return applyTheme();
|
|
2121
|
+
if (picker.kind === "chat") return resumeChat(picker.items[picker.index]?.id);
|
|
2122
|
+
return selectModel();
|
|
2123
|
+
}
|
|
674
2124
|
if (key.name === "escape") {
|
|
2125
|
+
if (themePicker) return cancelTheme();
|
|
675
2126
|
state.picker = null;
|
|
676
2127
|
return render();
|
|
677
2128
|
}
|
|
@@ -712,6 +2163,11 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
712
2163
|
state.menuDismissed = true;
|
|
713
2164
|
return render();
|
|
714
2165
|
}
|
|
2166
|
+
// Esc uma vez avisa, Esc de novo para o que esta rodando.
|
|
2167
|
+
if (state.busy || state.draining) {
|
|
2168
|
+
if (state.escArmed) return cancelRun();
|
|
2169
|
+
return armEscape();
|
|
2170
|
+
}
|
|
715
2171
|
return;
|
|
716
2172
|
}
|
|
717
2173
|
if (key.name === "up" || key.name === "down") {
|
|
@@ -751,7 +2207,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
751
2207
|
return setInput(before + state.input.slice(state.cursor), before.length);
|
|
752
2208
|
}
|
|
753
2209
|
if (key.ctrl && key.name === "l") {
|
|
754
|
-
|
|
2210
|
+
clearScreen();
|
|
755
2211
|
return render();
|
|
756
2212
|
}
|
|
757
2213
|
|
|
@@ -769,31 +2225,62 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
769
2225
|
if (closed) return;
|
|
770
2226
|
closed = true;
|
|
771
2227
|
if (spinner) clearInterval(spinner);
|
|
772
|
-
|
|
773
|
-
|
|
2228
|
+
if (renderTimer) clearTimeout(renderTimer);
|
|
2229
|
+
if (escTimer) clearTimeout(escTimer);
|
|
2230
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
2231
|
+
stdin.removeListener("data", onData);
|
|
2232
|
+
stdout.removeListener("resize", onResize);
|
|
774
2233
|
try {
|
|
775
2234
|
stdin.setRawMode(false);
|
|
776
2235
|
} catch {}
|
|
2236
|
+
// Fechou no meio da conversa: guarda e mostra como voltar para ela.
|
|
2237
|
+
const hasChat = state.messages.some(
|
|
2238
|
+
(message) => message.role === "user" || message.role === "assistant"
|
|
2239
|
+
);
|
|
2240
|
+
if (hasChat) saveNow();
|
|
2241
|
+
mcp.close();
|
|
777
2242
|
leaveScreen();
|
|
778
2243
|
write("\n");
|
|
2244
|
+
if (hasChat) {
|
|
2245
|
+
write(
|
|
2246
|
+
`${chalk.cyan(" Retome esta conversa:")} ${chalk.bold(
|
|
2247
|
+
`kafox --resume ${state.sessionId}`
|
|
2248
|
+
)}\n\n`
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
779
2251
|
process.exit(0);
|
|
780
2252
|
}
|
|
781
2253
|
|
|
2254
|
+
function onResize() {
|
|
2255
|
+
clearScreen();
|
|
2256
|
+
render();
|
|
2257
|
+
}
|
|
2258
|
+
|
|
782
2259
|
enterScreen();
|
|
783
2260
|
if (stdin.isTTY) stdin.setRawMode(true);
|
|
2261
|
+
const reader = createKeyReader((event) => onKey(event.str, event.key));
|
|
2262
|
+
const onData = (chunk) => reader.feed(chunk);
|
|
2263
|
+
stdin.setEncoding("utf8");
|
|
2264
|
+
stdin.on("data", onData);
|
|
784
2265
|
stdin.resume();
|
|
785
|
-
|
|
786
|
-
stdin.on("keypress", onKey);
|
|
787
|
-
stdout.on("resize", render);
|
|
2266
|
+
stdout.on("resize", onResize);
|
|
788
2267
|
|
|
789
2268
|
await refreshAccount();
|
|
2269
|
+
if (resumeMissing) {
|
|
2270
|
+
state.modal = {
|
|
2271
|
+
title: "Resume",
|
|
2272
|
+
text: `Nao achei a conversa "${resumeMissing}".\nVeja as salvas em ${sessions.chatsDir()}.`,
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
790
2275
|
render();
|
|
2276
|
+
if (resumePick) openChatPicker();
|
|
2277
|
+
connectMcp().catch(() => {});
|
|
791
2278
|
|
|
792
2279
|
spinner = setInterval(() => {
|
|
793
2280
|
if (!state.busy || closed) return;
|
|
794
2281
|
state.frame += 1;
|
|
795
2282
|
render();
|
|
796
|
-
},
|
|
2283
|
+
}, 180);
|
|
797
2284
|
spinner.unref?.();
|
|
798
2285
|
|
|
799
2286
|
return { state, quit, render, submit };
|
|
@@ -807,4 +2294,11 @@ module.exports = {
|
|
|
807
2294
|
wrap,
|
|
808
2295
|
initialState,
|
|
809
2296
|
filteredCommands,
|
|
2297
|
+
menuLines,
|
|
2298
|
+
decodeKeys,
|
|
2299
|
+
createKeyReader,
|
|
2300
|
+
accentFor,
|
|
2301
|
+
planRepaint,
|
|
2302
|
+
APPROVAL_OPTIONS,
|
|
2303
|
+
layoutFor,
|
|
810
2304
|
};
|