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