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 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.1.tgz
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 o REPL |
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
- Dentro do REPL: `/help`, `/models`, `/model <id>`, `/balance`, `/pricing`,
47
- `/key`, `/login`, `/logout`, `/whoami`, `/theme`, `/background`, `/clear`,
48
- `/compact`, `/status`, `/exit`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Kafox CLI in opencode style: full-screen terminal UI, device login, models, balance, themes",
5
5
  "bin": {
6
6
  "kafox": "bin/kafox.js"
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 <id>": "set default 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 [url]": "set background image (modal asks for URL)",
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 provider ${result.provider || "?"})`));
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 "/", caixa de input e rodape.
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
- const art = mascot.split("\n");
88
- const visibleArt = rows >= 46 ? art : art.slice(0, 9);
89
- const lines = visibleArt.map((line) => center(paint(state.theme, line), width));
90
- lines.push("");
91
- lines.push(center(chalk.bold("O que voce quer fazer?"), width));
92
- lines.push(
93
- center(chalk.dim("Use / para comandos | Enter envia | Ctrl+C sai"), width)
94
- );
95
- lines.push("");
96
- return lines;
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).filter(([name]) =>
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 = state.version ? `kafox ${state.version}` : "kafox";
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 header = headerLines(state, width, rows);
170
- const menu = menuLines(state, commands, width);
171
- const footer = footerLines(state, width);
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 reserved = footer.length + menu.length + 3;
184
- const available = Math.max(1, rows - header.length - reserved);
185
- let body = messageLines(state, width);
186
- if (body.length > available) body = body.slice(body.length - available);
187
- while (body.length < available) body.unshift("");
188
-
189
- const raw = [...header, ...body, "", ...menu, inputLine, rule, ...footer];
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 + 1 + menu.length + 1;
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 name = line.split(/\s+/)[0];
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") return submit();
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) return setInput("");
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
- return setInput(state.input, state.cursor - 1);
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 };