kafox-cli 0.2.2 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +11 -3
  2. package/package.json +2 -2
  3. package/src/tui.js +327 -88
package/README.md CHANGED
@@ -12,7 +12,7 @@ npm install -g kafox-cli
12
12
  O comando instalado e `kafox`. Alternativa sem npm:
13
13
 
14
14
  ```bash
15
- npm install -g https://kafox.pypag.com/static/downloads/kafox-cli-0.2.2.tgz
15
+ npm install -g https://kafox.pypag.com/static/downloads/kafox-cli-0.3.0.tgz
16
16
  ```
17
17
 
18
18
  Requisito: Node.js 22.5+.
@@ -43,8 +43,10 @@ navegador, entre na sua conta e clique em **Autorizar**. A sessao fica em
43
43
  | `kafox background [url]` | imagem de fundo do terminal |
44
44
  | `kafox compact` / `clear` | compacta / limpa o contexto do REPL |
45
45
 
46
- Na interface: digite `/` para o menu (↑/↓ navega, Tab completa, Enter executa
47
- ), `/model` abre o seletor de modelo (↑/↓ + Enter). Demais comandos: `/help`,
46
+ Na interface: digite `/` para o menu (↑/↓ navega, Tab completa, Enter executa),
47
+ `/model` abre o seletor de modelo (↑/↓ + Enter). Respostas sao renderizadas em
48
+ Markdown (titulos, listas, negrito e codigo). PgUp/PgDn ou a roda do mouse rolam a
49
+ conversa. Saidas de comando abrem num modal central; qualquer tecla fecha. Demais comandos: `/help`,
48
50
  `/models`, `/balance`, `/pricing`, `/key`, `/login`, `/logout`, `/whoami`,
49
51
  `/theme`, `/background`, `/clear`, `/compact`, `/status`, `/exit`.
50
52
 
