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 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.0.tgz
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: 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,7 +1,7 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.1.0",
4
- "description": "Kafox CLI in opencode style: REPL, account login, models, balance, themes",
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 [k, v] of Object.entries(COMMANDS)) {
43
- console.log(` ${chalk.bold(k)} ${chalk.dim(v)}`);
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
- async function runREPL() {
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, "\n Kafox CLI v0.1.0 - opencode style"));
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
- async function ask(question, options = {}) {
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
- console.log(chalk.dim('Usage: kafox ask "your question" [-m model]'));
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
- console.log(chalk.yellow("Login first: `kafox login`"));
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
- console.log(chalk.red("Session invalid. Run `kafox login`"));
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
- console.log(
28
- chalk.red("\nSem saldo para responder. Recarregue em https://kafox.pypag.com/dashboard")
29
- );
30
- return;
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((r) => r.json())
38
+ .then((response) => response.json())
37
39
  .catch(() => null);
38
40
  model = pricing?.pricing?.[0]?.model;
39
41
  if (!model) {
40
- console.log(chalk.red("Could not load model catalog."));
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 kr = await fetchApi("/api/keys", {
49
+ const keyResponse = await fetchApi("/api/keys", {
48
50
  method: "POST",
49
51
  body: JSON.stringify({ name: "kafox-cli" }),
50
52
  });
51
- const kd = await kr.json().catch(() => ({}));
52
- if (!kr.ok) {
53
- console.log(chalk.red(`Failed to create key: ${kd.error || "error"}`));
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 = kd.secret;
57
+ apiKey = keyData.secret;
57
58
  storage.setConfig({ api_key: apiKey });
58
- console.log(chalk.dim("(key created and saved to ~/.kafox/config.json)"));
59
+ keyCreated = true;
59
60
  }
60
61
 
61
- console.log(chalk.dim(`Model: ${model}`));
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 res.json().catch(() => ({}));
67
- if (!res.ok) {
68
- if (res.status === 402) {
69
- console.log(
70
- chalk.red("\nNo balance! Recharge at https://kafox.pypag.com/dashboard")
71
- );
72
- } else {
73
- console.log(chalk.red(`\nFailed: ${data.error || "gateway error"}`));
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
- console.log(
83
- chalk.red("\nNo balance! Recharge at https://kafox.pypag.com/dashboard")
84
- );
85
- } else {
86
- console.log(chalk.red(`\nFailed (${data.status}): ${detail}`));
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 out =
93
+ const output =
92
94
  typeof body === "string"
93
95
  ? body
94
- : body?.choices?.[0]?.message?.content
95
- || body?.choices?.[0]?.text
96
- || JSON.stringify(body, null, 2);
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(out);
99
- if (data.ms) console.log(chalk.dim(`\n(${data.ms}ms provider ${data.provider || "?"})`));
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 };