kafox-cli 0.2.1 → 0.2.3
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 +6 -5
- package/package.json +1 -1
- package/src/cli.js +2 -2
- package/src/gateway.js +1 -1
- package/src/tui.js +173 -50
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.
|
|
15
|
+
npm install -g https://kafox.pypag.com/static/downloads/kafox-cli-0.2.3.tgz
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Requisito: Node.js 22.5+.
|
|
@@ -31,7 +31,7 @@ navegador, entre na sua conta e clique em **Autorizar**. A sessao fica em
|
|
|
31
31
|
|
|
32
32
|
| Comando | O que faz |
|
|
33
33
|
| --- | --- |
|
|
34
|
-
| `kafox` | abre
|
|
34
|
+
| `kafox` | abre a interface de tela cheia |
|
|
35
35
|
| `kafox login` / `logout` / `whoami` | sessao da conta |
|
|
36
36
|
| `kafox models` | lista modelos e precos por 1M de tokens |
|
|
37
37
|
| `kafox pricing` | pacotes de recarga |
|
|
@@ -43,9 +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
|
-
|
|
47
|
-
|
|
48
|
-
`/
|
|
46
|
+
Na interface: digite `/` para o menu (↑/↓ navega, Tab completa, Enter executa
|
|
47
|
+
), `/model` abre o seletor de modelo (↑/↓ + Enter). Demais comandos: `/help`,
|
|
48
|
+
`/models`, `/balance`, `/pricing`, `/key`, `/login`, `/logout`, `/whoami`,
|
|
49
|
+
`/theme`, `/background`, `/clear`, `/compact`, `/status`, `/exit`.
|
|
49
50
|
|
|
50
51
|
## Configuracao
|
|
51
52
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ const { version: VERSION } = require("../package.json");
|
|
|
13
13
|
const COMMANDS = {
|
|
14
14
|
"/help": "show this help",
|
|
15
15
|
"/models": "list models and prices",
|
|
16
|
-
"/model
|
|
16
|
+
"/model": "choose the default model (picker)",
|
|
17
17
|
"/balance": "show balance",
|
|
18
18
|
"/pricing": "show recharge packages",
|
|
19
19
|
"/key": "create an API key",
|
|
@@ -21,7 +21,7 @@ const COMMANDS = {
|
|
|
21
21
|
"/logout": "end the session",
|
|
22
22
|
"/whoami": "show the linked account",
|
|
23
23
|
"/theme": "open theme selector",
|
|
24
|
-
"/background
|
|
24
|
+
"/background": "set background image (modal asks for URL)",
|
|
25
25
|
"/clear": "clear conversation context",
|
|
26
26
|
"/compact": "compact conversation context",
|
|
27
27
|
"/status": "show account, balance and gateway status",
|
package/src/gateway.js
CHANGED
|
@@ -120,7 +120,7 @@ async function ask(question, options = {}) {
|
|
|
120
120
|
console.log(chalk.green("\nAnswer:"));
|
|
121
121
|
console.log(result.text);
|
|
122
122
|
if (result.ms) {
|
|
123
|
-
console.log(chalk.dim(`\n(${result.ms}ms
|
|
123
|
+
console.log(chalk.dim(`\n(${result.ms}ms)`));
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
package/src/tui.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Interface de terminal do kafox, no estilo opencode:
|
|
3
|
-
* tela cheia, mascote, menu de comandos "/",
|
|
3
|
+
* tela cheia, mascote, menu de comandos "/", seletor de modelo, input e rodape.
|
|
4
4
|
*
|
|
5
5
|
* Tudo e desenhado a mao (sem dependencia de TUI) para manter o pacote leve e
|
|
6
6
|
* funcionar no Windows Terminal. Sem TTY, o cli.js usa o REPL simples.
|
|
@@ -9,7 +9,6 @@ const readline = require("readline");
|
|
|
9
9
|
const chalk = require("chalk");
|
|
10
10
|
const storage = require("./storage");
|
|
11
11
|
const { mascot } = require("./mascot");
|
|
12
|
-
const { paint } = require("./theme");
|
|
13
12
|
const { ensureSession, fetchApi } = require("./auth");
|
|
14
13
|
const { askModel } = require("./gateway");
|
|
15
14
|
|
|
@@ -72,6 +71,9 @@ function initialState() {
|
|
|
72
71
|
busy: false,
|
|
73
72
|
frame: 0,
|
|
74
73
|
menuIndex: 0,
|
|
74
|
+
menuDismissed: false,
|
|
75
|
+
picker: null,
|
|
76
|
+
models: [],
|
|
75
77
|
balance: null,
|
|
76
78
|
model: cfg.model || null,
|
|
77
79
|
theme: cfg.theme || "default",
|
|
@@ -82,18 +84,27 @@ function initialState() {
|
|
|
82
84
|
|
|
83
85
|
/* ------------------------------------------------------------------ layout */
|
|
84
86
|
|
|
85
|
-
function headerLines(state, width, rows) {
|
|
86
|
-
if (state.messages.length) return [];
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
|
|
87
|
+
function headerLines(state, width, rows, footerCount, compact) {
|
|
88
|
+
if (state.messages.length || state.picker) return [];
|
|
89
|
+
|
|
90
|
+
// Centraliza o bloco inteiro: as linhas tem larguras diferentes e cada uma
|
|
91
|
+
// precisa do mesmo recuo para a raposa nao sair distorcida.
|
|
92
|
+
const artLines = mascot.split("\n");
|
|
93
|
+
const artWidth = Math.max(...artLines.map((line) => line.length));
|
|
94
|
+
const artLeft = " ".repeat(Math.max(0, Math.floor((width - artWidth) / 2)));
|
|
95
|
+
const art = artLines.map((line) => artLeft + chalk.white(line));
|
|
96
|
+
const space = rows - 2 - footerCount - (compact ? 0 : 1);
|
|
97
|
+
|
|
98
|
+
if (space >= art.length + 4) {
|
|
99
|
+
return [...art, "", center(chalk.bold("O que voce quer fazer?"), width),
|
|
100
|
+
center(chalk.dim("Use / para comandos | Enter envia | Ctrl+C sai"), width), ""];
|
|
101
|
+
}
|
|
102
|
+
if (space >= art.length + 2) {
|
|
103
|
+
return [...art, "", center(chalk.bold("O que voce quer fazer?"), width), ""];
|
|
104
|
+
}
|
|
105
|
+
if (space >= art.length + 1) return [...art, ""];
|
|
106
|
+
if (space >= art.length) return art;
|
|
107
|
+
return art.slice(0, Math.max(0, space));
|
|
97
108
|
}
|
|
98
109
|
|
|
99
110
|
function messageLines(state, width) {
|
|
@@ -109,12 +120,7 @@ function messageLines(state, width) {
|
|
|
109
120
|
} else if (message.role === "assistant") {
|
|
110
121
|
lines.push("");
|
|
111
122
|
for (const line of wrap(message.text, width - 2)) lines.push(" " + line);
|
|
112
|
-
const meta =
|
|
113
|
-
message.ms ? `${(message.ms / 1000).toFixed(1)}s` : null,
|
|
114
|
-
message.provider,
|
|
115
|
-
]
|
|
116
|
-
.filter(Boolean)
|
|
117
|
-
.join(" | ");
|
|
123
|
+
const meta = message.ms ? `${(message.ms / 1000).toFixed(1)}s` : "";
|
|
118
124
|
if (meta) lines.push(chalk.dim(" " + meta));
|
|
119
125
|
} else {
|
|
120
126
|
lines.push("");
|
|
@@ -130,11 +136,15 @@ function messageLines(state, width) {
|
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
function filteredCommands(state, commands) {
|
|
139
|
+
if (state.menuDismissed) return [];
|
|
133
140
|
if (!state.input.startsWith("/") || state.input.includes(" ")) return [];
|
|
134
141
|
const query = state.input.toLowerCase();
|
|
135
|
-
return Object.entries(commands)
|
|
136
|
-
name.toLowerCase().startsWith(query)
|
|
137
|
-
|
|
142
|
+
return Object.entries(commands)
|
|
143
|
+
.filter(([name]) => name.toLowerCase().startsWith(query))
|
|
144
|
+
.sort(
|
|
145
|
+
(a, b) =>
|
|
146
|
+
Number(b[0].toLowerCase() === query) - Number(a[0].toLowerCase() === query)
|
|
147
|
+
);
|
|
138
148
|
}
|
|
139
149
|
|
|
140
150
|
function menuLines(state, commands, width) {
|
|
@@ -147,8 +157,33 @@ function menuLines(state, commands, width) {
|
|
|
147
157
|
});
|
|
148
158
|
}
|
|
149
159
|
|
|
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
|
+
|
|
150
185
|
function footerLines(state, width) {
|
|
151
|
-
const name =
|
|
186
|
+
const name = `kafox ${state.version}`;
|
|
152
187
|
const left = chalk.dim(truncate(`${name} | ${state.model || "auto"}`, width - 16));
|
|
153
188
|
const right = chalk.green(
|
|
154
189
|
state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`
|
|
@@ -165,10 +200,19 @@ function buildFrame(state, cols, rows, commands) {
|
|
|
165
200
|
const width = Math.min(cols - 2, 108);
|
|
166
201
|
const margin = Math.floor((cols - width) / 2);
|
|
167
202
|
const gutter = " ".repeat(Math.max(0, margin));
|
|
203
|
+
const compact = rows < 34;
|
|
168
204
|
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
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);
|
|
209
|
+
const header = headerLines(state, width, rows, footer.length, compact);
|
|
210
|
+
|
|
211
|
+
const reserved = 2 + footer.length + (compact ? 0 : 1) + panel.length;
|
|
212
|
+
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);
|
|
215
|
+
while (body.length < available) body.unshift("");
|
|
172
216
|
|
|
173
217
|
const inputWidth = width - 2;
|
|
174
218
|
const start = state.input.length > inputWidth
|
|
@@ -180,17 +224,20 @@ function buildFrame(state, cols, rows, commands) {
|
|
|
180
224
|
: chalk.cyan("> ") + chalk.dim(truncate("Pergunte alguma coisa...", inputWidth));
|
|
181
225
|
const rule = chalk.dim("-".repeat(width));
|
|
182
226
|
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
227
|
+
const middle = compact ? [] : [""];
|
|
228
|
+
const raw = [
|
|
229
|
+
...header,
|
|
230
|
+
...body,
|
|
231
|
+
...middle,
|
|
232
|
+
...panel,
|
|
233
|
+
inputLine,
|
|
234
|
+
rule,
|
|
235
|
+
...footer,
|
|
236
|
+
];
|
|
190
237
|
while (raw.length < rows) raw.push("");
|
|
191
238
|
const lines = raw.slice(0, rows).map((line) => gutter + padRight(line, width));
|
|
192
239
|
|
|
193
|
-
const inputRow = header.length + available +
|
|
240
|
+
const inputRow = header.length + available + middle.length + panel.length + 1;
|
|
194
241
|
const cursorCol = Math.max(1, margin + 2 + (state.cursor - start) + 1);
|
|
195
242
|
return { lines, cursorRow: Math.min(rows, inputRow), cursorCol };
|
|
196
243
|
}
|
|
@@ -247,6 +294,46 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
247
294
|
return collected.join("\n");
|
|
248
295
|
}
|
|
249
296
|
|
|
297
|
+
async function loadModels() {
|
|
298
|
+
if (state.models.length) return state.models;
|
|
299
|
+
const pricing = await fetchApi("/api/public/pricing")
|
|
300
|
+
.then((res) => res.json())
|
|
301
|
+
.catch(() => null);
|
|
302
|
+
state.models = (pricing?.pricing || []).map((row) => ({
|
|
303
|
+
id: row.model,
|
|
304
|
+
input: row.input_micros_per_1m,
|
|
305
|
+
output: row.output_micros_per_1m,
|
|
306
|
+
}));
|
|
307
|
+
return state.models;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function openModelPicker() {
|
|
311
|
+
state.menuDismissed = true;
|
|
312
|
+
state.picker = { items: state.models, index: 0, loading: true };
|
|
313
|
+
render();
|
|
314
|
+
const models = await loadModels();
|
|
315
|
+
if (!models.length) {
|
|
316
|
+
state.picker = null;
|
|
317
|
+
state.messages.push({ role: "system", text: "Nao consegui carregar os modelos.", error: true });
|
|
318
|
+
return render();
|
|
319
|
+
}
|
|
320
|
+
const current = models.findIndex((item) => item.id === state.model);
|
|
321
|
+
state.picker = { items: models, index: current >= 0 ? current : 0, loading: false };
|
|
322
|
+
render();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function selectModel() {
|
|
326
|
+
const picker = state.picker;
|
|
327
|
+
if (!picker || picker.loading) return;
|
|
328
|
+
const item = picker.items[picker.index];
|
|
329
|
+
if (!item) return;
|
|
330
|
+
storage.setConfig({ model: item.id });
|
|
331
|
+
state.model = item.id;
|
|
332
|
+
state.picker = null;
|
|
333
|
+
state.messages.push({ role: "system", text: `Modelo: ${item.id}` });
|
|
334
|
+
render();
|
|
335
|
+
}
|
|
336
|
+
|
|
250
337
|
async function runOutside(line) {
|
|
251
338
|
state.suspended = true;
|
|
252
339
|
leaveScreen();
|
|
@@ -265,12 +352,14 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
265
352
|
}
|
|
266
353
|
|
|
267
354
|
async function runCommand(line) {
|
|
268
|
-
const
|
|
355
|
+
const parts = line.trim().split(/\s+/);
|
|
356
|
+
const name = parts[0];
|
|
269
357
|
if (name === "/exit" || name === "/quit") return quit();
|
|
270
358
|
|
|
359
|
+
if (name === "/model" && !parts[1]) return openModelPicker();
|
|
360
|
+
|
|
271
361
|
if (name === "/clear") {
|
|
272
362
|
state.messages = [];
|
|
273
|
-
state.status = "";
|
|
274
363
|
return render();
|
|
275
364
|
}
|
|
276
365
|
if (name === "/compact") {
|
|
@@ -279,10 +368,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
279
368
|
} else {
|
|
280
369
|
const total = state.messages.length;
|
|
281
370
|
state.messages = [
|
|
282
|
-
{
|
|
283
|
-
role: "system",
|
|
284
|
-
text: `Contexto compactado: ${total} mensagens resumidas.`,
|
|
285
|
-
},
|
|
371
|
+
{ role: "system", text: `Contexto compactado: ${total} mensagens resumidas.` },
|
|
286
372
|
...state.messages.slice(-6),
|
|
287
373
|
];
|
|
288
374
|
}
|
|
@@ -311,6 +397,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
311
397
|
state.input = "";
|
|
312
398
|
state.cursor = 0;
|
|
313
399
|
state.menuIndex = 0;
|
|
400
|
+
state.menuDismissed = false;
|
|
314
401
|
|
|
315
402
|
if (line.startsWith("/")) return runCommand(line);
|
|
316
403
|
|
|
@@ -339,6 +426,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
339
426
|
state.input = value;
|
|
340
427
|
state.cursor = cursor == null ? value.length : clamp(cursor, 0, value.length);
|
|
341
428
|
state.menuIndex = 0;
|
|
429
|
+
state.menuDismissed = false;
|
|
342
430
|
render();
|
|
343
431
|
}
|
|
344
432
|
|
|
@@ -346,10 +434,46 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
346
434
|
if (state.suspended) return;
|
|
347
435
|
if (key.ctrl && key.name === "c") return quit();
|
|
348
436
|
|
|
437
|
+
// Seletor de modelo tem prioridade sobre o resto.
|
|
438
|
+
if (state.picker) {
|
|
439
|
+
if (state.picker.loading) return;
|
|
440
|
+
if (key.name === "up" || key.name === "down") {
|
|
441
|
+
const delta = key.name === "up" ? -1 : 1;
|
|
442
|
+
state.picker.index = clamp(
|
|
443
|
+
state.picker.index + delta,
|
|
444
|
+
0,
|
|
445
|
+
Math.max(0, state.picker.items.length - 1)
|
|
446
|
+
);
|
|
447
|
+
return render();
|
|
448
|
+
}
|
|
449
|
+
if (key.name === "return") return selectModel();
|
|
450
|
+
if (key.name === "escape") {
|
|
451
|
+
state.picker = null;
|
|
452
|
+
return render();
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
349
457
|
const items = filteredCommands(state, commands);
|
|
350
458
|
const menuOpen = items.length > 0;
|
|
351
459
|
|
|
352
|
-
if (key.name === "return")
|
|
460
|
+
if (key.name === "return") {
|
|
461
|
+
// Se o texto ja e um comando exato, ele vence a lista (ex.: /model vs /models).
|
|
462
|
+
const exact = Object.keys(commands).find(
|
|
463
|
+
(name) => name.toLowerCase() === state.input.trim().toLowerCase()
|
|
464
|
+
);
|
|
465
|
+
if (exact) {
|
|
466
|
+
state.input = exact;
|
|
467
|
+
state.cursor = exact.length;
|
|
468
|
+
} else if (menuOpen) {
|
|
469
|
+
// Enter executa o item destacado, nao o texto parcial digitado.
|
|
470
|
+
const selected = items[state.menuIndex % items.length][0];
|
|
471
|
+
state.input = selected;
|
|
472
|
+
state.cursor = selected.length;
|
|
473
|
+
state.menuIndex = 0;
|
|
474
|
+
}
|
|
475
|
+
return submit();
|
|
476
|
+
}
|
|
353
477
|
if (key.name === "tab") {
|
|
354
478
|
if (menuOpen) {
|
|
355
479
|
const selected = items[state.menuIndex % items.length];
|
|
@@ -358,7 +482,10 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
358
482
|
return;
|
|
359
483
|
}
|
|
360
484
|
if (key.name === "escape") {
|
|
361
|
-
if (menuOpen)
|
|
485
|
+
if (menuOpen) {
|
|
486
|
+
state.menuDismissed = true;
|
|
487
|
+
return render();
|
|
488
|
+
}
|
|
362
489
|
return;
|
|
363
490
|
}
|
|
364
491
|
if (key.name === "up" || key.name === "down") {
|
|
@@ -374,12 +501,8 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
374
501
|
if (next === state.history.length) return setInput("");
|
|
375
502
|
return setInput(state.history[next]);
|
|
376
503
|
}
|
|
377
|
-
if (key.name === "left")
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
if (key.name === "right") {
|
|
381
|
-
return setInput(state.input, state.cursor + 1);
|
|
382
|
-
}
|
|
504
|
+
if (key.name === "left") return setInput(state.input, state.cursor - 1);
|
|
505
|
+
if (key.name === "right") return setInput(state.input, state.cursor + 1);
|
|
383
506
|
if (key.name === "home") return setInput(state.input, 0);
|
|
384
507
|
if (key.name === "end") return setInput(state.input, state.input.length);
|
|
385
508
|
if (key.name === "backspace") {
|
|
@@ -450,4 +573,4 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
450
573
|
return { state, quit, render, submit };
|
|
451
574
|
}
|
|
452
575
|
|
|
453
|
-
module.exports = { runTUI, buildFrame, wrap, initialState, filteredCommands };
|
|
576
|
+
module.exports = { runTUI, buildFrame, wrap, initialState, filteredCommands, pickerLines };
|