@@ -56,3 +58,9 @@ Na interface: digite `/` para o menu (↑/↓ navega, Tab completa, Enter execut
56
58
 
57
59
  A CLI so responde se a conta tiver saldo; sem credito ela avisa e nao chama o
58
60
  gateway.
61
+
62
+ ## Escopo
63
+
64
+ O kafox e um cliente de conversa: autentica na conta, lista modelos, mostra saldo
65
+ e envia mensagens ao gateway. Ele nao executa ferramentas locais nem conecta
66
+ servidores MCP.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.2.2",
4
- "description": "Kafox CLI in opencode style: full-screen terminal UI, device login, models, balance, themes",
3
+ "version": "0.3.0",
4
+ "description": "Kafox CLI in opencode style: scrollable TUI, markdown, modals, device login, models, balance",
5
5
  "bin": {
6
6
  "kafox": "bin/kafox.js"
7
7
  },
package/src/tui.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Interface de terminal do kafox, no estilo opencode:
3
- * tela cheia, mascote, menu de comandos "/", seletor de modelo, input e rodape.
2
+ * Interface de terminal do kafox, no estilo opencode.
4
3
  *
5
- * Tudo e desenhado a mao (sem dependencia de TUI) para manter o pacote leve e
6
- * funcionar no Windows Terminal. Sem TTY, o cli.js usa o REPL simples.
4
+ * - 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
7
7
  */
8
8
  const readline = require("readline");
9
9
  const chalk = require("chalk");
@@ -50,16 +50,163 @@ function truncate(text, width) {
50
50
  return plain.slice(0, width - 3) + "...";
51
51
  }
52
52
 
53
+ function padRight(text, width) {
54
+ const missing = width - visible(text);
55
+ return missing > 0 ? text + " ".repeat(missing) : truncate(text, width);
56
+ }
57
+
53
58
  function center(text, width) {
54
59
  const pad = Math.max(0, Math.floor((width - visible(text)) / 2));
55
60
  return " ".repeat(pad) + text;
56
61
  }
57
62
 
58
- function padRight(text, width) {
59
- const missing = width - visible(text);
60
- return missing > 0 ? text + " ".repeat(missing) : truncate(text, width);
63
+ /* ----------------------------------------------------------------- markdown */
64
+
65
+ const INLINE = /(\*\*[^*]+\*\*|`[^`]+`|\*[^*\n]+\*)/g;
66
+
67
+ function parseInline(text) {
68
+ const parts = String(text).split(INLINE);
69
+ const segments = [];
70
+ for (const part of parts) {
71
+ if (!part) continue;
72
+ if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
73
+ segments.push({ text: part.slice(2, -2), bold: true });
74
+ } else if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
75
+ segments.push({ text: part.slice(1, -1), code: true });
76
+ } else if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
77
+ segments.push({ text: part.slice(1, -1), italic: true });
78
+ } else {
79
+ segments.push({ text: part });
80
+ }
81
+ }
82
+ return segments;
61
83
  }
62
84
 
85
+ function wrapSegments(segments, width) {
86
+ const max = Math.max(10, width);
87
+ const lines = [];
88
+ let current = [];
89
+ let length = 0;
90
+
91
+ const flush = () => {
92
+ lines.push(current);
93
+ current = [];
94
+ length = 0;
95
+ };
96
+
97
+ for (const segment of segments) {
98
+ for (const token of String(segment.text).split(/(\s+)/)) {
99
+ if (!token || !token.trim()) continue;
100
+ let word = token;
101
+ while (word.length > max) {
102
+ const slice = word.slice(0, max - (length > 0 ? 1 : 0));
103
+ if (length > 0) {
104
+ current.push({ text: " " });
105
+ length += 1;
106
+ }
107
+ current.push({ ...segment, text: slice });
108
+ flush();
109
+ word = word.slice(slice.length);
110
+ }
111
+ const space = length > 0 ? 1 : 0;
112
+ if (length + space + word.length > max) flush();
113
+ if (length > 0) {
114
+ current.push({ text: " " });
115
+ length += 1;
116
+ }
117
+ current.push({ ...segment, text: word });
118
+ length += word.length;
119
+ }
120
+ }
121
+ if (current.length) flush();
122
+ return lines;
123
+ }
124
+
125
+ function renderSegments(segments) {
126
+ return segments
127
+ .map((segment) => {
128
+ let text = segment.text;
129
+ if (segment.code) text = chalk.cyan(text);
130
+ if (segment.italic) text = chalk.italic(text);
131
+ if (segment.bold) text = chalk.bold(text);
132
+ return text;
133
+ })
134
+ .join("");
135
+ }
136
+
137
+ /** Converte Markdown simples em linhas prontas para o terminal. */
138
+ function markdownLines(text, width) {
139
+ const out = [];
140
+ const rows = String(text).replace(/\r/g, "").split("\n");
141
+ let inCode = false;
142
+
143
+ for (const raw of rows) {
144
+ const line = raw.replace(/\s+$/, "");
145
+ if (/^\s*```/.test(line)) {
146
+ inCode = !inCode;
147
+ out.push(chalk.dim(" " + "-".repeat(Math.min(width, 32))));
148
+ continue;
149
+ }
150
+ if (inCode) {
151
+ out.push(chalk.cyan(" " + truncate(line, width - 2)));
152
+ continue;
153
+ }
154
+ if (!line.trim()) {
155
+ out.push("");
156
+ continue;
157
+ }
158
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
159
+ out.push(chalk.dim(" " + "-".repeat(Math.min(width, 40))));
160
+ continue;
161
+ }
162
+
163
+ const heading = line.match(/^\s*#{1,6}\s+(.*)$/);
164
+ if (heading) {
165
+ if (out.length) out.push("");
166
+ for (const wrapped of wrapSegments(parseInline(heading[1]), width - 2)) {
167
+ out.push(" " + chalk.bold(renderSegments(wrapped)));
168
+ }
169
+ out.push("");
170
+ continue;
171
+ }
172
+
173
+ const bullet = line.match(/^\s*[-*+]\s+(.*)$/);
174
+ if (bullet) {
175
+ const wrapped = wrapSegments(parseInline(bullet[1]), width - 4);
176
+ wrapped.forEach((part, index) => {
177
+ out.push((index === 0 ? " • " : " ") + renderSegments(part));
178
+ });
179
+ continue;
180
+ }
181
+
182
+ const numbered = line.match(/^\s*(\d+)[.)]\s+(.*)$/);
183
+ if (numbered) {
184
+ const wrapped = wrapSegments(parseInline(numbered[2]), width - 6);
185
+ wrapped.forEach((part, index) => {
186
+ out.push((index === 0 ? ` ${numbered[1]}. ` : " ") + renderSegments(part));
187
+ });
188
+ continue;
189
+ }
190
+
191
+ const quote = line.match(/^\s*>\s?(.*)$/);
192
+ if (quote) {
193
+ for (const part of wrapSegments(parseInline(quote[1]), width - 4)) {
194
+ out.push(chalk.dim(" | " + stripAnsi(renderSegments(part))));
195
+ }
196
+ continue;
197
+ }
198
+
199
+ // Modelos costumam indentar paragrafos; no terminal isso vira ruim.
200
+ const clean = line.replace(/^\s+(?=\S)/, "");
201
+ for (const part of wrapSegments(parseInline(clean), width - 2)) {
202
+ out.push(" " + renderSegments(part));
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
208
+ /* ------------------------------------------------------------------- layout */
209
+
63
210
  function initialState() {
64
211
  const cfg = storage.getConfig();
65
212
  return {
@@ -73,8 +220,11 @@ function initialState() {
73
220
  menuIndex: 0,
74
221
  menuDismissed: false,
75
222
  picker: null,
223
+ modal: null,
76
224
  models: [],
77
225
  balance: null,
226
+ scroll: 0,
227
+ pageSize: 10,
78
228
  model: cfg.model || null,
79
229
  theme: cfg.theme || "default",
80
230
  suspended: false,
@@ -82,17 +232,20 @@ function initialState() {
82
232
  };
83
233
  }
84
234
 
85
- /* ------------------------------------------------------------------ layout */
86
-
87
235
  function headerLines(state, width, rows, footerCount, compact) {
88
- if (state.messages.length || state.picker) return [];
89
-
90
- const art = mascot.split("\n").map((line) => chalk.white(line));
236
+ if (state.messages.length || state.picker || state.modal) return [];
237
+
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));
91
244
  const space = rows - 2 - footerCount - (compact ? 0 : 1);
92
245
 
93
246
  if (space >= art.length + 4) {
94
247
  return [...art, "", center(chalk.bold("O que voce quer fazer?"), width),
95
- center(chalk.dim("Use / para comandos | Enter envia | Ctrl+C sai"), width), ""];
248
+ center(chalk.dim("Use / para comandos | Enter envia | PgUp/PgDn rola"), width), ""];
96
249
  }
97
250
  if (space >= art.length + 2) {
98
251
  return [...art, "", center(chalk.bold("O que voce quer fazer?"), width), ""];
@@ -114,9 +267,8 @@ function messageLines(state, width) {
114
267
  }
115
268
  } else if (message.role === "assistant") {
116
269
  lines.push("");
117
- for (const line of wrap(message.text, width - 2)) lines.push(" " + line);
118
- const meta = message.ms ? `${(message.ms / 1000).toFixed(1)}s` : "";
119
- if (meta) lines.push(chalk.dim(" " + meta));
270
+ lines.push(...markdownLines(message.text, width - 2));
271
+ if (message.ms) lines.push(chalk.dim(` ${(message.ms / 1000).toFixed(1)}s`));
120
272
  } else {
121
273
  lines.push("");
122
274
  const color = message.error ? chalk.red : chalk.dim;
@@ -152,31 +304,6 @@ function menuLines(state, commands, width) {
152
304
  });
153
305
  }
