kafox-cli 0.2.3 → 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 +320 -86
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.3.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.3",
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;
83
+ }
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;
61
206
  }
62
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,10 +232,8 @@ 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 [];
236
+ if (state.messages.length || state.picker || state.modal) return [];
89
237
 
90
238
  // Centraliza o bloco inteiro: as linhas tem larguras diferentes e cada uma
91
239
  // precisa do mesmo recuo para a raposa nao sair distorcida.
@@ -97,7 +245,7 @@ function headerLines(state, width, rows, footerCount, compact) {
97
245
 
98
246
  if (space >= art.length + 4) {
99
247
  return [...art, "", center(chalk.bold("O que voce quer fazer?"), width),
100
- 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), ""];
101
249
  }
102
250
  if (space >= art.length + 2) {
103
251
  return [...art, "", center(chalk.bold("O que voce quer fazer?"), width), ""];
@@ -119,9 +267,8 @@ function messageLines(state, width) {
119
267
  }
120
268
  } else if (message.role === "assistant") {
121
269
  lines.push("");
122
- for (const line of wrap(message.text, width - 2)) lines.push(" " + line);
123
- const meta = message.ms ? `${(message.ms / 1000).toFixed(1)}s` : "";
124
- if (meta) lines.push(chalk.dim(" " + meta));
270
+ lines.push(...markdownLines(message.text, width - 2));
271
+ if (message.ms) lines.push(chalk.dim(` ${(message.ms / 1000).toFixed(1)}s`));
125
272
  } else {
126
273
  lines.push("");
127
274
  const color = message.error ? chalk.red : chalk.dim;
@@ -157,31 +304,6 @@ function menuLines(state, commands, width) {
157
304
  });
158
305
  }
159
306
 
