kafox-cli 0.2.2 → 0.3.0
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 +11 -3
- package/package.json +2 -2
- package/src/tui.js +327 -88
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.
|
|
15
|
+
npm install -g https://kafox.pypag.com/static/downloads/kafox-cli-0.3.0.tgz
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Requisito: Node.js 22.5+.
|
|
@@ -43,8 +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
|
-
Na interface: digite `/` para o menu (↑/↓ navega, Tab completa, Enter executa
|
|
47
|
-
|
|
46
|
+
Na interface: digite `/` para o menu (↑/↓ navega, Tab completa, Enter executa),
|
|
47
|
+
`/model` abre o seletor de modelo (↑/↓ + Enter). Respostas sao renderizadas em
|
|
48
|
+
Markdown (titulos, listas, negrito e codigo). PgUp/PgDn ou a roda do mouse rolam a
|
|
49
|
+
conversa. Saidas de comando abrem num modal central; qualquer tecla fecha. Demais comandos: `/help`,
|
|
48
50
|
`/models`, `/balance`, `/pricing`, `/key`, `/login`, `/logout`, `/whoami`,
|
|
49
51
|
`/theme`, `/background`, `/clear`, `/compact`, `/status`, `/exit`.
|
|
50
52
|
|
|
@@ -56,3 +58,9 @@ Na interface: digite `/` para o menu (↑/↓ navega, Tab completa, Enter execut
|
|
|
56
58
|
|
|
57
59
|
A CLI so responde se a conta tiver saldo; sem credito ela avisa e nao chama o
|
|
58
60
|
gateway.
|
|
61
|
+
|
|
62
|
+
## Escopo
|
|
63
|
+
|
|
64
|
+
O kafox e um cliente de conversa: autentica na conta, lista modelos, mostra saldo
|
|
65
|
+
e envia mensagens ao gateway. Ele nao executa ferramentas locais nem conecta
|
|
66
|
+
servidores MCP.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kafox-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Kafox CLI in opencode style:
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Kafox CLI in opencode style: scrollable TUI, markdown, modals, device login, models, balance",
|
|
5
5
|
"bin": {
|
|
6
6
|
"kafox": "bin/kafox.js"
|
|
7
7
|
},
|
package/src/tui.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Interface de terminal do kafox, no estilo opencode
|
|
3
|
-
* tela cheia, mascote, menu de comandos "/", seletor de modelo, input e rodape.
|
|
2
|
+
* Interface de terminal do kafox, no estilo opencode.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* - conversa rolavel (PgUp/PgDn e roda do mouse)
|
|
5
|
+
* - respostas em Markdown (titulos, listas, negrito, codigo)
|
|
6
|
+
* - comandos "/" abrem um modal centralizado, como no opencode
|
|
7
7
|
*/
|
|
8
8
|
const readline = require("readline");
|
|
9
9
|
const chalk = require("chalk");
|
|
@@ -50,16 +50,163 @@ function truncate(text, width) {
|
|
|
50
50
|
return plain.slice(0, width - 3) + "...";
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function padRight(text, width) {
|
|
54
|
+
const missing = width - visible(text);
|
|
55
|
+
return missing > 0 ? text + " ".repeat(missing) : truncate(text, width);
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
function center(text, width) {
|
|
54
59
|
const pad = Math.max(0, Math.floor((width - visible(text)) / 2));
|
|
55
60
|
return " ".repeat(pad) + text;
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
/* ----------------------------------------------------------------- markdown */
|
|
64
|
+
|
|
65
|
+
const INLINE = /(\*\*[^*]+\*\*|`[^`]+`|\*[^*\n]+\*)/g;
|
|
66
|
+
|
|
67
|
+
function parseInline(text) {
|
|
68
|
+
const parts = String(text).split(INLINE);
|
|
69
|
+
const segments = [];
|
|
70
|
+
for (const part of parts) {
|
|
71
|
+
if (!part) continue;
|
|
72
|
+
if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
|
|
73
|
+
segments.push({ text: part.slice(2, -2), bold: true });
|
|
74
|
+
} else if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
|
|
75
|
+
segments.push({ text: part.slice(1, -1), code: true });
|
|
76
|
+
} else if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
|
|
77
|
+
segments.push({ text: part.slice(1, -1), italic: true });
|
|
78
|
+
} else {
|
|
79
|
+
segments.push({ text: part });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return segments;
|
|
61
83
|
}
|
|
62
84
|
|
|
85
|
+
function wrapSegments(segments, width) {
|
|
86
|
+
const max = Math.max(10, width);
|
|
87
|
+
const lines = [];
|
|
88
|
+
let current = [];
|
|
89
|
+
let length = 0;
|
|
90
|
+
|
|
91
|
+
const flush = () => {
|
|
92
|
+
lines.push(current);
|
|
93
|
+
current = [];
|
|
94
|
+
length = 0;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
for (const segment of segments) {
|
|
98
|
+
for (const token of String(segment.text).split(/(\s+)/)) {
|
|
99
|
+
if (!token || !token.trim()) continue;
|
|
100
|
+
let word = token;
|
|
101
|
+
while (word.length > max) {
|
|
102
|
+
const slice = word.slice(0, max - (length > 0 ? 1 : 0));
|
|
103
|
+
if (length > 0) {
|
|
104
|
+
current.push({ text: " " });
|
|
105
|
+
length += 1;
|
|
106
|
+
}
|
|
107
|
+
current.push({ ...segment, text: slice });
|
|
108
|
+
flush();
|
|
109
|
+
word = word.slice(slice.length);
|
|
110
|
+
}
|
|
111
|
+
const space = length > 0 ? 1 : 0;
|
|
112
|
+
if (length + space + word.length > max) flush();
|
|
113
|
+
if (length > 0) {
|
|
114
|
+
current.push({ text: " " });
|
|
115
|
+
length += 1;
|
|
116
|
+
}
|
|
117
|
+
current.push({ ...segment, text: word });
|
|
118
|
+
length += word.length;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (current.length) flush();
|
|
122
|
+
return lines;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderSegments(segments) {
|
|
126
|
+
return segments
|
|
127
|
+
.map((segment) => {
|
|
128
|
+
let text = segment.text;
|
|
129
|
+
if (segment.code) text = chalk.cyan(text);
|
|
130
|
+
if (segment.italic) text = chalk.italic(text);
|
|
131
|
+
if (segment.bold) text = chalk.bold(text);
|
|
132
|
+
return text;
|
|
133
|
+
})
|
|
134
|
+
.join("");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Converte Markdown simples em linhas prontas para o terminal. */
|
|
138
|
+
function markdownLines(text, width) {
|
|
139
|
+
const out = [];
|
|
140
|
+
const rows = String(text).replace(/\r/g, "").split("\n");
|
|
141
|
+
let inCode = false;
|
|
142
|
+
|
|
143
|
+
for (const raw of rows) {
|
|
144
|
+
const line = raw.replace(/\s+$/, "");
|
|
145
|
+
if (/^\s*```/.test(line)) {
|
|
146
|
+
inCode = !inCode;
|
|
147
|
+
out.push(chalk.dim(" " + "-".repeat(Math.min(width, 32))));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (inCode) {
|
|
151
|
+
out.push(chalk.cyan(" " + truncate(line, width - 2)));
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!line.trim()) {
|
|
155
|
+
out.push("");
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
159
|
+
out.push(chalk.dim(" " + "-".repeat(Math.min(width, 40))));
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const heading = line.match(/^\s*#{1,6}\s+(.*)$/);
|
|
164
|
+
if (heading) {
|
|
165
|
+
if (out.length) out.push("");
|
|
166
|
+
for (const wrapped of wrapSegments(parseInline(heading[1]), width - 2)) {
|
|
167
|
+
out.push(" " + chalk.bold(renderSegments(wrapped)));
|
|
168
|
+
}
|
|
169
|
+
out.push("");
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const bullet = line.match(/^\s*[-*+]\s+(.*)$/);
|
|
174
|
+
if (bullet) {
|
|
175
|
+
const wrapped = wrapSegments(parseInline(bullet[1]), width - 4);
|
|
176
|
+
wrapped.forEach((part, index) => {
|
|
177
|
+
out.push((index === 0 ? " • " : " ") + renderSegments(part));
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const numbered = line.match(/^\s*(\d+)[.)]\s+(.*)$/);
|
|
183
|
+
if (numbered) {
|
|
184
|
+
const wrapped = wrapSegments(parseInline(numbered[2]), width - 6);
|
|
185
|
+
wrapped.forEach((part, index) => {
|
|
186
|
+
out.push((index === 0 ? ` ${numbered[1]}. ` : " ") + renderSegments(part));
|
|
187
|
+
});
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const quote = line.match(/^\s*>\s?(.*)$/);
|
|
192
|
+
if (quote) {
|
|
193
|
+
for (const part of wrapSegments(parseInline(quote[1]), width - 4)) {
|
|
194
|
+
out.push(chalk.dim(" | " + stripAnsi(renderSegments(part))));
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Modelos costumam indentar paragrafos; no terminal isso vira ruim.
|
|
200
|
+
const clean = line.replace(/^\s+(?=\S)/, "");
|
|
201
|
+
for (const part of wrapSegments(parseInline(clean), width - 2)) {
|
|
202
|
+
out.push(" " + renderSegments(part));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ------------------------------------------------------------------- layout */
|
|
209
|
+
|
|
63
210
|
function initialState() {
|
|
64
211
|
const cfg = storage.getConfig();
|
|
65
212
|
return {
|
|
@@ -73,8 +220,11 @@ function initialState() {
|
|
|
73
220
|
menuIndex: 0,
|
|
74
221
|
menuDismissed: false,
|
|
75
222
|
picker: null,
|
|
223
|
+
modal: null,
|
|
76
224
|
models: [],
|
|
77
225
|
balance: null,
|
|
226
|
+
scroll: 0,
|
|
227
|
+
pageSize: 10,
|
|
78
228
|
model: cfg.model || null,
|
|
79
229
|
theme: cfg.theme || "default",
|
|
80
230
|
suspended: false,
|
|
@@ -82,17 +232,20 @@ function initialState() {
|
|
|
82
232
|
};
|
|
83
233
|
}
|
|
84
234
|
|
|
85
|
-
/* ------------------------------------------------------------------ layout */
|
|
86
|
-
|
|
87
235
|
function headerLines(state, width, rows, footerCount, compact) {
|
|
88
|
-
if (state.messages.length || state.picker) return [];
|
|
89
|
-
|
|
90
|
-
|
|
236
|
+
if (state.messages.length || state.picker || state.modal) return [];
|
|
237
|
+
|
|
238
|
+
// Centraliza o bloco inteiro: as linhas tem larguras diferentes e cada uma
|
|
239
|
+
// precisa do mesmo recuo para a raposa nao sair distorcida.
|
|
240
|
+
const artLines = mascot.split("\n");
|
|
241
|
+
const artWidth = Math.max(...artLines.map((line) => line.length));
|
|
242
|
+
const artLeft = " ".repeat(Math.max(0, Math.floor((width - artWidth) / 2)));
|
|
243
|
+
const art = artLines.map((line) => artLeft + chalk.white(line));
|
|
91
244
|
const space = rows - 2 - footerCount - (compact ? 0 : 1);
|
|
92
245
|
|
|
93
246
|
if (space >= art.length + 4) {
|
|
94
247
|
return [...art, "", center(chalk.bold("O que voce quer fazer?"), width),
|
|
95
|
-
center(chalk.dim("Use / para comandos | Enter envia |
|
|
248
|
+
center(chalk.dim("Use / para comandos | Enter envia | PgUp/PgDn rola"), width), ""];
|
|
96
249
|
}
|
|
97
250
|
if (space >= art.length + 2) {
|
|
98
251
|
return [...art, "", center(chalk.bold("O que voce quer fazer?"), width), ""];
|
|
@@ -114,9 +267,8 @@ function messageLines(state, width) {
|
|
|
114
267
|
}
|
|
115
268
|
} else if (message.role === "assistant") {
|
|
116
269
|
lines.push("");
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (meta) lines.push(chalk.dim(" " + meta));
|
|
270
|
+
lines.push(...markdownLines(message.text, width - 2));
|
|
271
|
+
if (message.ms) lines.push(chalk.dim(` ${(message.ms / 1000).toFixed(1)}s`));
|
|
120
272
|
} else {
|
|
121
273
|
lines.push("");
|
|
122
274
|
const color = message.error ? chalk.red : chalk.dim;
|
|
@@ -152,31 +304,6 @@ function menuLines(state, commands, width) {
|
|
|
152
304
|
});
|
|
153
305
|
}
|
|
154
306
|
|
|
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
|
-
|
|
180
307
|
function footerLines(state, width) {
|
|
181
308
|
const name = `kafox ${state.version}`;
|
|
182
309
|
const left = chalk.dim(truncate(`${name} | ${state.model || "auto"}`, width - 16));
|
|
@@ -184,10 +311,11 @@ function footerLines(state, width) {
|
|
|
184
311
|
state.balance == null ? "saldo --" : `R$ ${(state.balance / 1_000_000).toFixed(2)}`
|
|
185
312
|
);
|
|
186
313
|
const gap = " ".repeat(Math.max(1, width - visible(left) - visible(right)));
|
|
314
|
+
const scroll = state.scroll > 0 ? `↑ ${state.scroll} linhas | ` : "";
|
|
187
315
|
return [
|
|
188
316
|
left + gap + right,
|
|
189
317
|
chalk.dim(truncate(process.cwd(), width)),
|
|
190
|
-
chalk.dim(truncate(
|
|
318
|
+
chalk.dim(truncate(`${scroll}PgUp/PgDn rola | / comandos | Ctrl+C sai`, width)),
|
|
191
319
|
];
|
|
192
320
|
}
|
|
193
321
|
|
|
@@ -197,16 +325,24 @@ function buildFrame(state, cols, rows, commands) {
|
|
|
197
325
|
const gutter = " ".repeat(Math.max(0, margin));
|
|
198
326
|
const compact = rows < 34;
|
|
199
327
|
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
?
|
|
203
|
-
|
|
328
|
+
const footerAll = footerLines(state, width);
|
|
329
|
+
const footer = compact
|
|
330
|
+
? state.scroll > 0
|
|
331
|
+
? [footerAll[0], footerAll[2]]
|
|
332
|
+
: [footerAll[0]]
|
|
333
|
+
: footerAll;
|
|
334
|
+
const panel = state.picker || state.modal ? [] : menuLines(state, commands, width);
|
|
204
335
|
const header = headerLines(state, width, rows, footer.length, compact);
|
|
205
336
|
|
|
206
337
|
const reserved = 2 + footer.length + (compact ? 0 : 1) + panel.length;
|
|
207
338
|
const available = Math.max(0, rows - header.length - reserved);
|
|
208
|
-
|
|
209
|
-
|
|
339
|
+
state.pageSize = Math.max(1, available);
|
|
340
|
+
|
|
341
|
+
const all = messageLines(state, width);
|
|
342
|
+
const maxScroll = Math.max(0, all.length - available);
|
|
343
|
+
state.scroll = clamp(state.scroll, 0, maxScroll);
|
|
344
|
+
const end = all.length - state.scroll;
|
|
345
|
+
let body = all.slice(Math.max(0, end - available), end);
|
|
210
346
|
while (body.length < available) body.unshift("");
|
|
211
347
|
|
|
212
348
|
const inputWidth = width - 2;
|
|
@@ -220,15 +356,7 @@ function buildFrame(state, cols, rows, commands) {
|
|
|
220
356
|
const rule = chalk.dim("-".repeat(width));
|
|
221
357
|
|
|
222
358
|
const middle = compact ? [] : [""];
|
|
223
|
-
const raw = [
|
|
224
|
-
...header,
|
|
225
|
-
...body,
|
|
226
|
-
...middle,
|
|
227
|
-
...panel,
|
|
228
|
-
inputLine,
|
|
229
|
-
rule,
|
|
230
|
-
...footer,
|
|
231
|
-
];
|
|
359
|
+
const raw = [...header, ...body, ...middle, ...panel, inputLine, rule, ...footer];
|
|
232
360
|
while (raw.length < rows) raw.push("");
|
|
233
361
|
const lines = raw.slice(0, rows).map((line) => gutter + padRight(line, width));
|
|
234
362
|
|
|
@@ -237,6 +365,81 @@ function buildFrame(state, cols, rows, commands) {
|
|
|
237
365
|
return { lines, cursorRow: Math.min(rows, inputRow), cursorCol };
|
|
238
366
|
}
|
|
239
367
|
|
|
368
|
+
/* -------------------------------------------------------------- modal/overlay */
|
|
369
|
+
|
|
370
|
+
function box(title, body, hint, width) {
|
|
371
|
+
const inner = width - 4;
|
|
372
|
+
const lines = [];
|
|
373
|
+
lines.push(chalk.dim("┌" + "─".repeat(width - 2) + "┐"));
|
|
374
|
+
if (title) {
|
|
375
|
+
lines.push(chalk.dim("│") + " " + chalk.bold(padRight(truncate(title, inner), inner)) + " " + chalk.dim("│"));
|
|
376
|
+
lines.push(chalk.dim("│") + " ".repeat(width - 2) + chalk.dim("│"));
|
|
377
|
+
}
|
|
378
|
+
for (const line of body) {
|
|
379
|
+
lines.push(chalk.dim("│") + " " + padRight(line, inner) + " " + chalk.dim("│"));
|
|
380
|
+
}
|
|
381
|
+
if (hint) {
|
|
382
|
+
lines.push(chalk.dim("│") + " ".repeat(width - 2) + chalk.dim("│"));
|
|
383
|
+
lines.push(chalk.dim("│") + " " + chalk.dim(padRight(hint, inner)) + " " + chalk.dim("│"));
|
|
384
|
+
}
|
|
385
|
+
lines.push(chalk.dim("└" + "─".repeat(width - 2) + "┘"));
|
|
386
|
+
return lines;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function buildOverlay(state, cols, rows) {
|
|
390
|
+
let title = "";
|
|
391
|
+
let body = [];
|
|
392
|
+
let hint = "";
|
|
393
|
+
|
|
394
|
+
if (state.picker) {
|
|
395
|
+
const picker = state.picker;
|
|
396
|
+
title = "Escolha o modelo";
|
|
397
|
+
hint = "↑/↓ navega | Enter seleciona | Esc cancela";
|
|
398
|
+
if (picker.loading) {
|
|
399
|
+
body = [chalk.dim("carregando modelos...")];
|
|
400
|
+
} else {
|
|
401
|
+
const height = 8;
|
|
402
|
+
const start = clamp(picker.index - Math.floor(height / 2), 0, Math.max(0, picker.items.length - height));
|
|
403
|
+
picker.items.slice(start, start + height).forEach((item, offset) => {
|
|
404
|
+
const real = start + offset;
|
|
405
|
+
const price = `${(item.input / 1_000_000).toFixed(2)} / ${(item.output / 1_000_000).toFixed(2)} R$`;
|
|
406
|
+
const available = 44;
|
|
407
|
+
const label = padRight(` ${real === picker.index ? ">" : " "} ${item.id}`, available);
|
|
408
|
+
const line = label + " " + chalk.dim(price);
|
|
409
|
+
body.push(real === picker.index ? chalk.inverse(padRight(line, available + price.length + 2)) : line);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
} else if (state.modal) {
|
|
413
|
+
title = state.modal.title;
|
|
414
|
+
hint = state.modal.hint || "Enter ou qualquer tecla fecha";
|
|
415
|
+
body = [];
|
|
416
|
+
} else {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const width = state.modal
|
|
421
|
+
? clamp(Math.min(90, cols - 6), 44, Math.max(44, cols - 4))
|
|
422
|
+
: clamp(66, 44, Math.max(44, cols - 4));
|
|
423
|
+
const inner = width - 4;
|
|
424
|
+
if (state.modal) body = wrap(state.modal.text || "", inner);
|
|
425
|
+
|
|
426
|
+
let content = body;
|
|
427
|
+
const maxBody = Math.max(1, rows - 8);
|
|
428
|
+
if (content.length > maxBody) {
|
|
429
|
+
content = content.slice(0, maxBody - 1);
|
|
430
|
+
content.push(chalk.dim(`... (+${body.length - content.length} linhas)`));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const boxed = box(title, content, hint, width);
|
|
434
|
+
const top = Math.max(1, Math.floor((rows - boxed.length) / 2) + 1);
|
|
435
|
+
const left = Math.max(1, Math.floor((cols - width) / 2) + 1);
|
|
436
|
+
return {
|
|
437
|
+
top,
|
|
438
|
+
left,
|
|
439
|
+
lines: boxed.map((line) => padRight(line, width)),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
240
443
|
/* --------------------------------------------------------------------- app */
|
|
241
444
|
|
|
242
445
|
async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set() } = {}) {
|
|
@@ -247,8 +450,9 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
247
450
|
let closed = false;
|
|
248
451
|
|
|
249
452
|
const write = (value) => stdout.write(value);
|
|
250
|
-
const enterScreen = () =>
|
|
251
|
-
|
|
453
|
+
const enterScreen = () =>
|
|
454
|
+
write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J\x1b[?1000h\x1b[?1006h");
|
|
455
|
+
const leaveScreen = () => write("\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l");
|
|
252
456
|
|
|
253
457
|
async function refreshAccount() {
|
|
254
458
|
const token = await ensureSession();
|
|
@@ -272,7 +476,16 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
272
476
|
const rows = Math.max(16, stdout.rows || 24);
|
|
273
477
|
const frame = buildFrame(state, cols, rows, commands);
|
|
274
478
|
write("\x1b[?25l\x1b[H" + frame.lines.join("\r\n"));
|
|
275
|
-
|
|
479
|
+
|
|
480
|
+
const overlay = buildOverlay(state, cols, rows);
|
|
481
|
+
if (overlay) {
|
|
482
|
+
overlay.lines.forEach((line, index) => {
|
|
483
|
+
write(`\x1b[${overlay.top + index};${overlay.left}H${line}`);
|
|
484
|
+
});
|
|
485
|
+
write("\x1b[?25l");
|
|
486
|
+
} else {
|
|
487
|
+
write(`\x1b[${frame.cursorRow};${frame.cursorCol}H\x1b[?25h`);
|
|
488
|
+
}
|
|
276
489
|
}
|
|
277
490
|
|
|
278
491
|
async function capture(fn) {
|
|
@@ -309,7 +522,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
309
522
|
const models = await loadModels();
|
|
310
523
|
if (!models.length) {
|
|
311
524
|
state.picker = null;
|
|
312
|
-
state.
|
|
525
|
+
state.modal = { title: "Modelos", text: "Nao consegui carregar os modelos." };
|
|
313
526
|
return render();
|
|
314
527
|
}
|
|
315
528
|
const current = models.findIndex((item) => item.id === state.model);
|
|
@@ -325,7 +538,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
325
538
|
storage.setConfig({ model: item.id });
|
|
326
539
|
state.model = item.id;
|
|
327
540
|
state.picker = null;
|
|
328
|
-
state.
|
|
541
|
+
state.modal = { title: "Modelo", text: item.id };
|
|
329
542
|
render();
|
|
330
543
|
}
|
|
331
544
|
|
|
@@ -355,30 +568,31 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
355
568
|
|
|
356
569
|
if (name === "/clear") {
|
|
357
570
|
state.messages = [];
|
|
571
|
+
state.scroll = 0;
|
|
358
572
|
return render();
|
|
359
573
|
}
|
|
360
574
|
if (name === "/compact") {
|
|
361
575
|
if (!state.messages.length) {
|
|
362
|
-
state.
|
|
363
|
-
|
|
364
|
-
const total = state.messages.length;
|
|
365
|
-
state.messages = [
|
|
366
|
-
{ role: "system", text: `Contexto compactado: ${total} mensagens resumidas.` },
|
|
367
|
-
...state.messages.slice(-6),
|
|
368
|
-
];
|
|
576
|
+
state.modal = { title: "Contexto", text: "Nada para compactar." };
|
|
577
|
+
return render();
|
|
369
578
|
}
|
|
579
|
+
const total = state.messages.length;
|
|
580
|
+
state.messages = [
|
|
581
|
+
{ role: "system", text: `Contexto compactado: ${total} mensagens resumidas.` },
|
|
582
|
+
...state.messages.slice(-6),
|
|
583
|
+
];
|
|
584
|
+
state.scroll = 0;
|
|
370
585
|
return render();
|
|
371
586
|
}
|
|
372
587
|
|
|
373
588
|
if (interactiveCommands.has(name)) return runOutside(line);
|
|
374
589
|
|
|
375
|
-
const
|
|
376
|
-
const output = (await capture(() => onCommand(line))).trim();
|
|
377
|
-
if (output) state.messages.push({ role: "system", text: output });
|
|
378
|
-
else if (state.messages.length === before) {
|
|
379
|
-
state.messages.push({ role: "system", text: `${name}: sem saida.` });
|
|
380
|
-
}
|
|
590
|
+
const output = stripAnsi(await capture(() => onCommand(line))).trim();
|
|
381
591
|
state.model = storage.getConfig().model || state.model;
|
|
592
|
+
state.modal = {
|
|
593
|
+
title: name.replace(/^\//, ""),
|
|
594
|
+
text: output || "(sem saida)",
|
|
595
|
+
};
|
|
382
596
|
render();
|
|
383
597
|
await refreshAccount();
|
|
384
598
|
render();
|
|
@@ -398,20 +612,17 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
398
612
|
|
|
399
613
|
state.messages.push({ role: "user", text: line });
|
|
400
614
|
state.busy = true;
|
|
615
|
+
state.scroll = 0;
|
|
401
616
|
render();
|
|
402
617
|
|
|
403
618
|
const result = await askModel(line, { model: state.model });
|
|
404
619
|
state.busy = false;
|
|
405
620
|
if (result.ok) {
|
|
406
|
-
state.messages.push({
|
|
407
|
-
role: "assistant",
|
|
408
|
-
text: result.text,
|
|
409
|
-
ms: result.ms,
|
|
410
|
-
provider: result.provider,
|
|
411
|
-
});
|
|
621
|
+
state.messages.push({ role: "assistant", text: result.text, ms: result.ms });
|
|
412
622
|
} else {
|
|
413
623
|
state.messages.push({ role: "system", text: result.error || "Falha ao responder.", error: true });
|
|
414
624
|
}
|
|
625
|
+
state.scroll = 0;
|
|
415
626
|
render();
|
|
416
627
|
await refreshAccount();
|
|
417
628
|
render();
|
|
@@ -429,7 +640,25 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
429
640
|
if (state.suspended) return;
|
|
430
641
|
if (key.ctrl && key.name === "c") return quit();
|
|
431
642
|
|
|
432
|
-
//
|
|
643
|
+
// Roda do mouse (SGR): 64 = cima, 65 = baixo.
|
|
644
|
+
if (str && str.startsWith("\x1b[<")) {
|
|
645
|
+
const match = str.match(/<(\d+);\d+;\d+[Mm]/);
|
|
646
|
+
if (match) {
|
|
647
|
+
if (state.modal) state.modal = null;
|
|
648
|
+
const button = Number(match[1]);
|
|
649
|
+
if (button === 64) state.scroll += 3;
|
|
650
|
+
if (button === 65) state.scroll = Math.max(0, state.scroll - 3);
|
|
651
|
+
return render();
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Modal de texto: qualquer tecla fecha.
|
|
656
|
+
if (state.modal) {
|
|
657
|
+
state.modal = null;
|
|
658
|
+
return render();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Seletor de modelo.
|
|
433
662
|
if (state.picker) {
|
|
434
663
|
if (state.picker.loading) return;
|
|
435
664
|
if (key.name === "up" || key.name === "down") {
|
|
@@ -449,6 +678,12 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
449
678
|
return;
|
|
450
679
|
}
|
|
451
680
|
|
|
681
|
+
if (key.name === "pageup" || key.name === "pagedown") {
|
|
682
|
+
const delta = key.name === "pageup" ? state.pageSize : -state.pageSize;
|
|
683
|
+
state.scroll = Math.max(0, state.scroll + delta);
|
|
684
|
+
return render();
|
|
685
|
+
}
|
|
686
|
+
|
|
452
687
|
const items = filteredCommands(state, commands);
|
|
453
688
|
const menuOpen = items.length > 0;
|
|
454
689
|
|
|
@@ -461,7 +696,6 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
461
696
|
state.input = exact;
|
|
462
697
|
state.cursor = exact.length;
|
|
463
698
|
} else if (menuOpen) {
|
|
464
|
-
// Enter executa o item destacado, nao o texto parcial digitado.
|
|
465
699
|
const selected = items[state.menuIndex % items.length][0];
|
|
466
700
|
state.input = selected;
|
|
467
701
|
state.cursor = selected.length;
|
|
@@ -470,10 +704,7 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
470
704
|
return submit();
|
|
471
705
|
}
|
|
472
706
|
if (key.name === "tab") {
|
|
473
|
-
if (menuOpen)
|
|
474
|
-
const selected = items[state.menuIndex % items.length];
|
|
475
|
-
return setInput(selected[0] + " ");
|
|
476
|
-
}
|
|
707
|
+
if (menuOpen) return setInput(items[state.menuIndex % items.length][0] + " ");
|
|
477
708
|
return;
|
|
478
709
|
}
|
|
479
710
|
if (key.name === "escape") {
|
|
@@ -568,4 +799,12 @@ async function runTUI({ commands = {}, onCommand, interactiveCommands = new Set(
|
|
|
568
799
|
return { state, quit, render, submit };
|
|
569
800
|
}
|
|
570
801
|
|
|
571
|
-
module.exports = {
|
|
802
|
+
module.exports = {
|
|
803
|
+
runTUI,
|
|
804
|
+
buildFrame,
|
|
805
|
+
buildOverlay,
|
|
806
|
+
markdownLines,
|
|
807
|
+
wrap,
|
|
808
|
+
initialState,
|
|
809
|
+
filteredCommands,
|
|
810
|
+
};
|