154
306
 
155
- function pickerLines(state, width) {
156
- const picker = state.picker;
157
- if (!picker) return [];
158
-
159
- const lines = [
160
- chalk.bold(" Escolha o modelo"),
161
- chalk.dim(" ↑/↓ navega | Enter seleciona | Esc cancela"),
162
- "",
163
- ];
164
- if (picker.loading) return [...lines, chalk.dim(" carregando modelos..."), ""];
165
-
166
- const height = 6;
167
- const start = clamp(picker.index - Math.floor(height / 2), 0, Math.max(0, picker.items.length - height));
168
- picker.items.slice(start, start + height).forEach((item, offset) => {
169
- const real = start + offset;
170
- const price = `in ${(item.input / 1_000_000).toFixed(2)} out ${(item.output / 1_000_000).toFixed(2)} R$/1M`;
171
- const available = Math.max(20, width - price.length - 2);
172
- const label = padRight(truncate(` ${real === picker.index ? ">" : " "} ${item.id}`, available), available);
173
- const line = label + " " + chalk.dim(price);
174
- lines.push(real === picker.index ? chalk.inverse(padRight(line, width)) : line);
175
- });
176
- lines.push("");
177
- return lines;
178
- }
179
-
180
307
  function footerLines(state, width) {
181
308
  const name = `kafox ${state.version}`;
182
309
  const left = chalk.dim(truncate(`${name} | ${state.model || "auto"}`, width - 16));
@@ -184,10 +311,11 @@ function footerLines(state, width) {
184
311
  state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`
185
312
  );
186
313
  const gap = " ".repeat(Math.max(1, width - visible(left) - visible(right)));
314
+ const scroll = state.scroll > 0 ? `↑ ${state.scroll} linhas | ` : "";
187
315
  return [
188
316
  left + gap + right,
189
317
  chalk.dim(truncate(process.cwd(), width)),
190
- chalk.dim(truncate("Enter envia | / comandos | Ctrl+C sai", width)),
318
+ chalk.dim(truncate(`${scroll}PgUp/PgDn rola | / comandos | Ctrl+C sai`, width)),
191
319
  ];
192
320
  }
193
321
 
@@ -197,16 +325,24 @@ function buildFrame(state, cols, rows, commands) {
197
325
  const gutter = " ".repeat(Math.max(0, margin));
198
326
  const compact = rows < 34;
199
327
 
200
- const footer = footerLines(state, width).slice(0, compact ? 1 : 3);
201
- const panel = state.picker
202
- ? pickerLines(state, width)
203
- : menuLines(state, commands, width);
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);
204
335
  const header = headerLines(state, width, rows, footer.length, compact);
205
336
 
206
337
  const reserved = 2 + footer.length + (compact ? 0 : 1) + panel.length;
207
338
  const available = Math.max(0, rows - header.length - reserved);
208
- let body = messageLines(state, width);
209
- if (body.length > available) body = body.slice(body.length - available);
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);
210
346
  while (body.length < available) body.unshift("");
211
347
 
212
348
  const inputWidth = width - 2;
@@ -220,15 +356,7 @@ function buildFrame(state, cols, rows, commands) {
220
356
  const rule = chalk.dim("-".repeat(width));
221
357
 
222
358
  const middle = compact ? [] : [""];
223
- const raw = [
224
- ...header,
225
- ...body,
226
- ...middle,
227
- ...panel,
228
- inputLine,
229
- rule,
230
- ...footer,
231
- ];
359
+ const raw = [...header, ...body, ...middle, ...panel, inputLine, rule, ...footer];
232
360
  while (raw.length < rows) raw.push("");
233
361
  const lines = raw.slice(0, rows).map((line) => gutter + padRight(line, width));
234
362
 
@@ -237,6 +365,81 @@ function buildFrame(state, cols, rows, commands) {
237
365
  return { lines, cursorRow: Math.min(rows, inputRow), cursorCol };
238
366
  }
239
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) + "┐"));
374
+ 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("│"));
380
+ }
381
+ if (hint) {
382
+ lines.push(chalk.dim("│") + " ".repeat(width - 2) + chalk.dim("│"));
383
+ lines.push(chalk.dim("│") + " " + chalk.dim(padRight(hint, inner)) + " " + chalk.dim("│"));
384
+ }
385
+ lines.push(chalk.dim("└" + "─".repeat(width - 2) + "┘"));
386
+ return lines;
387
+ }
388
+
389
+ function buildOverlay(state, cols, rows) {
390
+ let title = "";
391
+ let body = [];
392
+ let hint = "";
393
+
394
+ if (state.picker) {
395
+ const picker = state.picker;
396
+ title = "Escolha o modelo";
397
+ hint = "↑/↓ navega | Enter seleciona | Esc cancela";
398
+ if (picker.loading) {
399
+ body = [chalk.dim("carregando modelos...")];
400
+ } 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) => {
404
+ 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
+ });
411
+ }
412
+ } else if (state.modal) {
413
+ title = state.modal.title;
414
+ hint = state.modal.hint || "Enter ou qualquer tecla fecha";
415
+ body = [];
416
+ } else {
417
+ return null;
418
+ }
419
+
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)`));
431
+ }
432
+
433
+ const boxed = box(title, content, hint, width);
434
+ const top = Math.max(1, Math.floor((rows - boxed.length) / 2) + 1);
435
+ const left = Math.max(1, Math.floor((cols - width) / 2) + 1);
436
+ return {
437
+ top,
438
+ left,
439
+ lines: boxed.map((line) => padRight(line, width)),
440
+ };
441
+ }
442
+
240
443
  /* --------------------------------------------------------------------- app */
