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/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
- * Tudo e desenhado a mao (sem dependencia de TUI) para manter o pacote leve e
6
- * funcionar no Windows Terminal. Sem TTY, o cli.js usa o REPL simples.
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 readline = require("readline");
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 { askModel } = require("./gateway");
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 - 3) + "...";
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
- function padRight(text, width) {
59
- const missing = width - visible(text);
60
- return missing > 0 ? text + " ".repeat(missing) : truncate(text, width);
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
- /* ------------------------------------------------------------------ layout */
518
+ /* ------------------------------------------------------------------- layout */
86
519
 
87
- function headerLines(state, width, rows, footerCount, compact) {
88
- if (state.messages.length || state.picker) return [];
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
- // Centraliza o bloco inteiro: as linhas tem larguras diferentes e cada uma
91
- // precisa do mesmo recuo para a raposa nao sair distorcida.
92
- const artLines = mascot.split("\n");
93
- const artWidth = Math.max(...artLines.map((line) => line.length));
94
- const artLeft = " ".repeat(Math.max(0, Math.floor((width - artWidth) / 2)));
95
- const art = artLines.map((line) => artLeft + chalk.white(line));
96
- const space = rows - 2 - footerCount - (compact ? 0 : 1);
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
- if (space >= art.length + 4) {
99
- return [...art, "", center(chalk.bold("O que voce quer fazer?"), width),
100
- center(chalk.dim("Use / para comandos | Enter envia | Ctrl+C sai"), width), ""];
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
- if (space >= art.length + 2) {
103
- return [...art, "", center(chalk.bold("O que voce quer fazer?"), width), ""];
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
- if (space >= art.length + 1) return [...art, ""];
106
- if (space >= art.length) return art;
107
- return art.slice(0, Math.max(0, space));
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("> ") + chalk.bold(body[0] || ""));
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(" " + chalk.bold(body[index]));
809
+ lines.push(indent(4) + chalk.bold(body[index]));
119
810
  }
120
811
  } else if (message.role === "assistant") {
121
812
  lines.push("");
122
- for (const line of wrap(message.text, width - 2)) lines.push(" " + line);
123
- const meta = message.ms ? `${(message.ms / 1000).toFixed(1)}s` : "";
124
- if (meta) lines.push(chalk.dim(" " + meta));
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
- for (const line of wrap(message.text, width - 2)) lines.push(color(" " + line));
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
- if (state.busy) {
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(` pensando${".".repeat(state.frame % 4)}`));
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).slice(0, 8);
872
+ const items = filteredCommands(state, commands);
152
873
  if (!items.length) return [];
153
- return items.map(([name, description], index) => {
154
- const selected = index === state.menuIndex % items.length;
155
- const text = truncate(` ${name}`.padEnd(24) + description, width);
156
- return selected ? chalk.inverse(padRight(text, width)) : chalk.dim(text);
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 pickerLines(state, width) {
161
- const picker = state.picker;
162
- if (!picker) return [];
163
-
164
- const lines = [
165
- chalk.bold(" Escolha o modelo"),
166
- chalk.dim(" ↑/↓ navega | Enter seleciona | Esc cancela"),
167
- "",
168
- ];
169
- if (picker.loading) return [...lines, chalk.dim(" carregando modelos..."), ""];
170
-
171
- const height = 6;
172
- const start = clamp(picker.index - Math.floor(height / 2), 0, Math.max(0, picker.items.length - height));
173
- picker.items.slice(start, start + height).forEach((item, offset) => {
174
- const real = start + offset;
175
- const price = `in ${(item.input / 1_000_000).toFixed(2)} out ${(item.output / 1_000_000).toFixed(2)} R$/1M`;
176
- const available = Math.max(20, width - price.length - 2);
177
- const label = padRight(truncate(` ${real === picker.index ? ">" : " "} ${item.id}`, available), available);
178
- const line = label + " " + chalk.dim(price);
179
- lines.push(real === picker.index ? chalk.inverse(padRight(line, width)) : line);
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
- function footerLines(state, width) {
186
- const name = `kafox ${state.version}`;
187
- const left = chalk.dim(truncate(`${name} | ${state.model || "auto"}`, width - 16));
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
- left + gap + right,
194
- chalk.dim(truncate(process.cwd(), width)),
195
- chalk.dim(truncate("Enter envia | / comandos | Ctrl+C sai", width)),
196
- ];
197
- }
198
-
199
- function buildFrame(state, cols, rows, commands) {
200
- const width = Math.min(cols - 2, 108);
201
- const margin = Math.floor((cols - width) / 2);
202
- const gutter = " ".repeat(Math.max(0, margin));
203
- const compact = rows < 34;
204
-
205
- const footer = footerLines(state, width).slice(0, compact ? 1 : 3);
206
- const panel = state.picker
207
- ? pickerLines(state, width)
208
- : menuLines(state, commands, width);
209
- const header = headerLines(state, width, rows, footer.length, compact);
210
-
211
- const reserved = 2 + footer.length + (compact ? 0 : 1) + panel.length;
212
- const available = Math.max(0, rows - header.length - reserved);
213
- let body = messageLines(state, width);
214
- if (body.length > available) body = body.slice(body.length - available);
215
- while (body.length < available) body.unshift("");
216
-
217
- const inputWidth = width - 2;
218
- const start = state.input.length > inputWidth
219
- ? clamp(state.cursor - (inputWidth - 2), 0, Math.max(0, state.input.length - inputWidth))
220
- : 0;
221
- const shown = state.input.slice(start, start + inputWidth);
222
- const inputLine = state.input
223
- ? chalk.cyan("> ") + shown
224
- : chalk.cyan("> ") + chalk.dim(truncate("Pergunte alguma coisa...", inputWidth));
225
- const rule = chalk.dim("-".repeat(width));
226
-
227
- const middle = compact ? [] : [""];
228
- const raw = [
229
- ...header,
230
- ...body,
231
- ...middle,
232
- ...panel,
233
- inputLine,
234
- rule,
235
- ...footer,
236
- ];
237
- while (raw.length < rows) raw.push("");
238
- const lines = raw.slice(0, rows).map((line) => gutter + padRight(line, width));
239
-
240
- const inputRow = header.length + available + middle.length + panel.length + 1;
241
- const cursorCol = Math.max(1, margin + 2 + (state.cursor - start) + 1);
242
- return { lines, cursorRow: Math.min(rows, inputRow), cursorCol };
243
- }
244
-
245
- /* --------------------------------------------------------------------- app */
246
-
247
- async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set() } = {}) {
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
- const enterScreen = () => write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J");
256
- const leaveScreen = () => write("\x1b[?25h\x1b[?1049l");
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(40, stdout.columns || 80);
277
- const rows = Math.max(16, stdout.rows || 24);
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
- write("\x1b[?25l\x1b[H" + frame.lines.join("\r\n"));
280
- write(`\x1b[${frame.cursorRow};${frame.cursorCol}H\x1b[?25h`);
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.messages.push({ role: "system", text: "Nao consegui carregar os modelos.", error: true });
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
- state.messages.push({ role: "system", text: `Modelo: ${item.id}` });
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.messages.push({ role: "system", text: "Nada para compactar." });
368
- } else {
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 before = state.messages.length;
381
- const output = (await capture(() => onCommand(line))).trim();
382
- if (output) state.messages.push({ role: "system", text: output });
383
- else if (state.messages.length === before) {
384
- state.messages.push({ role: "system", text: `${name}: sem saida.` });
385
- }
386
- state.model = storage.getConfig().model || state.model;
387
- render();
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
- async function submit() {
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 || state.busy) return;
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.startsWith("/")) return runCommand(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
- state.messages.push({ role: "user", text: line });
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
- const result = await askModel(line, { model: state.model });
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
- if (result.ok) {
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: "assistant",
413
- text: result.text,
414
- ms: result.ms,
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({ role: "system", text: result.error || "Falha ao responder.", error: true });
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
- await refreshAccount();
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
- // Seletor de modelo tem prioridade sobre o resto.
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
- if (state.picker.loading) return;
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
- state.picker.index = clamp(
443
- state.picker.index + delta,
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
- write("\x1b[H\x1b[2J");
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
- stdin.removeListener("keypress", onKey);
547
- stdout.removeListener("resize", render);
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
- readline.emitKeypressEvents(stdin);
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
- }, 110);
2113
+ }, 180);
571
2114
  spinner.unref?.();
572
2115
 
573
2116
  return { state, quit, render, submit };
574
2117
  }
575
2118
 
576
- module.exports = { runTUI, buildFrame, wrap, initialState, filteredCommands, pickerLines };
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
+ };