kafox-cli 0.3.0 → 0.7.0

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