241
444
 
242
445
  async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set() } = {}) {
@@ -247,8 +450,9 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
247
450
  let closed = false;
248
451
 
249
452
  const write = (value) => stdout.write(value);
250
- const enterScreen = () => write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J");
251
- const leaveScreen = () => write("\x1b[?25h\x1b[?1049l");
453
+ 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");
252
456
 
253
457
  async function refreshAccount() {
254
458
  const token = await ensureSession();
@@ -272,7 +476,16 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
272
476
  const rows = Math.max(16, stdout.rows || 24);
273
477
  const frame = buildFrame(state, cols, rows, commands);
274
478
  write("\x1b[?25l\x1b[H" + frame.lines.join("\r\n"));
275
- write(`\x1b[${frame.cursorRow};${frame.cursorCol}H\x1b[?25h`);
479
+
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
+ }
276
489
  }
277
490
 
278
491
  async function capture(fn) {
@@ -309,7 +522,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
309
522
  const models = await loadModels();
310
523
  if (!models.length) {
311
524
  state.picker = null;
312
- state.messages.push({ role: "system", text: "Nao consegui carregar os modelos.", error: true });
525
+ state.modal = { title: "Modelos", text: "Nao consegui carregar os modelos." };
313
526
  return render();
314
527
  }
315
528
  const current = models.findIndex((item) => item.id === state.model);
@@ -325,7 +538,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
325
538
  storage.setConfig({ model: item.id });
326
539
  state.model = item.id;
327
540
  state.picker = null;
328
- state.messages.push({ role: "system", text: `Modelo: ${item.id}` });
541
+ state.modal = { title: "Modelo", text: item.id };
329
542
  render();
330
543
  }
