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.
- package/README.md +11 -3
- package/package.json +2 -2
- 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.
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Kafox CLI in opencode style:
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 |
|
|
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
|
-
|
|
123
|
-
|
|
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(
|
|
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
|
|
206
|
-
const
|
|
207
|
-
?
|
|
208
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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 = () =>
|
|
256
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
368
|
-
|
|
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
|
|
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
|
-
//
|
|
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 = {
|
|
802
|
+
module.exports = {
|
|
803
|
+
runTUI,
|
|
804
|
+
buildFrame,
|
|
805
|
+
buildOverlay,
|
|
806
|
+
markdownLines,
|
|
807
|
+
wrap,
|
|
808
|
+
initialState,
|
|
809
|
+
filteredCommands,
|
|
810
|
+
};
|