kafox-cli 0.2.0 → 0.2.2

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.0.tgz
15
+ npm install -g https://kafox.pypag.com/static/downloads/kafox-cli-0.2.2.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/bin/kafox.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  const { Command } = require("commander");
3
+ const { version } = require("../package.json");
3
4
  const { runREPL, compact, clearContext, showStatus } = require("../src/cli");
4
5
  const { login, logout, whoami, createKey } = require("../src/auth");
5
6
  const { listModels, showBalance, showPricing } = require("../src/models");
@@ -10,8 +11,8 @@ const program = new Command();
10
11
 
11
12
  program
12
13
  .name("kafox")
13
- .description("Kafox CLI in opencode style: REPL, account login, models, balance, themes")
14
- .version("0.1.0");
14
+ .description("Kafox CLI in opencode style: terminal UI, account login, models, balance, themes")
15
+ .version(version);
15
16
 
16
17
  program.command("login").description("Login with your Kafox account (device code SSO)").action(login);
17
18
  program.command("logout").description("End the CLI session").action(logout);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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
@@ -8,12 +8,12 @@ const { runTUI } = require("./tui");
8
8
  const { runTheme, setContentBackground, paint } = require("./theme");
9
9
  const { mascot } = require("./mascot");
10
10
 
11
- const VERSION = "0.2.0";
11
+ const { version: VERSION } = require("../package.json");
12
12
 
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,28 +71,35 @@ 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",
78
80
  suspended: false,
79
- version: "0.2.0",
81
+ version: require("../package.json").version,
80
82
  };
81
83
  }
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
+ const art = mascot.split("\n").map((line) => chalk.white(line));
91
+ const space = rows - 2 - footerCount - (compact ? 0 : 1);
92
+
93
+ if (space >= art.length + 4) {
94
+ return [...art, "", center(chalk.bold("O que voce quer fazer?"), width),
95
+ center(chalk.dim("Use / para comandos | Enter envia | Ctrl+C sai"), width), ""];
96
+ }
97
+ if (space >= art.length + 2) {
98
+ return [...art, "", center(chalk.bold("O que voce quer fazer?"), width), ""];
99
+ }
100
+ if (space >= art.length + 1) return [...art, ""];
101
+ if (space >= art.length) return art;
102
+ return art.slice(0, Math.max(0, space));
97
103
  }
98
104
 