331
544
 
@@ -355,30 +568,31 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
355
568
 
356
569
  if (name === "/clear") {
357
570
  state.messages = [];
571
+ state.scroll = 0;
358
572
  return render();
359
573
  }
360
574
  if (name === "/compact") {
361
575
  if (!state.messages.length) {
362
- state.messages.push({ role: "system", text: "Nada para compactar." });
363
- } else {
364
- const total = state.messages.length;
365
- state.messages = [
366
- { role: "system", text: `Contexto compactado: ${total} mensagens resumidas.` },
367
- ...state.messages.slice(-6),
368
- ];
576
+ state.modal = { title: "Contexto", text: "Nada para compactar." };
577
+ return render();
369
578
  }
579
+ const total = state.messages.length;
580
+ state.messages = [
581
+ { role: "system", text: `Contexto compactado: ${total} mensagens resumidas.` },
582
+ ...state.messages.slice(-6),
583
+ ];
584
+ state.scroll = 0;
370
585
  return render();
371
586
  }
372
587
 
373
588
  if (interactiveCommands.has(name)) return runOutside(line);
374
589
 
375
- const before = state.messages.length;
376
- const output = (await capture(() => onCommand(line))).trim();
377
- if (output) state.messages.push({ role: "system", text: output });
378
- else if (state.messages.length === before) {
379
- state.messages.push({ role: "system", text: `${name}: sem saida.` });
380
- }
590
+ const output = stripAnsi(await capture(() => onCommand(line))).trim();
381
591
  state.model = storage.getConfig().model || state.model;
