kafox-cli 0.3.0 → 0.6.0

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