160
- function pickerLines(state, width) {
161
- const picker = state.picker;
162
- if (!picker) return [];
163
-
164
- const lines = [
165
- chalk.bold(" Escolha o modelo"),
166
- chalk.dim(" ↑/↓ navega | Enter seleciona | Esc cancela"),
167
- "",
168
- ];
169
- if (picker.loading) return [...lines, chalk.dim(" carregando modelos..."), ""];
170
-
171
- const height = 6;
172
- const start = clamp(picker.index - Math.floor(height / 2), 0, Math.max(0, picker.items.length - height));
173
- picker.items.slice(start, start + height).forEach((item, offset) => {
174
- const real = start + offset;
175
- const price = `in ${(item.input / 1_000_000).toFixed(2)} out ${(item.output / 1_000_000).toFixed(2)} R$/1M`;
176
- const available = Math.max(20, width - price.length - 2);
177
- const label = padRight(truncate(` ${real === picker.index ? ">" : " "} ${item.id}`, available), available);
178
- const line = label + " " + chalk.dim(price);
179
- lines.push(real === picker.index ? chalk.inverse(padRight(line, width)) : line);
180
- });
181
- lines.push("");
182
- return lines;
183
- }
184
-
185
307
  function footerLines(state, width) {
186
308
  const name = `kafox ${state.version}`;
187
309
  const left = chalk.dim(truncate(`${name} | ${state.model || "auto"}`, width - 16));
@@ -189,10 +311,11 @@ function footerLines(state, width) {
189
311
  state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`
190
312
  );
191
313
  const gap = " ".repeat(Math.max(1, width - visible(left) - visible(right)));
314
+ const scroll = state.scroll > 0 ? `↑ ${state.scroll} linhas | ` : "";
192
315
  return [
193
316
  left + gap + right,
194
317
  chalk.dim(truncate(process.cwd(), width)),
195
- chalk.dim(truncate("Enter envia | / comandos | Ctrl+C sai", width)),
318
+ chalk.dim(truncate(`${scroll}PgUp/PgDn rola | / comandos | Ctrl+C sai`, width)),
196
319
  ];
197
320
  }
198
321
 
@@ -202,16 +325,24 @@ function buildFrame(state, cols, rows, commands) {
202
325
  const gutter = " ".repeat(Math.max(0, margin));
203
326
  const compact = rows < 34;
204
327
 
205
- const footer = footerLines(state, width).slice(0, compact ? 1 : 3);
206
- const panel = state.picker
207
- ? pickerLines(state, width)
208
- : menuLines(state, commands, width);
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);
209
335
  const header = headerLines(state, width, rows, footer.length, compact);
210
336
 
211
337
  const reserved = 2 + footer.length + (compact ? 0 : 1) + panel.length;
212
338
  const available = Math.max(0, rows - header.length - reserved);
213
- let body = messageLines(state, width);
214
- if (body.length > available) body = body.slice(body.length - available);
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);
215
346
  while (body.length < available) body.unshift("");
216
347
 
217
348
  const inputWidth = width - 2;
@@ -225,15 +356,7 @@ function buildFrame(state, cols, rows, commands) {
225
356
  const rule = chalk.dim("-".repeat(width));
226
357
 
227
358
  const middle = compact ? [] : [""];
228
- const raw = [
229
- ...header,
230
- ...body,
231
- ...middle,
232
- ...panel,
233
- inputLine,
234
- rule,
235
- ...footer,
236
- ];
359
+ const raw = [...header, ...body, ...middle, ...panel, inputLine, rule, ...footer];
237
360
  while (raw.length < rows) raw.push("");
238
361
  const lines = raw.slice(0, rows).map((line) => gutter + padRight(line, width));
239
362
 
@@ -242,6 +365,81 @@ function buildFrame(state, cols, rows, commands) {
242
365
  return { lines, cursorRow: Math.min(rows, inputRow), cursorCol };
243
366
  }
244
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
+
245
443
  /* --------------------------------------------------------------------- app */
246
444
 
247
445
  async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set() } = {}) {
@@ -252,8 +450,9 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
252
450
  let closed = false;
253
451
 
254
452
  const write = (value) => stdout.write(value);
255
- const enterScreen = () => write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J");
256
- const leaveScreen = () => write("\x1b[?25h\x1b[?1049l");
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");
257
456
 
258
457
  async function refreshAccount() {
259
458
  const token = await ensureSession();
@@ -277,7 +476,16 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
277
476
  const rows = Math.max(16, stdout.rows || 24);
278
477
  const frame = buildFrame(state, cols, rows, commands);
279
478
  write("\x1b[?25l\x1b[H" + frame.lines.join("\r\n"));
280
- 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
+ }
281
489
  }
282
490
 
283
491
  async function capture(fn) {
@@ -314,7 +522,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
314
522
  const models = await loadModels();
315
523
  if (!models.length) {
316
524
  state.picker = null;
317
- state.messages.push({ role: "system", text: "Nao consegui carregar os modelos.", error: true });
525
+ state.modal = { title: "Modelos", text: "Nao consegui carregar os modelos." };
318
526
  return render();
319
527
  }
320
528
  const current = models.findIndex((item) => item.id === state.model);
@@ -330,7 +538,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
330
538
  storage.setConfig({ model: item.id });
331
539
  state.model = item.id;
332
540
  state.picker = null;
333
- state.messages.push({ role: "system", text: `Modelo: ${item.id}` });
541
+ state.modal = { title: "Modelo", text: item.id };
334
542
  render();
335
543
  }
336
544
 
@@ -360,30 +568,31 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
360
568
 
361
569
  if (name === "/clear") {
362
570
  state.messages = [];
571
+ state.scroll = 0;
363
572
  return render();
364
573
  }
365
574
  if (name === "/compact") {
366
575
  if (!state.messages.length) {
367
- state.messages.push({ role: "system", text: "Nada para compactar." });
368
- } else {
369
- const total = state.messages.length;
370
- state.messages = [
371
- { role: "system", text: `Contexto compactado: ${total} mensagens resumidas.` },
372
- ...state.messages.slice(-6),
373
- ];
576
+ state.modal = { title: "Contexto", text: "Nada para compactar." };
577
+ return render();
374
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;
375
585
  return render();
376
586
  }
377
587
 
378
588
  if (interactiveCommands.has(name)) return runOutside(line);
379
589
 
380
- const before = state.messages.length;
381
- const output = (await capture(() => onCommand(line))).trim();
382
- if (output) state.messages.push({ role: "system", text: output });
383
- else if (state.messages.length === before) {
384
- state.messages.push({ role: "system", text: `${name}: sem saida.` });
385
- }
590
+ const output = stripAnsi(await capture(() => onCommand(line))).trim();
386
591
  state.model = storage.getConfig().model || state.model;
592
+ state.modal = {
593
+ title: name.replace(/^\//, ""),
594
+ text: output || "(sem saida)",
595
+ };
387
596
  render();
388
597
  await refreshAccount();
389
598
  render();
@@ -403,20 +612,17 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
403
612
 
404
613
  state.messages.push({ role: "user", text: line });
405
614
  state.busy = true;
615
+ state.scroll = 0;
406
616
  render();
407
617
 
408
618
  const result = await askModel(line, { model: state.model });
409
619
  state.busy = false;
410
620
  if (result.ok) {
411
- state.messages.push({
412
- role: "assistant",
413
- text: result.text,
414
- ms: result.ms,
415
- provider: result.provider,
416
- });
621
+ state.messages.push({ role: "assistant", text: result.text, ms: result.ms });
417
622
  } else {
418
623
  state.messages.push({ role: "system", text: result.error || "Falha ao responder.", error: true });
419
624
  }
625
+ state.scroll = 0;
420
626
  render();
421
627
  await refreshAccount();
422
628
  render();
@@ -434,7 +640,25 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
434
640
  if (state.suspended) return;
435
641
  if (key.ctrl && key.name === "c") return quit();
436
642
 
437
- // 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.
438
662
  if (state.picker) {
439
663
  if (state.picker.loading) return;
440
664
  if (key.name === "up" || key.name === "down") {
@@ -454,6 +678,12 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
454
678
  return;
455
679
  }
456
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
+
457
687
  const items = filteredCommands(state, commands);
458
688
  const menuOpen = items.length > 0;
459
689
 
@@ -466,7 +696,6 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
466
696
  state.input = exact;
467
697
  state.cursor = exact.length;
468
698
  } else if (menuOpen) {
469
- // Enter executa o item destacado, nao o texto parcial digitado.
470
699
  const selected = items[state.menuIndex % items.length][0];
471
700
  state.input = selected;
472
701
  state.cursor = selected.length;
@@ -475,10 +704,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
475
704
  return submit();
476
705
  }
477
706
  if (key.name === "tab") {
478
- if (menuOpen) {
479
- const selected = items[state.menuIndex % items.length];
480
- return setInput(selected[0] + " ");
481
- }
707
+ if (menuOpen) return setInput(items[state.menuIndex % items.length][0] + " ");
482
708
  return;
483
709
  }
484
710
  if (key.name === "escape") {
@@ -573,4 +799,12 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
573
799
  return { state, quit, render, submit };
574
800
  }
575
801
 
576
- 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
+ };