592
+ state.modal = {
593
+ title: name.replace(/^\//, ""),
594
+ text: output || "(sem saida)",
595
+ };
382
596
  render();
383
597
  await refreshAccount();
384
598
  render();
@@ -398,20 +612,17 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
398
612
 
399
613
  state.messages.push({ role: "user", text: line });
400
614
  state.busy = true;
615
+ state.scroll = 0;
401
616
  render();
402
617
 
403
618
  const result = await askModel(line, { model: state.model });
404
619
  state.busy = false;
405
620
  if (result.ok) {
406
- state.messages.push({
407
- role: "assistant",
408
- text: result.text,
409
- ms: result.ms,
410
- provider: result.provider,
411
- });
621
+ state.messages.push({ role: "assistant", text: result.text, ms: result.ms });
412
622
  } else {
413
623
  state.messages.push({ role: "system", text: result.error || "Falha ao responder.", error: true });
414
624
  }
625
+ state.scroll = 0;
415
626
  render();
416
627
  await refreshAccount();
417
628
  render();
@@ -429,7 +640,25 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
429
640
  if (state.suspended) return;
430
641
  if (key.ctrl && key.name === "c") return quit();
431
642
 
432
- // Seletor de modelo tem prioridade sobre o resto.
643
+ // Roda do mouse (SGR): 64 = cima, 65 = baixo.
644
+ 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);
651
+ return render();
652
+ }
653
+ }
654
+
655
+ // Modal de texto: qualquer tecla fecha.
656
+ if (state.modal) {
657
+ state.modal = null;
658
+ return render();
659
+ }
660
+
661
+ // Seletor de modelo.
433
662
  if (state.picker) {
434
663
  if (state.picker.loading) return;
435
664
  if (key.name === "up" || key.name === "down") {
@@ -449,6 +678,12 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
449
678
  return;
450
679
  }
451
680
 
681
+ if (key.name === "pageup" || key.name === "pagedown") {
682
+ const delta = key.name === "pageup" ? state.pageSize : -state.pageSize;
683
+ state.scroll = Math.max(0, state.scroll + delta);
684
+ return render();
685
+ }
686
+
452
687
  const items = filteredCommands(state, commands);
453
688
  const menuOpen = items.length > 0;
454
689
 
@@ -461,7 +696,6 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
461
696
  state.input = exact;
462
697
  state.cursor = exact.length;
463
698
  } else if (menuOpen) {
464
- // Enter executa o item destacado, nao o texto parcial digitado.
465
699
  const selected = items[state.menuIndex % items.length][0];
466
700
  state.input = selected;
467
701
  state.cursor = selected.length;
@@ -470,10 +704,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
470
704
  return submit();
471
705
  }
472
706
  if (key.name === "tab") {
473
- if (menuOpen) {
474
- const selected = items[state.menuIndex % items.length];
475
- return setInput(selected[0] + " ");
476
- }
707
+ if (menuOpen) return setInput(items[state.menuIndex % items.length][0] + " ");
477
708
  return;
478
709
  }
479
710
  if (key.name === "escape") {
@@ -568,4 +799,12 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
568
799
  return { state, quit, render, submit };
569
800
  }
570
801
 
571
- module.exports = { runTUI, buildFrame, wrap, initialState, filteredCommands, pickerLines };
802
+ module.exports = {
803
+ runTUI,
804
+ buildFrame,
805
+ buildOverlay,
806
+ markdownLines,
807
+ wrap,
808
+ initialState,
809
+ filteredCommands,
810
+ };