kafox-cli 0.1.0 → 0.2.1
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 +1 -1
- package/bin/kafox.js +3 -2
- package/package.json +6 -3
- package/src/cli.js +39 -22
- package/src/gateway.js +72 -47
- package/src/tui.js +453 -0
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-0.1.
|
|
15
|
+
npm install -g https://kafox.pypag.com/static/downloads/kafox-cli-0.2.1.tgz
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Requisito: Node.js 22.5+.
|
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:
|
|
14
|
-
.version(
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kafox-cli",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Kafox CLI in opencode style:
|
|
3
|
+
"version": "0.2.1",
|
|
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"
|
|
7
7
|
},
|
|
@@ -18,5 +18,8 @@
|
|
|
18
18
|
"chalk": "4.1.2",
|
|
19
19
|
"commander": "12.1.0",
|
|
20
20
|
"inquirer": "8.2.6"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test \"test/*.test.js\""
|
|
21
24
|
}
|
|
22
|
-
}
|
|
25
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -4,9 +4,12 @@ const chalk = require("chalk");
|
|
|
4
4
|
const storage = require("./storage");
|
|
5
5
|
const { ensureSession, fetchApi } = require("./auth");
|
|
6
6
|
const { ask } = require("./gateway");
|
|
7
|
+
const { runTUI } = require("./tui");
|
|
7
8
|
const { runTheme, setContentBackground, paint } = require("./theme");
|
|
8
9
|
const { mascot } = require("./mascot");
|
|
9
10
|
|
|
11
|
+
const { version: VERSION } = require("../package.json");
|
|
12
|
+
|
|
10
13
|
const COMMANDS = {
|
|
11
14
|
"/help": "show this help",
|
|
12
15
|
"/models": "list models and prices",
|
|
@@ -39,8 +42,8 @@ async function handleCommand(line) {
|
|
|
39
42
|
switch (cmd) {
|
|
40
43
|
case "/help":
|
|
41
44
|
console.log(chalk.cyan("\nCommands:\n"));
|
|
42
|
-
for (const [
|
|
43
|
-
console.log(` ${chalk.bold(
|
|
45
|
+
for (const [key, value] of Object.entries(COMMANDS)) {
|
|
46
|
+
console.log(` ${chalk.bold(key)} ${chalk.dim(value)}`);
|
|
44
47
|
}
|
|
45
48
|
console.log("");
|
|
46
49
|
return true;
|
|
@@ -133,9 +136,7 @@ async function compact() {
|
|
|
133
136
|
...last,
|
|
134
137
|
];
|
|
135
138
|
console.log(
|
|
136
|
-
chalk.green(
|
|
137
|
-
`Context compacted: ${turns} -> ${history.length} messages (auto summary).`
|
|
138
|
-
)
|
|
139
|
+
chalk.green(`Context compacted: ${turns} -> ${history.length} messages (auto summary).`)
|
|
139
140
|
);
|
|
140
141
|
}
|
|
141
142
|
|
|
@@ -155,35 +156,27 @@ async function showStatus() {
|
|
|
155
156
|
console.log(chalk.cyan("\nKafox status\n"));
|
|
156
157
|
console.log(` Account: ${data.user.email} (${data.user.name})`);
|
|
157
158
|
console.log(
|
|
158
|
-
` Balance: ${chalk.green(
|
|
159
|
-
(data.user.balance_micros / 1_000_000).toFixed(2) + " R$"
|
|
160
|
-
)}`
|
|
161
|
-
);
|
|
162
|
-
console.log(
|
|
163
|
-
` Model: ${modelOverride || cfg.model || "(auto)"}`
|
|
159
|
+
` Balance: ${chalk.green((data.user.balance_micros / 1_000_000).toFixed(2) + " R$")}`
|
|
164
160
|
);
|
|
161
|
+
console.log(` Model: ${modelOverride || cfg.model || "(auto)"}`);
|
|
165
162
|
console.log(` Keys: ${data.keys.active}/${data.max_active_keys} active`);
|
|
166
163
|
console.log(` Gateway: ${data.gateway_url}`);
|
|
167
164
|
}
|
|
168
165
|
|
|
169
|
-
|
|
166
|
+
/** Modo simples: sem TTY (scripts, pipes) ou apos o login. */
|
|
167
|
+
async function runPlainREPL() {
|
|
170
168
|
const cfg = storage.getConfig();
|
|
171
169
|
const theme = cfg.theme || "default";
|
|
172
170
|
|
|
173
171
|
console.log(paint(theme, mascot));
|
|
174
|
-
console.log(paint(theme,
|
|
172
|
+
console.log(paint(theme, `\n Kafox CLI v${VERSION} - opencode style`));
|
|
175
173
|
console.log(chalk.dim(" Type /help for commands. Ctrl+C to exit.\n"));
|
|
176
174
|
|
|
177
175
|
const token = await ensureSession().catch(() => null);
|
|
178
176
|
if (!token) {
|
|
179
177
|
console.log(chalk.yellow(" You are not logged in."));
|
|
180
178
|
const { doLogin } = await inquirer.prompt([
|
|
181
|
-
{
|
|
182
|
-
type: "confirm",
|
|
183
|
-
name: "doLogin",
|
|
184
|
-
message: "Login now?",
|
|
185
|
-
default: true,
|
|
186
|
-
},
|
|
179
|
+
{ type: "confirm", name: "doLogin", message: "Login now?", default: true },
|
|
187
180
|
]);
|
|
188
181
|
if (doLogin) {
|
|
189
182
|
const { login } = require("./auth");
|
|
@@ -216,10 +209,8 @@ async function runREPL() {
|
|
|
216
209
|
await handleCommand(line);
|
|
217
210
|
} else {
|
|
218
211
|
pushHistory("user", line);
|
|
219
|
-
process.stdout.write(chalk.dim("thinking..."));
|
|
220
212
|
await ask(line, { model: modelOverride || storage.getConfig().model });
|
|
221
213
|
pushHistory("assistant", "(answer above)");
|
|
222
|
-
process.stdout.write("\r");
|
|
223
214
|
}
|
|
224
215
|
} catch (err) {
|
|
225
216
|
console.log(chalk.red(`\nError: ${err.message}`));
|
|
@@ -233,6 +224,32 @@ async function runREPL() {
|
|
|
233
224
|
});
|
|
234
225
|
}
|
|
235
226
|
|
|
227
|
+
async function runREPL() {
|
|
228
|
+
// Sem TTY (pipe/CI), mantem o REPL linha a linha.
|
|
229
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return runPlainREPL();
|
|
230
|
+
|
|
231
|
+
const cfg = storage.getConfig();
|
|
232
|
+
const token = await ensureSession().catch(() => null);
|
|
233
|
+
|
|
234
|
+
if (!token) {
|
|
235
|
+
console.log(paint(cfg.theme || "default", mascot));
|
|
236
|
+
console.log(paint(cfg.theme || "default", `\n Kafox CLI v${VERSION}`));
|
|
237
|
+
console.log(chalk.yellow(" You are not logged in."));
|
|
238
|
+
const { doLogin } = await inquirer.prompt([
|
|
239
|
+
{ type: "confirm", name: "doLogin", message: "Login now?", default: true },
|
|
240
|
+
]);
|
|
241
|
+
if (!doLogin) return;
|
|
242
|
+
const { login } = require("./auth");
|
|
243
|
+
await login();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return runTUI({
|
|
247
|
+
commands: COMMANDS,
|
|
248
|
+
onCommand: handleCommand,
|
|
249
|
+
interactiveCommands: new Set(["/login", "/theme", "/key", "/background"]),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
236
253
|
module.exports = {
|
|
237
254
|
runREPL,
|
|
238
255
|
compact,
|
|
@@ -240,4 +257,4 @@ module.exports = {
|
|
|
240
257
|
showStatus,
|
|
241
258
|
handleCommand,
|
|
242
259
|
COMMANDS,
|
|
243
|
-
};
|
|
260
|
+
};
|
package/src/gateway.js
CHANGED
|
@@ -2,101 +2,126 @@ const chalk = require("chalk");
|
|
|
2
2
|
const storage = require("./storage");
|
|
3
3
|
const { ensureSession, fetchApi } = require("./auth");
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Envia a pergunta ao gateway e devolve o resultado estruturado.
|
|
7
|
+
* A TUI usa o retorno; o comando `kafox ask` so imprime.
|
|
8
|
+
*/
|
|
9
|
+
async function askModel(question, options = {}) {
|
|
6
10
|
const text = typeof question === "string" ? question : (question || []).join(" ");
|
|
7
11
|
if (!text.trim()) {
|
|
8
|
-
|
|
9
|
-
return;
|
|
12
|
+
return { ok: false, error: 'Usage: kafox ask "your question" [-m model]' };
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
const token = await ensureSession();
|
|
13
16
|
if (!token) {
|
|
14
|
-
|
|
15
|
-
return;
|
|
17
|
+
return { ok: false, error: "Login first: `kafox login`" };
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
// Saldo decide se a CLI responde: sem credito, nem chega no gateway.
|
|
19
21
|
const boot = await fetchApi("/api/bootstrap");
|
|
20
22
|
if (!boot.ok) {
|
|
21
|
-
|
|
22
|
-
return;
|
|
23
|
+
return { ok: false, error: "Session invalid. Run `kafox login`" };
|
|
23
24
|
}
|
|
24
25
|
const account = await boot.json();
|
|
25
26
|
const balance = Number(account?.user?.balance_micros || 0);
|
|
26
27
|
if (balance <= 0) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
code: "no_balance",
|
|
31
|
+
error: "Sem saldo para responder. Recarregue em https://kafox.pypag.com/dashboard",
|
|
32
|
+
};
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
let model = options.model || storage.getConfig().model;
|
|
34
36
|
if (!model) {
|
|
35
37
|
const pricing = await fetchApi("/api/public/pricing")
|
|
36
|
-
.then((
|
|
38
|
+
.then((response) => response.json())
|
|
37
39
|
.catch(() => null);
|
|
38
40
|
model = pricing?.pricing?.[0]?.model;
|
|
39
41
|
if (!model) {
|
|
40
|
-
|
|
41
|
-
return;
|
|
42
|
+
return { ok: false, error: "Could not load model catalog." };
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
let keyCreated = false;
|
|
45
47
|
let apiKey = storage.getConfig().api_key;
|
|
46
48
|
if (!apiKey) {
|
|
47
|
-
const
|
|
49
|
+
const keyResponse = await fetchApi("/api/keys", {
|
|
48
50
|
method: "POST",
|
|
49
51
|
body: JSON.stringify({ name: "kafox-cli" }),
|
|
50
52
|
});
|
|
51
|
-
const
|
|
52
|
-
if (!
|
|
53
|
-
|
|
54
|
-
return;
|
|
53
|
+
const keyData = await keyResponse.json().catch(() => ({}));
|
|
54
|
+
if (!keyResponse.ok) {
|
|
55
|
+
return { ok: false, error: `Failed to create key: ${keyData.error || "error"}` };
|
|
55
56
|
}
|
|
56
|
-
apiKey =
|
|
57
|
+
apiKey = keyData.secret;
|
|
57
58
|
storage.setConfig({ api_key: apiKey });
|
|
58
|
-
|
|
59
|
+
keyCreated = true;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
const res = await fetchApi("/api/public/playground", {
|
|
62
|
+
const response = await fetchApi("/api/public/playground", {
|
|
63
63
|
method: "POST",
|
|
64
64
|
body: JSON.stringify({ api_key: apiKey, model, message: text }),
|
|
65
65
|
});
|
|
66
|
-
const data = await
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
66
|
+
const data = await response.json().catch(() => ({}));
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
if (response.status === 402) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
code: "no_balance",
|
|
73
|
+
error: "Sem saldo para responder. Recarregue em https://kafox.pypag.com/dashboard",
|
|
74
|
+
};
|
|
74
75
|
}
|
|
75
|
-
return;
|
|
76
|
+
return { ok: false, error: `Failed: ${data.error || "gateway error"}` };
|
|
76
77
|
}
|
|
78
|
+
|
|
77
79
|
// O playground devolve 200 mesmo quando o gateway recusa a chamada.
|
|
78
80
|
if (data.ok === false) {
|
|
79
|
-
const detail =
|
|
80
|
-
data.body?.error?.message || data.body?.error || "gateway error";
|
|
81
|
+
const detail = data.body?.error?.message || data.body?.error || "gateway error";
|
|
81
82
|
if (data.status === 402) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
code: "no_balance",
|
|
86
|
+
error: "Sem saldo para responder. Recarregue em https://kafox.pypag.com/dashboard",
|
|
87
|
+
};
|
|
87
88
|
}
|
|
88
|
-
return;
|
|
89
|
+
return { ok: false, error: `Failed (${data.status}): ${detail}` };
|
|
89
90
|
}
|
|
91
|
+
|
|
90
92
|
const body = data.body;
|
|
91
|
-
const
|
|
93
|
+
const output =
|
|
92
94
|
typeof body === "string"
|
|
93
95
|
? body
|
|
94
|
-
: body?.choices?.[0]?.message?.content
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
: body?.choices?.[0]?.message?.content ||
|
|
97
|
+
body?.choices?.[0]?.text ||
|
|
98
|
+
JSON.stringify(body, null, 2);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
text: String(output || "").trim() || "(resposta vazia)",
|
|
103
|
+
ms: data.ms,
|
|
104
|
+
provider: data.provider,
|
|
105
|
+
model,
|
|
106
|
+
keyCreated,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function ask(question, options = {}) {
|
|
111
|
+
const result = await askModel(question, options);
|
|
112
|
+
if (!result.ok) {
|
|
113
|
+
console.log(chalk.red(`\n${result.error}`));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (result.keyCreated) {
|
|
117
|
+
console.log(chalk.dim("(key created and saved to ~/.kafox/config.json)"));
|
|
118
|
+
}
|
|
119
|
+
console.log(chalk.dim(`Model: ${result.model}`));
|
|
97
120
|
console.log(chalk.green("\nAnswer:"));
|
|
98
|
-
console.log(
|
|
99
|
-
if (
|
|
121
|
+
console.log(result.text);
|
|
122
|
+
if (result.ms) {
|
|
123
|
+
console.log(chalk.dim(`\n(${result.ms}ms provider ${result.provider || "?"})`));
|
|
124
|
+
}
|
|
100
125
|
}
|
|
101
126
|
|
|
102
|
-
module.exports = { ask };
|
|
127
|
+
module.exports = { ask, askModel };
|
package/src/tui.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interface de terminal do kafox, no estilo opencode:
|
|
3
|
+
* tela cheia, mascote, menu de comandos "/", caixa de input e rodape.
|
|
4
|
+
*
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
const readline = require("readline");
|
|
9
|
+
const chalk = require("chalk");
|
|
10
|
+
const storage = require("./storage");
|
|
11
|
+
const { mascot } = require("./mascot");
|
|
12
|
+
const { paint } = require("./theme");
|
|
13
|
+
const { ensureSession, fetchApi } = require("./auth");
|
|
14
|
+
const { askModel } = require("./gateway");
|
|
15
|
+
|
|
16
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
17
|
+
const stripAnsi = (value) => String(value).replace(ANSI, "");
|
|
18
|
+
const visible = (value) => stripAnsi(value).length;
|
|
19
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
20
|
+
|
|
21
|
+
function wrap(text, width) {
|
|
22
|
+
const max = Math.max(10, width);
|
|
23
|
+
const lines = [];
|
|
24
|
+
for (const paragraph of String(text).replace(/\r/g, "").split("\n")) {
|
|
25
|
+
if (!paragraph.length) {
|
|
26
|
+
lines.push("");
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
let line = "";
|
|
30
|
+
for (const word of paragraph.split(/\s+/)) {
|
|
31
|
+
if (!line) line = word;
|
|
32
|
+
else if (line.length + 1 + word.length <= max) line += " " + word;
|
|
33
|
+
else {
|
|
34
|
+
lines.push(line);
|
|
35
|
+
line = word;
|
|
36
|
+
}
|
|
37
|
+
while (line.length > max) {
|
|
38
|
+
lines.push(line.slice(0, max));
|
|
39
|
+
line = line.slice(max);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
lines.push(line);
|
|
43
|
+
}
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function truncate(text, width) {
|
|
48
|
+
const plain = stripAnsi(text);
|
|
49
|
+
if (plain.length <= width) return text;
|
|
50
|
+
if (width <= 3) return plain.slice(0, width);
|
|
51
|
+
return plain.slice(0, width - 3) + "...";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function center(text, width) {
|
|
55
|
+
const pad = Math.max(0, Math.floor((width - visible(text)) / 2));
|
|
56
|
+
return " ".repeat(pad) + text;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function padRight(text, width) {
|
|
60
|
+
const missing = width - visible(text);
|
|
61
|
+
return missing > 0 ? text + " ".repeat(missing) : truncate(text, width);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function initialState() {
|
|
65
|
+
const cfg = storage.getConfig();
|
|
66
|
+
return {
|
|
67
|
+
input: "",
|
|
68
|
+
cursor: 0,
|
|
69
|
+
history: [],
|
|
70
|
+
historyIndex: -1,
|
|
71
|
+
messages: [],
|
|
72
|
+
busy: false,
|
|
73
|
+
frame: 0,
|
|
74
|
+
menuIndex: 0,
|
|
75
|
+
balance: null,
|
|
76
|
+
model: cfg.model || null,
|
|
77
|
+
theme: cfg.theme || "default",
|
|
78
|
+
suspended: false,
|
|
79
|
+
version: require("../package.json").version,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* ------------------------------------------------------------------ layout */
|
|
84
|
+
|
|
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;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function messageLines(state, width) {
|
|
100
|
+
const lines = [];
|
|
101
|
+
for (const message of state.messages) {
|
|
102
|
+
if (message.role === "user") {
|
|
103
|
+
lines.push("");
|
|
104
|
+
const body = wrap(message.text, width - 2);
|
|
105
|
+
lines.push(chalk.cyan("> ") + chalk.bold(body[0] || ""));
|
|
106
|
+
for (let index = 1; index < body.length; index += 1) {
|
|
107
|
+
lines.push(" " + chalk.bold(body[index]));
|
|
108
|
+
}
|
|
109
|
+
} else if (message.role === "assistant") {
|
|
110
|
+
lines.push("");
|
|
111
|
+
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
|
+
if (meta) lines.push(chalk.dim(" " + meta));
|
|
119
|
+
} else {
|
|
120
|
+
lines.push("");
|
|
121
|
+
const color = message.error ? chalk.red : chalk.dim;
|
|
122
|
+
for (const line of wrap(message.text, width - 2)) lines.push(color(" " + line));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (state.busy) {
|
|
126
|
+
lines.push("");
|
|
127
|
+
lines.push(chalk.dim(` pensando${".".repeat(state.frame % 4)}`));
|
|
128
|
+
}
|
|
129
|
+
return lines;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function filteredCommands(state, commands) {
|
|
133
|
+
if (!state.input.startsWith("/") || state.input.includes(" ")) return [];
|
|
134
|
+
const query = state.input.toLowerCase();
|
|
135
|
+
return Object.entries(commands).filter(([name]) =>
|
|
136
|
+
name.toLowerCase().startsWith(query)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function menuLines(state, commands, width) {
|
|
141
|
+
const items = filteredCommands(state, commands).slice(0, 8);
|
|
142
|
+
if (!items.length) return [];
|
|
143
|
+
return items.map(([name, description], index) => {
|
|
144
|
+
const selected = index === state.menuIndex % items.length;
|
|
145
|
+
const text = truncate(` ${name}`.padEnd(24) + description, width);
|
|
146
|
+
return selected ? chalk.inverse(padRight(text, width)) : chalk.dim(text);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function footerLines(state, width) {
|
|
151
|
+
const name = state.version ? `kafox ${state.version}` : "kafox";
|
|
152
|
+
const left = chalk.dim(truncate(`${name} | ${state.model || "auto"}`, width - 16));
|
|
153
|
+
const right = chalk.green(
|
|
154
|
+
state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`
|
|
155
|
+
);
|
|
156
|
+
const gap = " ".repeat(Math.max(1, width - visible(left) - visible(right)));
|
|
157
|
+
return [
|
|
158
|
+
left + gap + right,
|
|
159
|
+
chalk.dim(truncate(process.cwd(), width)),
|
|
160
|
+
chalk.dim(truncate("Enter envia | / comandos | Ctrl+C sai", width)),
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildFrame(state, cols, rows, commands) {
|
|
165
|
+
const width = Math.min(cols - 2, 108);
|
|
166
|
+
const margin = Math.floor((cols - width) / 2);
|
|
167
|
+
const gutter = " ".repeat(Math.max(0, margin));
|
|
168
|
+
|
|
169
|
+
const header = headerLines(state, width, rows);
|
|
170
|
+
const menu = menuLines(state, commands, width);
|
|
171
|
+
const footer = footerLines(state, width);
|
|
172
|
+
|
|
173
|
+
const inputWidth = width - 2;
|
|
174
|
+
const start = state.input.length > inputWidth
|
|
175
|
+
? clamp(state.cursor - (inputWidth - 2), 0, Math.max(0, state.input.length - inputWidth))
|
|
176
|
+
: 0;
|
|
177
|
+
const shown = state.input.slice(start, start + inputWidth);
|
|
178
|
+
const inputLine = state.input
|
|
179
|
+
? chalk.cyan("> ") + shown
|
|
180
|
+
: chalk.cyan("> ") + chalk.dim(truncate("Pergunte alguma coisa...", inputWidth));
|
|
181
|
+
const rule = chalk.dim("-".repeat(width));
|
|
182
|
+
|
|
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];
|
|
190
|
+
while (raw.length < rows) raw.push("");
|
|
191
|
+
const lines = raw.slice(0, rows).map((line) => gutter + padRight(line, width));
|
|
192
|
+
|
|
193
|
+
const inputRow = header.length + available + 1 + menu.length + 1;
|
|
194
|
+
const cursorCol = Math.max(1, margin + 2 + (state.cursor - start) + 1);
|
|
195
|
+
return { lines, cursorRow: Math.min(rows, inputRow), cursorCol };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* --------------------------------------------------------------------- app */
|
|
199
|
+
|
|
200
|
+
async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set() } = {}) {
|
|
201
|
+
const stdin = process.stdin;
|
|
202
|
+
const stdout = process.stdout;
|
|
203
|
+
const state = initialState();
|
|
204
|
+
let spinner = null;
|
|
205
|
+
let closed = false;
|
|
206
|
+
|
|
207
|
+
const write = (value) => stdout.write(value);
|
|
208
|
+
const enterScreen = () => write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J");
|
|
209
|
+
const leaveScreen = () => write("\x1b[?25h\x1b[?1049l");
|
|
210
|
+
|
|
211
|
+
async function refreshAccount() {
|
|
212
|
+
const token = await ensureSession();
|
|
213
|
+
if (!token) return;
|
|
214
|
+
const response = await fetchApi("/api/bootstrap").catch(() => null);
|
|
215
|
+
if (!response || !response.ok) return;
|
|
216
|
+
const data = await response.json().catch(() => null);
|
|
217
|
+
if (data?.user) state.balance = data.user.balance_micros;
|
|
218
|
+
|
|
219
|
+
if (!state.model) {
|
|
220
|
+
const pricing = await fetchApi("/api/public/pricing")
|
|
221
|
+
.then((res) => res.json())
|
|
222
|
+
.catch(() => null);
|
|
223
|
+
state.model = pricing?.pricing?.[0]?.model || null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function render() {
|
|
228
|
+
if (closed || state.suspended) return;
|
|
229
|
+
const cols = Math.max(40, stdout.columns || 80);
|
|
230
|
+
const rows = Math.max(16, stdout.rows || 24);
|
|
231
|
+
const frame = buildFrame(state, cols, rows, commands);
|
|
232
|
+
write("\x1b[?25l\x1b[H" + frame.lines.join("\r\n"));
|
|
233
|
+
write(`\x1b[${frame.cursorRow};${frame.cursorCol}H\x1b[?25h`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function capture(fn) {
|
|
237
|
+
const original = console.log;
|
|
238
|
+
const collected = [];
|
|
239
|
+
console.log = (...args) => collected.push(args.map((item) => String(item)).join(" "));
|
|
240
|
+
try {
|
|
241
|
+
await fn();
|
|
242
|
+
} catch (error) {
|
|
243
|
+
collected.push(`Erro: ${error.message}`);
|
|
244
|
+
} finally {
|
|
245
|
+
console.log = original;
|
|
246
|
+
}
|
|
247
|
+
return collected.join("\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function runOutside(line) {
|
|
251
|
+
state.suspended = true;
|
|
252
|
+
leaveScreen();
|
|
253
|
+
stdin.setRawMode(false);
|
|
254
|
+
stdin.pause();
|
|
255
|
+
try {
|
|
256
|
+
await onCommand(line);
|
|
257
|
+
} finally {
|
|
258
|
+
stdin.resume();
|
|
259
|
+
stdin.setRawMode(true);
|
|
260
|
+
state.suspended = false;
|
|
261
|
+
enterScreen();
|
|
262
|
+
await refreshAccount();
|
|
263
|
+
render();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function runCommand(line) {
|
|
268
|
+
const name = line.split(/\s+/)[0];
|
|
269
|
+
if (name === "/exit" || name === "/quit") return quit();
|
|
270
|
+
|
|
271
|
+
if (name === "/clear") {
|
|
272
|
+
state.messages = [];
|
|
273
|
+
state.status = "";
|
|
274
|
+
return render();
|
|
275
|
+
}
|
|
276
|
+
if (name === "/compact") {
|
|
277
|
+
if (!state.messages.length) {
|
|
278
|
+
state.messages.push({ role: "system", text: "Nada para compactar." });
|
|
279
|
+
} else {
|
|
280
|
+
const total = state.messages.length;
|
|
281
|
+
state.messages = [
|
|
282
|
+
{
|
|
283
|
+
role: "system",
|
|
284
|
+
text: `Contexto compactado: ${total} mensagens resumidas.`,
|
|
285
|
+
},
|
|
286
|
+
...state.messages.slice(-6),
|
|
287
|
+
];
|
|
288
|
+
}
|
|
289
|
+
return render();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (interactiveCommands.has(name)) return runOutside(line);
|
|
293
|
+
|
|
294
|
+
const before = state.messages.length;
|
|
295
|
+
const output = (await capture(() => onCommand(line))).trim();
|
|
296
|
+
if (output) state.messages.push({ role: "system", text: output });
|
|
297
|
+
else if (state.messages.length === before) {
|
|
298
|
+
state.messages.push({ role: "system", text: `${name}: sem saida.` });
|
|
299
|
+
}
|
|
300
|
+
state.model = storage.getConfig().model || state.model;
|
|
301
|
+
render();
|
|
302
|
+
await refreshAccount();
|
|
303
|
+
render();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function submit() {
|
|
307
|
+
const line = state.input.trim();
|
|
308
|
+
if (!line || state.busy) return;
|
|
309
|
+
state.history.push(line);
|
|
310
|
+
state.historyIndex = state.history.length;
|
|
311
|
+
state.input = "";
|
|
312
|
+
state.cursor = 0;
|
|
313
|
+
state.menuIndex = 0;
|
|
314
|
+
|
|
315
|
+
if (line.startsWith("/")) return runCommand(line);
|
|
316
|
+
|
|
317
|
+
state.messages.push({ role: "user", text: line });
|
|
318
|
+
state.busy = true;
|
|
319
|
+
render();
|
|
320
|
+
|
|
321
|
+
const result = await askModel(line, { model: state.model });
|
|
322
|
+
state.busy = false;
|
|
323
|
+
if (result.ok) {
|
|
324
|
+
state.messages.push({
|
|
325
|
+
role: "assistant",
|
|
326
|
+
text: result.text,
|
|
327
|
+
ms: result.ms,
|
|
328
|
+
provider: result.provider,
|
|
329
|
+
});
|
|
330
|
+
} else {
|
|
331
|
+
state.messages.push({ role: "system", text: result.error || "Falha ao responder.", error: true });
|
|
332
|
+
}
|
|
333
|
+
render();
|
|
334
|
+
await refreshAccount();
|
|
335
|
+
render();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function setInput(value, cursor) {
|
|
339
|
+
state.input = value;
|
|
340
|
+
state.cursor = cursor == null ? value.length : clamp(cursor, 0, value.length);
|
|
341
|
+
state.menuIndex = 0;
|
|
342
|
+
render();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function onKey(str, key = {}) {
|
|
346
|
+
if (state.suspended) return;
|
|
347
|
+
if (key.ctrl && key.name === "c") return quit();
|
|
348
|
+
|
|
349
|
+
const items = filteredCommands(state, commands);
|
|
350
|
+
const menuOpen = items.length > 0;
|
|
351
|
+
|
|
352
|
+
if (key.name === "return") return submit();
|
|
353
|
+
if (key.name === "tab") {
|
|
354
|
+
if (menuOpen) {
|
|
355
|
+
const selected = items[state.menuIndex % items.length];
|
|
356
|
+
return setInput(selected[0] + " ");
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (key.name === "escape") {
|
|
361
|
+
if (menuOpen) return setInput("");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (key.name === "up" || key.name === "down") {
|
|
365
|
+
if (menuOpen) {
|
|
366
|
+
const delta = key.name === "up" ? -1 : 1;
|
|
367
|
+
state.menuIndex = (state.menuIndex + delta + items.length) % items.length;
|
|
368
|
+
return render();
|
|
369
|
+
}
|
|
370
|
+
if (!state.history.length) return;
|
|
371
|
+
const delta = key.name === "up" ? -1 : 1;
|
|
372
|
+
const next = clamp(state.historyIndex + delta, 0, state.history.length);
|
|
373
|
+
state.historyIndex = next;
|
|
374
|
+
if (next === state.history.length) return setInput("");
|
|
375
|
+
return setInput(state.history[next]);
|
|
376
|
+
}
|
|
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
|
+
}
|
|
383
|
+
if (key.name === "home") return setInput(state.input, 0);
|
|
384
|
+
if (key.name === "end") return setInput(state.input, state.input.length);
|
|
385
|
+
if (key.name === "backspace") {
|
|
386
|
+
if (!state.cursor) return;
|
|
387
|
+
return setInput(
|
|
388
|
+
state.input.slice(0, state.cursor - 1) + state.input.slice(state.cursor),
|
|
389
|
+
state.cursor - 1
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (key.name === "delete") {
|
|
393
|
+
if (state.cursor >= state.input.length) return;
|
|
394
|
+
return setInput(
|
|
395
|
+
state.input.slice(0, state.cursor) + state.input.slice(state.cursor + 1),
|
|
396
|
+
state.cursor
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (key.ctrl && key.name === "u") return setInput("");
|
|
400
|
+
if (key.ctrl && key.name === "w") {
|
|
401
|
+
const before = state.input.slice(0, state.cursor).replace(/\s*\S*$/, "");
|
|
402
|
+
return setInput(before + state.input.slice(state.cursor), before.length);
|
|
403
|
+
}
|
|
404
|
+
if (key.ctrl && key.name === "l") {
|
|
405
|
+
write("\x1b[H\x1b[2J");
|
|
406
|
+
return render();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (str && !key.ctrl && !key.meta) {
|
|
410
|
+
const text = str.replace(/[\x00-\x1f\x7f]/g, "");
|
|
411
|
+
if (!text) return;
|
|
412
|
+
setInput(
|
|
413
|
+
state.input.slice(0, state.cursor) + text + state.input.slice(state.cursor),
|
|
414
|
+
state.cursor + text.length
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function quit() {
|
|
420
|
+
if (closed) return;
|
|
421
|
+
closed = true;
|
|
422
|
+
if (spinner) clearInterval(spinner);
|
|
423
|
+
stdin.removeListener("keypress", onKey);
|
|
424
|
+
stdout.removeListener("resize", render);
|
|
425
|
+
try {
|
|
426
|
+
stdin.setRawMode(false);
|
|
427
|
+
} catch {}
|
|
428
|
+
leaveScreen();
|
|
429
|
+
write("\n");
|
|
430
|
+
process.exit(0);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
enterScreen();
|
|
434
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
435
|
+
stdin.resume();
|
|
436
|
+
readline.emitKeypressEvents(stdin);
|
|
437
|
+
stdin.on("keypress", onKey);
|
|
438
|
+
stdout.on("resize", render);
|
|
439
|
+
|
|
440
|
+
await refreshAccount();
|
|
441
|
+
render();
|
|
442
|
+
|
|
443
|
+
spinner = setInterval(() => {
|
|
444
|
+
if (!state.busy || closed) return;
|
|
445
|
+
state.frame += 1;
|
|
446
|
+
render();
|
|
447
|
+
}, 110);
|
|
448
|
+
spinner.unref?.();
|
|
449
|
+
|
|
450
|
+
return { state, quit, render, submit };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
module.exports = { runTUI, buildFrame, wrap, initialState, filteredCommands };
|