kafox-cli 0.2.3 → 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 +83 -5
- 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 +1741 -183
package/src/tui.js
CHANGED
|
@@ -1,22 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Interface de terminal do kafox, no estilo opencode
|
|
3
|
-
* tela cheia, mascote, menu de comandos "/", seletor de modelo, input e rodape.
|
|
2
|
+
* Interface de terminal do kafox, no estilo opencode.
|
|
4
3
|
*
|
|
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,13 @@ 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) + "…";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function padRight(text, width) {
|
|
102
|
+
const missing = width - visible(text);
|
|
103
|
+
return missing > 0 ? text + " ".repeat(missing) : truncate(text, width);
|
|
51
104
|
}
|
|
52
105
|
|
|
53
106
|
function center(text, width) {
|
|
@@ -55,11 +108,375 @@ function center(text, width) {
|
|
|
55
108
|
return " ".repeat(pad) + text;
|
|
56
109
|
}
|
|
57
110
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
111
|
+
const indent = (size) => " ".repeat(Math.max(0, size));
|
|
112
|
+
|
|
113
|
+
/* ----------------------------------------------------------------- markdown */
|
|
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
|
+
|
|
121
|
+
const INLINE = /(\*\*[^*]+\*\*|`[^`]+`|\*[^*\n]+\*)/g;
|
|
122
|
+
|
|
123
|
+
function parseInline(text) {
|
|
124
|
+
const parts = String(text).split(INLINE);
|
|
125
|
+
const segments = [];
|
|
126
|
+
for (const part of parts) {
|
|
127
|
+
if (!part) continue;
|
|
128
|
+
if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
|
|
129
|
+
segments.push({ text: part.slice(2, -2), bold: true });
|
|
130
|
+
} else if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
|
|
131
|
+
segments.push({ text: part.slice(1, -1), code: true });
|
|
132
|
+
} else if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
|
|
133
|
+
segments.push({ text: part.slice(1, -1), italic: true });
|
|
134
|
+
} else {
|
|
135
|
+
segments.push({ text: part });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return segments;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function wrapSegments(segments, width) {
|
|
142
|
+
const max = Math.max(10, width);
|
|
143
|
+
const lines = [];
|
|
144
|
+
let current = [];
|
|
145
|
+
let length = 0;
|
|
146
|
+
|
|
147
|
+
const flush = () => {
|
|
148
|
+
lines.push(current);
|
|
149
|
+
current = [];
|
|
150
|
+
length = 0;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
for (const segment of segments) {
|
|
154
|
+
for (const token of String(segment.text).split(/(\s+)/)) {
|
|
155
|
+
if (!token || !token.trim()) continue;
|
|
156
|
+
let word = token;
|
|
157
|
+
while (word.length > max) {
|
|
158
|
+
const slice = word.slice(0, max - (length > 0 ? 1 : 0));
|
|
159
|
+
if (length > 0) {
|
|
160
|
+
current.push({ text: " " });
|
|
161
|
+
length += 1;
|
|
162
|
+
}
|
|
163
|
+
current.push({ ...segment, text: slice });
|
|
164
|
+
flush();
|
|
165
|
+
word = word.slice(slice.length);
|
|
166
|
+
}
|
|
167
|
+
const space = length > 0 ? 1 : 0;
|
|
168
|
+
if (length + space + word.length > max) flush();
|
|
169
|
+
if (length > 0) {
|
|
170
|
+
current.push({ text: " " });
|
|
171
|
+
length += 1;
|
|
172
|
+
}
|
|
173
|
+
current.push({ ...segment, text: word });
|
|
174
|
+
length += word.length;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (current.length) flush();
|
|
178
|
+
return lines;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function renderSegments(segments, theme = "default") {
|
|
182
|
+
const accent = accentFor(theme);
|
|
183
|
+
return segments
|
|
184
|
+
.map((segment) => {
|
|
185
|
+
let text = segment.text;
|
|
186
|
+
if (segment.code) text = chalk.cyan(text);
|
|
187
|
+
if (segment.italic) text = chalk.italic(text);
|
|
188
|
+
if (segment.bold) text = accent(chalk.bold(text));
|
|
189
|
+
return text;
|
|
190
|
+
})
|
|
191
|
+
.join("");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Converte Markdown simples em linhas prontas para o terminal. */
|
|
195
|
+
function markdownLines(text, width, theme = "default") {
|
|
196
|
+
const accent = accentFor(theme);
|
|
197
|
+
const out = [];
|
|
198
|
+
const rows = String(text).replace(/\r/g, "").split("\n");
|
|
199
|
+
let inCode = false;
|
|
200
|
+
|
|
201
|
+
for (const raw of rows) {
|
|
202
|
+
const line = raw.replace(/\s+$/, "");
|
|
203
|
+
if (/^\s*```/.test(line)) {
|
|
204
|
+
inCode = !inCode;
|
|
205
|
+
out.push(chalk.dim(indent(2) + "─".repeat(Math.min(width - 2, 24))));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (inCode) {
|
|
209
|
+
out.push(chalk.dim(indent(2) + "│ ") + chalk.cyan(truncate(line, width - 4)));
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (!line.trim()) {
|
|
213
|
+
out.push("");
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
217
|
+
out.push(chalk.dim(indent(2) + "─".repeat(Math.min(width - 2, 40))));
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const heading = line.match(/^\s*#{1,6}\s+(.*)$/);
|
|
222
|
+
if (heading) {
|
|
223
|
+
if (out.length) out.push("");
|
|
224
|
+
for (const wrapped of wrapSegments(parseInline(heading[1]), width - 2)) {
|
|
225
|
+
out.push(indent(2) + accent(chalk.bold(renderSegments(wrapped, theme))));
|
|
226
|
+
}
|
|
227
|
+
out.push("");
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const bullet = line.match(/^\s*[-*+]\s+(.*)$/);
|
|
232
|
+
if (bullet) {
|
|
233
|
+
const wrapped = wrapSegments(parseInline(bullet[1]), width - 5);
|
|
234
|
+
wrapped.forEach((part, index) => {
|
|
235
|
+
out.push((index === 0 ? indent(2) + "• " : indent(4)) + renderSegments(part, theme));
|
|
236
|
+
});
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const numbered = line.match(/^\s*(\d+)[.)]\s+(.*)$/);
|
|
241
|
+
if (numbered) {
|
|
242
|
+
const wrapped = wrapSegments(parseInline(numbered[2]), width - 6);
|
|
243
|
+
wrapped.forEach((part) => {
|
|
244
|
+
out.push(indent(2) + `${numbered[1]}. ` + renderSegments(part, theme));
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const quote = line.match(/^\s*>\s?(.*)$/);
|
|
250
|
+
if (quote) {
|
|
251
|
+
for (const part of wrapSegments(parseInline(quote[1]), width - 5)) {
|
|
252
|
+
out.push(chalk.dim(indent(2) + "│ " + stripAnsi(renderSegments(part, theme))));
|
|
253
|
+
}
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Modelos costumam indentar paragrafos; no terminal isso vira ruim.
|
|
258
|
+
const clean = line.replace(/^\s+(?=\S)/, "");
|
|
259
|
+
for (const part of wrapSegments(parseInline(clean), width - 2)) {
|
|
260
|
+
out.push(indent(2) + renderSegments(part, theme));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
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) };
|
|
61
369
|
}
|
|
62
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 */
|
|
479
|
+
|
|
63
480
|
function initialState() {
|
|
64
481
|
const cfg = storage.getConfig();
|
|
65
482
|
return {
|
|
@@ -68,13 +485,29 @@ function initialState() {
|
|
|
68
485
|
history: [],
|
|
69
486
|
historyIndex: -1,
|
|
70
487
|
messages: [],
|
|
488
|
+
context: [],
|
|
489
|
+
streaming: null,
|
|
490
|
+
approval: null,
|
|
491
|
+
autoApprove: false,
|
|
71
492
|
busy: false,
|
|
72
493
|
frame: 0,
|
|
494
|
+
queue: [],
|
|
495
|
+
draining: false,
|
|
496
|
+
escArmed: false,
|
|
497
|
+
abort: null,
|
|
498
|
+
|
|
73
499
|
menuIndex: 0,
|
|
74
500
|
menuDismissed: false,
|
|
75
501
|
picker: null,
|
|
502
|
+
modal: null,
|
|
76
503
|
models: [],
|
|
77
504
|
balance: null,
|
|
505
|
+
tokens: 0,
|
|
506
|
+
files: [],
|
|
507
|
+
mcp: { loading: false, statuses: [], tools: 0 },
|
|
508
|
+
scroll: 0,
|
|
509
|
+
pageSize: 10,
|
|
510
|
+
mouse: true,
|
|
78
511
|
model: cfg.model || null,
|
|
79
512
|
theme: cfg.theme || "default",
|
|
80
513
|
suspended: false,
|
|
@@ -82,29 +515,287 @@ function initialState() {
|
|
|
82
515
|
};
|
|
83
516
|
}
|
|
84
517
|
|
|
85
|
-
/*
|
|
518
|
+
/* ------------------------------------------------------------------- layout */
|
|
86
519
|
|
|
87
|
-
function
|
|
88
|
-
|
|
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
|
+
}
|
|
89
526
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
}
|
|
97
651
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
}
|
|
660
|
+
|
|
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
|
+
}
|
|
675
|
+
|
|
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}`) : ""}`;
|
|
687
|
+
}
|
|
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);
|
|
694
|
+
}
|
|
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 });
|
|
101
707
|
}
|
|
102
|
-
|
|
103
|
-
|
|
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));
|
|
104
714
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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;
|
|
108
799
|
}
|
|
109
800
|
|
|
110
801
|
function messageLines(state, width) {
|
|
@@ -113,24 +804,54 @@ function messageLines(state, width) {
|
|
|
113
804
|
if (message.role === "user") {
|
|
114
805
|
lines.push("");
|
|
115
806
|
const body = wrap(message.text, width - 2);
|
|
116
|
-
lines.push(chalk.cyan("
|
|
807
|
+
lines.push(indent(2) + chalk.cyan("▌ ") + chalk.bold(body[0] || ""));
|
|
117
808
|
for (let index = 1; index < body.length; index += 1) {
|
|
118
|
-
lines.push(
|
|
809
|
+
lines.push(indent(4) + chalk.bold(body[index]));
|
|
119
810
|
}
|
|
120
811
|
} else if (message.role === "assistant") {
|
|
121
812
|
lines.push("");
|
|
122
|
-
|
|
123
|
-
const meta =
|
|
124
|
-
if (
|
|
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));
|
|
125
824
|
} else {
|
|
126
825
|
lines.push("");
|
|
127
|
-
const color = message.error ? chalk.red : chalk.dim;
|
|
128
|
-
|
|
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));
|
|
129
829
|
}
|
|
130
830
|
}
|
|
131
|
-
|
|
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));
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (state.busy && !streaming?.text) {
|
|
132
853
|
lines.push("");
|
|
133
|
-
lines.push(chalk.dim(
|
|
854
|
+
lines.push(indent(2) + chalk.dim("pensando" + ".".repeat(state.frame % 4)));
|
|
134
855
|
}
|
|
135
856
|
return lines;
|
|
136
857
|
}
|
|
@@ -148,112 +869,464 @@ function filteredCommands(state, commands) {
|
|
|
148
869
|
}
|
|
149
870
|
|
|
150
871
|
function menuLines(state, commands, width) {
|
|
151
|
-
const items = filteredCommands(state, commands)
|
|
872
|
+
const items = filteredCommands(state, commands);
|
|
152
873
|
if (!items.length) return [];
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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)));
|
|
157
889
|
});
|
|
890
|
+
return rows;
|
|
158
891
|
}
|
|
159
892
|
|
|
160
|
-
function
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
});
|
|
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
|
+
|
|
181
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
|
+
|
|
182
947
|
return lines;
|
|
183
948
|
}
|
|
184
949
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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)));
|
|
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
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
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);
|
|
188
1015
|
const right = chalk.green(
|
|
189
1016
|
state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`
|
|
190
1017
|
);
|
|
191
1018
|
const gap = " ".repeat(Math.max(1, width - visible(left) - visible(right)));
|
|
192
|
-
return [
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
];
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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) + "┐"));
|
|
1049
|
+
if (title) {
|
|
1050
|
+
out.push(row(" " + chalk.bold(truncate(title, inner - 2))));
|
|
1051
|
+
out.push(row(""));
|
|
1052
|
+
}
|
|
1053
|
+
const bodyFirst = out.length;
|
|
1054
|
+
for (const line of body) out.push(row(" " + line));
|
|
1055
|
+
if (hint) {
|
|
1056
|
+
out.push(row(""));
|
|
1057
|
+
out.push(row(" " + chalk.dim(truncate(hint, inner - 2))));
|
|
1058
|
+
}
|
|
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);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
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
|
+
|
|
1121
|
+
let title = "";
|
|
1122
|
+
let hint = "";
|
|
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) {
|
|
1133
|
+
const picker = state.picker;
|
|
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
|
+
|
|
1138
|
+
if (picker.loading) {
|
|
1139
|
+
body = [chalk.dim("carregando modelos...")];
|
|
1140
|
+
} else {
|
|
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) {
|
|
1148
|
+
const real = start + offset;
|
|
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" };
|
|
1163
|
+
}
|
|
1164
|
+
} else {
|
|
1165
|
+
title = state.modal.title;
|
|
1166
|
+
hint = state.modal.hint || "qualquer tecla fecha";
|
|
1167
|
+
body = wrap(state.modal.text || "", width - 4);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
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;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const boxed = box(state, { title, body, hint, width });
|
|
1177
|
+
const top = Math.max(1, Math.floor((rows - boxed.lines.length) / 2) + 1);
|
|
1178
|
+
const left = Math.max(1, Math.floor((cols - width) / 2) + 1);
|
|
1179
|
+
|
|
1180
|
+
return {
|
|
1181
|
+
top,
|
|
1182
|
+
left,
|
|
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,
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
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
|
+
}
|
|
1266
|
+
|
|
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
|
+
} = {}) {
|
|
248
1301
|
const stdin = process.stdin;
|
|
249
1302
|
const stdout = process.stdout;
|
|
250
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() });
|
|
251
1307
|
let spinner = null;
|
|
1308
|
+
let escTimer = null;
|
|
252
1309
|
let closed = false;
|
|
1310
|
+
let previousLines = [];
|
|
253
1311
|
|
|
254
1312
|
const write = (value) => stdout.write(value);
|
|
255
|
-
|
|
256
|
-
|
|
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");
|
|
1326
|
+
const enterScreen = () =>
|
|
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");
|
|
257
1330
|
|
|
258
1331
|
async function refreshAccount() {
|
|
259
1332
|
const token = await ensureSession();
|
|
@@ -271,13 +1344,47 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
271
1344
|
}
|
|
272
1345
|
}
|
|
273
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
|
+
|
|
274
1368
|
function render() {
|
|
275
1369
|
if (closed || state.suspended) return;
|
|
276
|
-
const cols = Math.max(
|
|
277
|
-
const rows = Math.max(
|
|
1370
|
+
const cols = Math.max(30, stdout.columns || 80);
|
|
1371
|
+
const rows = Math.max(14, stdout.rows || 24);
|
|
278
1372
|
const frame = buildFrame(state, cols, rows, commands);
|
|
279
|
-
|
|
280
|
-
|
|
1373
|
+
paintFrame(frame, { cols, rows });
|
|
1374
|
+
}
|
|
1375
|
+
|
|
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 = [];
|
|
281
1388
|
}
|
|
282
1389
|
|
|
283
1390
|
async function capture(fn) {
|
|
@@ -307,18 +1414,53 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
307
1414
|
return state.models;
|
|
308
1415
|
}
|
|
309
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
|
+
|
|
310
1452
|
async function openModelPicker() {
|
|
311
1453
|
state.menuDismissed = true;
|
|
312
|
-
state.picker = { items: state.models, index: 0, loading: true };
|
|
1454
|
+
state.picker = { kind: "model", items: state.models, index: 0, loading: true };
|
|
313
1455
|
render();
|
|
314
1456
|
const models = await loadModels();
|
|
315
1457
|
if (!models.length) {
|
|
316
1458
|
state.picker = null;
|
|
317
|
-
state.
|
|
1459
|
+
state.modal = { title: "Modelos", text: "Nao consegui carregar os modelos." };
|
|
318
1460
|
return render();
|
|
319
1461
|
}
|
|
320
1462
|
const current = models.findIndex((item) => item.id === state.model);
|
|
321
|
-
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 };
|
|
322
1464
|
render();
|
|
323
1465
|
}
|
|
324
1466
|
|
|
@@ -330,7 +1472,33 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
330
1472
|
storage.setConfig({ model: item.id });
|
|
331
1473
|
state.model = item.id;
|
|
332
1474
|
state.picker = null;
|
|
333
|
-
|
|
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") };
|
|
334
1502
|
render();
|
|
335
1503
|
}
|
|
336
1504
|
|
|
@@ -346,6 +1514,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
346
1514
|
stdin.setRawMode(true);
|
|
347
1515
|
state.suspended = false;
|
|
348
1516
|
enterScreen();
|
|
1517
|
+
previousLines = [];
|
|
349
1518
|
await refreshAccount();
|
|
350
1519
|
render();
|
|
351
1520
|
}
|
|
@@ -357,41 +1526,77 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
357
1526
|
if (name === "/exit" || name === "/quit") return quit();
|
|
358
1527
|
|
|
359
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
|
+
}
|
|
360
1549
|
|
|
361
1550
|
if (name === "/clear") {
|
|
362
1551
|
state.messages = [];
|
|
1552
|
+
state.context = [];
|
|
1553
|
+
state.streaming = null;
|
|
1554
|
+
state.files = [];
|
|
1555
|
+
state.scroll = 0;
|
|
363
1556
|
return render();
|
|
364
1557
|
}
|
|
365
1558
|
if (name === "/compact") {
|
|
366
|
-
if (!state.messages.length) {
|
|
367
|
-
state.
|
|
368
|
-
|
|
369
|
-
const total = state.messages.length;
|
|
370
|
-
state.messages = [
|
|
371
|
-
{ role: "system", text: `Contexto compactado: ${total} mensagens resumidas.` },
|
|
372
|
-
...state.messages.slice(-6),
|
|
373
|
-
];
|
|
1559
|
+
if (!state.messages.length && !state.context.length) {
|
|
1560
|
+
state.modal = { title: "Contexto", text: "Nada para compactar." };
|
|
1561
|
+
return render();
|
|
374
1562
|
}
|
|
1563
|
+
const total = state.messages.length;
|
|
1564
|
+
const kept = state.context.slice(-6);
|
|
1565
|
+
state.messages = [
|
|
1566
|
+
{ role: "system", text: `Contexto compactado: ${total} mensagens resumidas.` },
|
|
1567
|
+
...state.messages.slice(-6),
|
|
1568
|
+
];
|
|
1569
|
+
// O system prompt do agente (indice 0) continua valendo.
|
|
1570
|
+
state.context = state.context.length ? [state.context[0], ...kept] : [];
|
|
1571
|
+
state.scroll = 0;
|
|
375
1572
|
return render();
|
|
376
1573
|
}
|
|
377
1574
|
|
|
378
1575
|
if (interactiveCommands.has(name)) return runOutside(line);
|
|
379
1576
|
|
|
380
|
-
const
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
1577
|
+
const output = stripAnsi(await capture(() => onCommand(line))).trim();
|
|
1578
|
+
const config = storage.getConfig();
|
|
1579
|
+
state.model = config.model || state.model;
|
|
1580
|
+
state.theme = config.theme || state.theme;
|
|
1581
|
+
state.modal = {
|
|
1582
|
+
title: name.replace(/^\//, ""),
|
|
1583
|
+
text: output || "(sem saida)",
|
|
1584
|
+
};
|
|
388
1585
|
await refreshAccount();
|
|
389
1586
|
render();
|
|
390
1587
|
}
|
|
391
1588
|
|
|
392
|
-
|
|
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() {
|
|
393
1598
|
const line = state.input.trim();
|
|
394
|
-
if (!line
|
|
1599
|
+
if (!line) return;
|
|
395
1600
|
state.history.push(line);
|
|
396
1601
|
state.historyIndex = state.history.length;
|
|
397
1602
|
state.input = "";
|
|
@@ -399,29 +1604,284 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
399
1604
|
state.menuIndex = 0;
|
|
400
1605
|
state.menuDismissed = false;
|
|
401
1606
|
|
|
402
|
-
if (line
|
|
1607
|
+
if (line === "/exit" || line === "/quit") return quit();
|
|
1608
|
+
|
|
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
|
+
}
|
|
403
1637
|
|
|
404
|
-
|
|
1638
|
+
const controller = new AbortController();
|
|
1639
|
+
state.abort = controller;
|
|
1640
|
+
state.escArmed = false;
|
|
1641
|
+
startMessageText(line);
|
|
1642
|
+
state.context.push({ role: "user", content: line });
|
|
405
1643
|
state.busy = true;
|
|
1644
|
+
state.streaming = { text: "", reasoning: "", thinkingStartedAt: 0 };
|
|
1645
|
+
state.scroll = 0;
|
|
406
1646
|
render();
|
|
407
1647
|
|
|
408
|
-
|
|
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;
|
|
409
1713
|
state.busy = false;
|
|
410
|
-
|
|
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
|
+
}
|
|
411
1721
|
state.messages.push({
|
|
412
|
-
role: "
|
|
413
|
-
text:
|
|
414
|
-
|
|
415
|
-
provider: result.provider,
|
|
1722
|
+
role: "system",
|
|
1723
|
+
text: "Interrompido por você (Esc).",
|
|
1724
|
+
interrupted: true,
|
|
416
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;
|
|
417
1738
|
} else {
|
|
418
|
-
state.messages.push({
|
|
1739
|
+
state.messages.push({
|
|
1740
|
+
role: "system",
|
|
1741
|
+
text: result.error || "Falha ao responder.",
|
|
1742
|
+
error: true,
|
|
1743
|
+
});
|
|
419
1744
|
}
|
|
1745
|
+
state.tokens = estimateTokens(state);
|
|
1746
|
+
state.scroll = 0;
|
|
420
1747
|
render();
|
|
421
|
-
|
|
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?.();
|
|
422
1778
|
render();
|
|
423
1779
|
}
|
|
424
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();
|
|
1789
|
+
render();
|
|
1790
|
+
}
|
|
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
|
+
|
|
425
1885
|
function setInput(value, cursor) {
|
|
426
1886
|
state.input = value;
|
|
427
1887
|
state.cursor = cursor == null ? value.length : clamp(cursor, 0, value.length);
|
|
@@ -430,30 +1890,101 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
430
1890
|
render();
|
|
431
1891
|
}
|
|
432
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
|
+
|
|
433
1899
|
function onKey(str, key = {}) {
|
|
434
1900
|
if (state.suspended) return;
|
|
435
1901
|
if (key.ctrl && key.name === "c") return quit();
|
|
436
1902
|
|
|
437
|
-
//
|
|
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.
|
|
1917
|
+
if (str && str.startsWith("\x1b[<")) {
|
|
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);
|
|
1939
|
+
return render();
|
|
1940
|
+
}
|
|
1941
|
+
state.scroll = Math.max(0, state.scroll + step);
|
|
1942
|
+
return render();
|
|
1943
|
+
}
|
|
1944
|
+
|
|
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).
|
|
1950
|
+
if (state.modal) {
|
|
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
|
+
}
|
|
1959
|
+
return render();
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// Seletor de modelo / tema.
|
|
438
1963
|
if (state.picker) {
|
|
439
|
-
|
|
1964
|
+
const picker = state.picker;
|
|
1965
|
+
const themePicker = picker.kind === "theme";
|
|
1966
|
+
if (picker.loading) return;
|
|
440
1967
|
if (key.name === "up" || key.name === "down") {
|
|
441
1968
|
const delta = key.name === "up" ? -1 : 1;
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
0,
|
|
445
|
-
Math.max(0, state.picker.items.length - 1)
|
|
446
|
-
);
|
|
1969
|
+
picker.index = clamp(picker.index + delta, 0, Math.max(0, picker.items.length - 1));
|
|
1970
|
+
if (themePicker) previewTheme(picker.index);
|
|
447
1971
|
return render();
|
|
448
1972
|
}
|
|
449
|
-
if (key.name === "return") return selectModel();
|
|
1973
|
+
if (key.name === "return") return themePicker ? applyTheme() : selectModel();
|
|
450
1974
|
if (key.name === "escape") {
|
|
1975
|
+
if (themePicker) return cancelTheme();
|
|
451
1976
|
state.picker = null;
|
|
452
1977
|
return render();
|
|
453
1978
|
}
|
|
454
1979
|
return;
|
|
455
1980
|
}
|
|
456
1981
|
|
|
1982
|
+
if (key.name === "pageup" || key.name === "pagedown") {
|
|
1983
|
+
const delta = key.name === "pageup" ? state.pageSize : -state.pageSize;
|
|
1984
|
+
state.scroll = Math.max(0, state.scroll + delta);
|
|
1985
|
+
return render();
|
|
1986
|
+
}
|
|
1987
|
+
|
|
457
1988
|
const items = filteredCommands(state, commands);
|
|
458
1989
|
const menuOpen = items.length > 0;
|
|
459
1990
|
|
|
@@ -466,7 +1997,6 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
466
1997
|
state.input = exact;
|
|
467
1998
|
state.cursor = exact.length;
|
|
468
1999
|
} else if (menuOpen) {
|
|
469
|
-
// Enter executa o item destacado, nao o texto parcial digitado.
|
|
470
2000
|
const selected = items[state.menuIndex % items.length][0];
|
|
471
2001
|
state.input = selected;
|
|
472
2002
|
state.cursor = selected.length;
|
|
@@ -475,10 +2005,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
475
2005
|
return submit();
|
|
476
2006
|
}
|
|
477
2007
|
if (key.name === "tab") {
|
|
478
|
-
if (menuOpen)
|
|
479
|
-
const selected = items[state.menuIndex % items.length];
|
|
480
|
-
return setInput(selected[0] + " ");
|
|
481
|
-
}
|
|
2008
|
+
if (menuOpen) return setInput(items[state.menuIndex % items.length][0] + " ");
|
|
482
2009
|
return;
|
|
483
2010
|
}
|
|
484
2011
|
if (key.name === "escape") {
|
|
@@ -486,6 +2013,11 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
486
2013
|
state.menuDismissed = true;
|
|
487
2014
|
return render();
|
|
488
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
|
+
}
|
|
489
2021
|
return;
|
|
490
2022
|
}
|
|
491
2023
|
if (key.name === "up" || key.name === "down") {
|
|
@@ -525,7 +2057,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
525
2057
|
return setInput(before + state.input.slice(state.cursor), before.length);
|
|
526
2058
|
}
|
|
527
2059
|
if (key.ctrl && key.name === "l") {
|
|
528
|
-
|
|
2060
|
+
clearScreen();
|
|
529
2061
|
return render();
|
|
530
2062
|
}
|
|
531
2063
|
|
|
@@ -543,34 +2075,60 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
543
2075
|
if (closed) return;
|
|
544
2076
|
closed = true;
|
|
545
2077
|
if (spinner) clearInterval(spinner);
|
|
546
|
-
|
|
547
|
-
|
|
2078
|
+
if (renderTimer) clearTimeout(renderTimer);
|
|
2079
|
+
if (escTimer) clearTimeout(escTimer);
|
|
2080
|
+
stdin.removeListener("data", onData);
|
|
2081
|
+
stdout.removeListener("resize", onResize);
|
|
548
2082
|
try {
|
|
549
2083
|
stdin.setRawMode(false);
|
|
550
2084
|
} catch {}
|
|
2085
|
+
mcp.close();
|
|
551
2086
|
leaveScreen();
|
|
552
2087
|
write("\n");
|
|
553
2088
|
process.exit(0);
|
|
554
2089
|
}
|
|
555
2090
|
|
|
2091
|
+
function onResize() {
|
|
2092
|
+
clearScreen();
|
|
2093
|
+
render();
|
|
2094
|
+
}
|
|
2095
|
+
|
|
556
2096
|
enterScreen();
|
|
557
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);
|
|
558
2102
|
stdin.resume();
|
|
559
|
-
|
|
560
|
-
stdin.on("keypress", onKey);
|
|
561
|
-
stdout.on("resize", render);
|
|
2103
|
+
stdout.on("resize", onResize);
|
|
562
2104
|
|
|
563
2105
|
await refreshAccount();
|
|
564
2106
|
render();
|
|
2107
|
+
connectMcp().catch(() => {});
|
|
565
2108
|
|
|
566
2109
|
spinner = setInterval(() => {
|
|
567
2110
|
if (!state.busy || closed) return;
|
|
568
2111
|
state.frame += 1;
|
|
569
2112
|
render();
|
|
570
|
-
},
|
|
2113
|
+
}, 180);
|
|
571
2114
|
spinner.unref?.();
|
|
572
2115
|
|
|
573
2116
|
return { state, quit, render, submit };
|
|
574
2117
|
}
|
|
575
2118
|
|
|
576
|
-
module.exports = {
|
|
2119
|
+
module.exports = {
|
|
2120
|
+
runTUI,
|
|
2121
|
+
buildFrame,
|
|
2122
|
+
buildOverlay,
|
|
2123
|
+
markdownLines,
|
|
2124
|
+
wrap,
|
|
2125
|
+
initialState,
|
|
2126
|
+
filteredCommands,
|
|
2127
|
+
menuLines,
|
|
2128
|
+
decodeKeys,
|
|
2129
|
+
createKeyReader,
|
|
2130
|
+
accentFor,
|
|
2131
|
+
planRepaint,
|
|
2132
|
+
APPROVAL_OPTIONS,
|
|
2133
|
+
layoutFor,
|
|
2134
|
+
};
|