99
105
  function messageLines(state, width) {
@@ -109,12 +115,7 @@ function messageLines(state, width) {
109
115
  } else if (message.role === "assistant") {
110
116
  lines.push("");
111
117
  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(" | ");
118
+ const meta = message.ms ? `${(message.ms / 1000).toFixed(1)}s` : "";
118
119
  if (meta) lines.push(chalk.dim(" " + meta));
119
120
  } else {
120
121
  lines.push("");
@@ -130,11 +131,15 @@ function messageLines(state, width) {
130
131
  }
131
132
 
132
133
  function filteredCommands(state, commands) {
134
+ if (state.menuDismissed) return [];
133
135
  if (!state.input.startsWith("/") || state.input.includes(" ")) return [];
134
136
  const query = state.input.toLowerCase();
135
- return Object.entries(commands).filter(([name]) =>
136
- name.toLowerCase().startsWith(query)
137
- );
137
+ return Object.entries(commands)
138
+ .filter(([name]) => name.toLowerCase().startsWith(query))
139
+ .sort(
140
+ (a, b) =>
141
+ Number(b[0].toLowerCase() === query) - Number(a[0].toLowerCase() === query)
142
+ );
138
143
  }
139
144
 
140
145
  function menuLines(state, commands, width) {
@@ -147,8 +152,33 @@ function menuLines(state, commands, width) {
147
152
  });
148
153
  }
149
154
 
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
+
150
180
  function footerLines(state, width) {
151
- const name = state.version ? `kafox ${state.version}` : "kafox";
181
+ const name = `kafox ${state.version}`;
152
182
  const left = chalk.dim(truncate(`${name} | ${state.model || "auto"}`, width - 16));
153
183
  const right = chalk.green(
154
184
  state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`
@@ -165,10 +195,19 @@ function buildFrame(state, cols, rows, commands) {
165
195
  const width = Math.min(cols - 2, 108);
166
196
  const margin = Math.floor((cols - width) / 2);
167
197
  const gutter = " ".repeat(Math.max(0, margin));
198
+ const compact = rows < 34;
168
199
 
169
- const header = headerLines(state, width, rows);
170
- const menu = menuLines(state, commands, width);
171
- const footer = footerLines(state, width);
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);
204
+ const header = headerLines(state, width, rows, footer.length, compact);
205
+
206
+ const reserved = 2 + footer.length + (compact ? 0 : 1) + panel.length;
207
+ 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);
210
+ while (body.length < available) body.unshift("");
172
211
 
173
212
  const inputWidth = width - 2;
174
213
  const start = state.input.length > inputWidth
@@ -180,17 +219,20 @@ function buildFrame(state, cols, rows, commands) {
180
219
  : chalk.cyan("> ") + chalk.dim(truncate("Pergunte alguma coisa...", inputWidth));
181
220
  const rule = chalk.dim("-".repeat(width));
182
221
 
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];
222
+ const middle = compact ? [] : [""];
223
+ const raw = [
224
+ ...header,
225
+ ...body,
226
+ ...middle,
227
+ ...panel,
228
+ inputLine,
229
+ rule,
230
+ ...footer,
231
+ ];
190
232
  while (raw.length < rows) raw.push("");
191
233
  const lines = raw.slice(0, rows).map((line) => gutter + padRight(line, width));
192
234
 
193
- const inputRow = header.length + available + 1 + menu.length + 1;
235
+ const inputRow = header.length + available + middle.length + panel.length + 1;
194
236
  const cursorCol = Math.max(1, margin + 2 + (state.cursor - start) + 1);
195
237
  return { lines, cursorRow: Math.min(rows, inputRow), cursorCol };
196
238
  }
@@ -247,6 +289,46 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
247
289
  return collected.join("\n");
248
290
  }
249
291
 
292
+ async function loadModels() {
293
+ if (state.models.length) return state.models;
294
+ const pricing = await fetchApi("/api/public/pricing")
295
+ .then((res) => res.json())
296
+ .catch(() => null);
297
+ state.models = (pricing?.pricing || []).map((row) => ({
298
+ id: row.model,
299
+ input: row.input_micros_per_1m,
300
+ output: row.output_micros_per_1m,
301
+ }));
302
+ return state.models;
303
+ }
304
+
305
+ async function openModelPicker() {
306
+ state.menuDismissed = true;
307
+ state.picker = { items: state.models, index: 0, loading: true };
308
+ render();
309
+ const models = await loadModels();
310
+ if (!models.length) {
311
+ state.picker = null;
312
+ state.messages.push({ role: "system", text: "Nao consegui carregar os modelos.", error: true });
313
+ return render();
314
+ }
315
+ const current = models.findIndex((item) => item.id === state.model);
316
+ state.picker = { items: models, index: current >= 0 ? current : 0, loading: false };
317
+ render();
318
+ }
319
+
320
+ function selectModel() {
321
+ const picker = state.picker;
322
+ if (!picker || picker.loading) return;
323
+ const item = picker.items[picker.index];
324
+ if (!item) return;
325
+ storage.setConfig({ model: item.id });
326
+ state.model = item.id;
327
+ state.picker = null;
328
+ state.messages.push({ role: "system", text: `Modelo: ${item.id}` });
329
+ render();
330
+ }
331
+
250
332
  async function runOutside(line) {
251
333
  state.suspended = true;
252
334
  leaveScreen();
@@ -265,12 +347,14 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
265
347
  }
266
348
 
267
349
  async function runCommand(line) {
268
- const name = line.split(/\s+/)[0];
350
+ const parts = line.trim().split(/\s+/);
351
+ const name = parts[0];
269
352
  if (name === "/exit" || name === "/quit") return quit();
270
353
 
354
+ if (name === "/model" && !parts[1]) return openModelPicker();
355
+
271
356
  if (name === "/clear") {
272
357
  state.messages = [];
273
- state.status = "";
274
358
  return render();
275
359
  }
276
360
  if (name === "/compact") {
@@ -279,10 +363,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
279
363
  } else {
280
364
  const total = state.messages.length;
281
365
  state.messages = [
282
- {
283
- role: "system",
284
- text: `Contexto compactado: ${total} mensagens resumidas.`,
285
- },
366
+ { role: "system", text: `Contexto compactado: ${total} mensagens resumidas.` },
286
367
  ...state.messages.slice(-6),
287
368
  ];
288
369
  }
@@ -311,6 +392,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
311
392
  state.input = "";
312
393
  state.cursor = 0;
313
394
  state.menuIndex = 0;
395
+ state.menuDismissed = false;
314
396
 
315
397
  if (line.startsWith("/")) return runCommand(line);
316
398
 
@@ -339,6 +421,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
339
421
  state.input = value;
340
422
  state.cursor = cursor == null ? value.length : clamp(cursor, 0, value.length);
341
423
  state.menuIndex = 0;
424
+ state.menuDismissed = false;
342
425
  render();
343
426
  }
344
427
 
@@ -346,10 +429,46 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
346
429
  if (state.suspended) return;
347
430
  if (key.ctrl && key.name === "c") return quit();
348
431
 
432
+ // Seletor de modelo tem prioridade sobre o resto.
433
+ if (state.picker) {
434
+ if (state.picker.loading) return;
435
+ if (key.name === "up" || key.name === "down") {
436
+ const delta = key.name === "up" ? -1 : 1;
437
+ state.picker.index = clamp(
438
+ state.picker.index + delta,
439
+ 0,
440
+ Math.max(0, state.picker.items.length - 1)
441
+ );
442
+ return render();
443
+ }
444
+ if (key.name === "return") return selectModel();
445
+ if (key.name === "escape") {
446
+ state.picker = null;
447
+ return render();
448
+ }
449
+ return;
450
+ }
451
+
349
452
  const items = filteredCommands(state, commands);
350
453
  const menuOpen = items.length > 0;
351
454
 
352
- if (key.name === "return") return submit();
455
+ if (key.name === "return") {
456
+ // Se o texto ja e um comando exato, ele vence a lista (ex.: /model vs /models).
457
+ const exact = Object.keys(commands).find(
458
+ (name) => name.toLowerCase() === state.input.trim().toLowerCase()
459
+ );
460
+ if (exact) {
461
+ state.input = exact;
462
+ state.cursor = exact.length;
463
+ } else if (menuOpen) {
464
+ // Enter executa o item destacado, nao o texto parcial digitado.
465
+ const selected = items[state.menuIndex % items.length][0];
466
+ state.input = selected;
467
+ state.cursor = selected.length;
468
+ state.menuIndex = 0;
469
+ }
470
+ return submit();
471
+ }
353
472
  if (key.name === "tab") {
354
473
  if (menuOpen) {
355
474
  const selected = items[state.menuIndex % items.length];
@@ -358,7 +477,10 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
358
477
  return;
359
478
  }
360
479
  if (key.name === "escape") {
361
- if (menuOpen) return setInput("");
480
+ if (menuOpen) {
481
+ state.menuDismissed = true;
482
+ return render();
483
+ }
362
484
  return;
363
485
  }
364
486
  if (key.name === "up" || key.name === "down") {
@@ -374,12 +496,8 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
374
496
  if (next === state.history.length) return setInput("");
375
497
  return setInput(state.history[next]);
376
498
  }
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
- }
499
+ if (key.name === "left") return setInput(state.input, state.cursor - 1);
500
+ if (key.name === "right") return setInput(state.input, state.cursor + 1);
383
501
  if (key.name === "home") return setInput(state.input, 0);
384
502
  if (key.name === "end") return setInput(state.input, state.input.length);
385
503
  if (key.name === "backspace") {
@@ -450,4 +568,4 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
450
568
  return { state, quit, render, submit };
451
569
  }
452
570
 
453
- module.exports = { runTUI, buildFrame, wrap, initialState, filteredCommands };
571
+ module.exports = { runTUI, buildFrame, wrap, initialState, filteredCommands, pickerLines };