kafox-cli 0.10.2 → 0.10.4
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 +20 -3
- package/package.json +1 -1
- package/src/cli.js +158 -23
- package/src/clipboard.js +183 -0
- package/src/images.js +341 -0
- package/src/mobile/index.js +1 -1
- package/src/prompt-history.js +64 -0
- package/src/sessions.js +88 -14
- package/src/tui.js +278 -41
package/README.md
CHANGED
|
@@ -85,6 +85,22 @@ conversa, com o texto por cima. Aceita **PNG** e **JPG**; a imagem e escurecida
|
|
|
85
85
|
para nao atrapalhar a leitura e se ajusta sozinha ao tamanho do terminal.
|
|
86
86
|
`/background off` remove, e a escolha fica em `~/.kafox/config.json`.
|
|
87
87
|
|
|
88
|
+
## Colar imagem (Ctrl+V)
|
|
89
|
+
|
|
90
|
+
Copie um print ou um recorte e aperte **Ctrl+V** na interface: a imagem entra
|
|
91
|
+
como anexo do proximo turno, com preview dentro da caixa de texto, e vai junto
|
|
92
|
+
com a mensagem para qualquer modelo com visao. Vale o mesmo limite do `/image`
|
|
93
|
+
(3 por mensagem) — clique no ✕ do preview para tirar uma.
|
|
94
|
+
|
|
95
|
+
A leitura usa o que existe na maquina: PowerShell no Windows, `pngpaste` (ou
|
|
96
|
+
`osascript`) no macOS, `wl-paste` no Wayland e `xclip` no X11. Se nenhum estiver
|
|
97
|
+
instalado, a CLI avisa e o `/image <arquivo>` continua funcionando.
|
|
98
|
+
|
|
99
|
+
Alguns terminais consomem o Ctrl+V antes da CLI ver (o paste deles e
|
|
100
|
+
`Ctrl+Shift+V`). Nesse caso use **`/paste`**, que le a area de transferencia do
|
|
101
|
+
mesmo jeito. Sem imagem copiada, o Ctrl+V/`/paste` cola o texto, como um paste
|
|
102
|
+
comum.
|
|
103
|
+
|
|
88
104
|
## Agentes e subagentes
|
|
89
105
|
|
|
90
106
|
O agente tem a ferramenta `spawn_subagent`: ele lança subagentes com objetivo
|
|
@@ -106,7 +122,8 @@ Na interface: digite `/` para o menu (↑/↓ navega, Tab completa, Enter execut
|
|
|
106
122
|
`/model` abre o seletor de modelo e `/theme` o de tema (↑/↓, clique do mouse,
|
|
107
123
|
Enter aplica, Esc cancela). A resposta chega em streaming, com bloco `Thinking`
|
|
108
124
|
quando o modelo manda o raciocinio. PgUp/PgDn ou a roda do mouse rolam a conversa;
|
|
109
|
-
Ctrl+Y copia a ultima resposta e F2 solta o
|
|
125
|
+
Ctrl+Y copia a ultima resposta, Ctrl+V cola uma imagem copiada e F2 solta o
|
|
126
|
+
mouse para selecionar texto.
|
|
110
127
|
|
|
111
128
|
Enquanto o modelo trabalha a caixa de texto mostra `trabalhando...` com os
|
|
112
129
|
pontinhos correndo, e o nome do modelo aparece fora dela, na linha de baixo.
|
|
@@ -122,8 +139,8 @@ o segundo interrompe o passo em andamento e guarda na conversa o que ja tinha
|
|
|
122
139
|
chegado.
|
|
123
140
|
|
|
124
141
|
Demais comandos: `/help`, `/models`, `/balance`, `/pricing`, `/key`, `/login`,
|
|
125
|
-
`/logout`, `/whoami`, `/mcp`, `/allow`, `/theme`, `/background`, `/
|
|
126
|
-
`/compact`, `/status`, `/exit`.
|
|
142
|
+
`/logout`, `/whoami`, `/mcp`, `/allow`, `/theme`, `/background`, `/image`,
|
|
143
|
+
`/paste`, `/clear`, `/compact`, `/status`, `/exit`.
|
|
127
144
|
|
|
128
145
|
O `/theme` traz 29 temas (default, dark, light, brutalist, neon, paper,
|
|
129
146
|
dracula, nord, gruvbox, monokai, tokyo-night, catppuccin, solarized, midnight,
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -4,8 +4,11 @@ const path = require("path");
|
|
|
4
4
|
const inquirer = require("inquirer");
|
|
5
5
|
const chalk = require("chalk");
|
|
6
6
|
const storage = require("./storage");
|
|
7
|
+
const promptHistory = require("./prompt-history");
|
|
7
8
|
const { ensureSession, fetchApi } = require("./auth");
|
|
8
|
-
const {
|
|
9
|
+
const { chat } = require("./gateway");
|
|
10
|
+
const { compactIfNeeded, trimHistory } = require("./compact");
|
|
11
|
+
const sessions = require("./sessions");
|
|
9
12
|
const { runTUI } = require("./tui");
|
|
10
13
|
const { runTheme, setContentBackground } = require("./theme");
|
|
11
14
|
const { logoText, logoLines } = require("./logo");
|
|
@@ -38,12 +41,61 @@ const COMMANDS = {
|
|
|
38
41
|
|
|
39
42
|
let history = [];
|
|
40
43
|
let modelOverride = null;
|
|
44
|
+
// Sessao do REPL sem TTY: e o que permite `kafox resume`/`kafox history`
|
|
45
|
+
// acharem uma conversa usada por pipe, script ou CI (sem passar pela TUI).
|
|
46
|
+
let plainSession = null;
|
|
41
47
|
|
|
42
48
|
function pushHistory(role, content) {
|
|
43
49
|
history.push({ role, content, at: new Date().toISOString() });
|
|
44
50
|
if (history.length > 200) history = history.slice(-200);
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Historico no formato OpenAI: e isso que vai para o provedor.
|
|
55
|
+
*
|
|
56
|
+
* Sem esse mapeamento o REPL sem TTY mandava so a linha digitada em cada
|
|
57
|
+
* requisicao, entao a IA respondia como se a conversa tivesse acabado de
|
|
58
|
+
* comecar. Mensagens seguidas do mesmo papel viram uma so (o provedor recusa
|
|
59
|
+
* duas em sequencia) e o system gerado pelo /compact e preservado.
|
|
60
|
+
*/
|
|
61
|
+
function conversationMessages(entries = history) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const role = String(entry?.role || "");
|
|
65
|
+
if (role !== "system" && role !== "user" && role !== "assistant") continue;
|
|
66
|
+
const content = String(entry?.content || "").trim();
|
|
67
|
+
if (!content) continue;
|
|
68
|
+
const last = out[out.length - 1];
|
|
69
|
+
if (last && last.role === role && role !== "system") last.content += `\n\n${content}`;
|
|
70
|
+
else out.push({ role, content });
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Conversa visivel, no formato que `sessions.save` e `kafox history` leem. */
|
|
76
|
+
function displayMessages() {
|
|
77
|
+
return history.map((entry) => ({ role: entry.role, text: entry.content }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Grava a conversa do REPL sem TTY em ~/.kafox/chats (best-effort). */
|
|
81
|
+
function savePlainSession() {
|
|
82
|
+
if (!plainSession) return;
|
|
83
|
+
try {
|
|
84
|
+
sessions.save({
|
|
85
|
+
id: plainSession.id,
|
|
86
|
+
cwd: process.cwd(),
|
|
87
|
+
model: modelOverride || storage.getConfig().model,
|
|
88
|
+
theme: storage.getConfig().theme,
|
|
89
|
+
allowAll: false,
|
|
90
|
+
messages: displayMessages(),
|
|
91
|
+
context: conversationMessages(),
|
|
92
|
+
startedAt: plainSession.startedAt,
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
// Persistencia e conveniencia: falha de disco nao derruba o chat.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
47
99
|
async function handleCommand(line) {
|
|
48
100
|
const [cmd, ...rest] = line.trim().split(/\s+/);
|
|
49
101
|
const arg = rest.join(" ");
|
|
@@ -58,6 +110,7 @@ async function handleCommand(line) {
|
|
|
58
110
|
console.log(` ${chalk.bold("Esc Esc")} ${chalk.dim("para a IA na hora (o primeiro Esc avisa)")}`);
|
|
59
111
|
console.log(` ${chalk.bold("PgUp/PgDn")} ${chalk.dim("ou a roda do mouse rola a conversa")}`);
|
|
60
112
|
console.log(` ${chalk.bold("Ctrl+Y")} ${chalk.dim("copia a ultima resposta para a area de transferencia")}`);
|
|
113
|
+
console.log(` ${chalk.bold("Ctrl+V")} ${chalk.dim("cola uma imagem copiada (ou o texto) no proximo turno")}`);
|
|
61
114
|
console.log(` ${chalk.bold("F2")} ${chalk.dim("solta o mouse para selecionar texto com o terminal")}`);
|
|
62
115
|
console.log(` ${chalk.bold("Ctrl+C")} ${chalk.dim("sai")}`);
|
|
63
116
|
console.log("");
|
|
@@ -178,6 +231,7 @@ async function handleCommand(line) {
|
|
|
178
231
|
return true;
|
|
179
232
|
case "/exit":
|
|
180
233
|
case "/quit":
|
|
234
|
+
savePlainSession();
|
|
181
235
|
process.exit(0);
|
|
182
236
|
default:
|
|
183
237
|
console.log(chalk.dim(`Unknown command: ${cmd}. Type /help`));
|
|
@@ -186,25 +240,39 @@ async function handleCommand(line) {
|
|
|
186
240
|
}
|
|
187
241
|
|
|
188
242
|
async function clearContext() {
|
|
243
|
+
// Guarda a conversa atual e comeca outra: manter o mesmo codigo fazia o
|
|
244
|
+
// que viesse depois do /clear cair no chat antigo (e ser sobrescrito por ele).
|
|
245
|
+
savePlainSession();
|
|
189
246
|
history = [];
|
|
247
|
+
plainSession = { id: sessions.newId(), startedAt: new Date().toISOString() };
|
|
190
248
|
console.log(chalk.green("Context cleared."));
|
|
191
249
|
}
|
|
192
250
|
|
|
193
251
|
async function compact() {
|
|
194
|
-
|
|
252
|
+
const messages = conversationMessages();
|
|
253
|
+
if (!messages.length) {
|
|
195
254
|
console.log(chalk.dim("Nothing to compact."));
|
|
196
255
|
return;
|
|
197
256
|
}
|
|
198
|
-
const turns =
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
257
|
+
const turns = messages.length;
|
|
258
|
+
// Mesmo resumo estruturado da TUI: o texto antigo vira nota de contexto em
|
|
259
|
+
// vez de sumir (o comportamento antigo so guardava um aviso vazio).
|
|
260
|
+
const result = await compactIfNeeded(messages, {
|
|
261
|
+
model: modelOverride || storage.getConfig().model,
|
|
262
|
+
// 0 forca o resumo: quem digitou /compact quer compactar agora.
|
|
263
|
+
thresholdTokens: 0,
|
|
264
|
+
});
|
|
265
|
+
if (!result.compacted) {
|
|
266
|
+
console.log(chalk.yellow(`Nothing to compact: ${result.error || "resumo vazio"}`));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const at = new Date().toISOString();
|
|
270
|
+
history = result.messages.map((message) => ({
|
|
271
|
+
role: message.role,
|
|
272
|
+
content: String(message.content || ""),
|
|
273
|
+
at,
|
|
274
|
+
}));
|
|
275
|
+
savePlainSession();
|
|
208
276
|
console.log(
|
|
209
277
|
chalk.green(`Context compacted: ${turns} -> ${history.length} messages (auto summary).`)
|
|
210
278
|
);
|
|
@@ -234,13 +302,40 @@ async function showStatus() {
|
|
|
234
302
|
}
|
|
235
303
|
|
|
236
304
|
/** Modo simples: sem TTY (scripts, pipes) ou apos o login. */
|
|
237
|
-
async function runPlainREPL() {
|
|
305
|
+
async function runPlainREPL(options = {}) {
|
|
238
306
|
const cfg = storage.getConfig();
|
|
239
307
|
const theme = cfg.theme || "default";
|
|
308
|
+
history = [];
|
|
309
|
+
plainSession = { id: sessions.newId(), startedAt: new Date().toISOString() };
|
|
310
|
+
|
|
311
|
+
// `kafox resume <codigo>` tambem vale sem TTY: antes o pipe/script abria
|
|
312
|
+
// sempre uma conversa vazia, mesmo com a sessao salva no disco.
|
|
313
|
+
if (options.resume) {
|
|
314
|
+
const wanted = options.resume === "pick" || options.resume === true ? "last" : options.resume;
|
|
315
|
+
const saved = String(wanted) === "last" ? sessions.latest() : sessions.find(wanted);
|
|
316
|
+
if (saved) {
|
|
317
|
+
plainSession = { id: saved.id, startedAt: saved.startedAt || plainSession.startedAt };
|
|
318
|
+
const restored =
|
|
319
|
+
Array.isArray(saved.context) && saved.context.length
|
|
320
|
+
? saved.context
|
|
321
|
+
: sessions.contextFromMessages(saved.messages);
|
|
322
|
+
const at = new Date().toISOString();
|
|
323
|
+
history = restored
|
|
324
|
+
.filter((message) => ["system", "user", "assistant"].includes(message?.role))
|
|
325
|
+
.map((message) => ({ role: message.role, content: String(message.content || ""), at }));
|
|
326
|
+
} else {
|
|
327
|
+
console.log(chalk.yellow(` Conversa ${wanted} nao encontrada - comecando uma nova.`));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
240
330
|
|
|
241
331
|
console.log(logoLines(theme).join("\n"));
|
|
242
332
|
console.log(chalk.bold(`\n Kafox CLI v${VERSION}`));
|
|
243
333
|
console.log(chalk.dim(" Type /help for commands. Ctrl+C to exit.\n"));
|
|
334
|
+
if (history.length) {
|
|
335
|
+
console.log(
|
|
336
|
+
chalk.dim(` Retomando ${plainSession.id} (${conversationMessages().length} mensagens no contexto).`)
|
|
337
|
+
);
|
|
338
|
+
}
|
|
244
339
|
|
|
245
340
|
const token = await ensureSession().catch(() => null);
|
|
246
341
|
if (!token) {
|
|
@@ -268,35 +363,68 @@ async function runPlainREPL() {
|
|
|
268
363
|
input: process.stdin,
|
|
269
364
|
output: process.stdout,
|
|
270
365
|
prompt: chalk.cyan("kafox> "),
|
|
366
|
+
// O readline guarda o mais recente no começo da lista.
|
|
367
|
+
history: promptHistory.load().slice().reverse(),
|
|
368
|
+
historySize: promptHistory.MAX,
|
|
369
|
+
removeHistoryDuplicates: true,
|
|
271
370
|
});
|
|
272
371
|
|
|
273
|
-
|
|
274
|
-
rl.on("line", async (raw) => {
|
|
372
|
+
async function handleLine(raw) {
|
|
275
373
|
const line = raw.trim();
|
|
276
|
-
if (!line) return
|
|
374
|
+
if (!line) return;
|
|
277
375
|
try {
|
|
278
376
|
if (line.startsWith("/")) {
|
|
279
377
|
await handleCommand(line);
|
|
280
378
|
} else {
|
|
281
379
|
pushHistory("user", line);
|
|
282
|
-
|
|
283
|
-
|
|
380
|
+
promptHistory.push(line);
|
|
381
|
+
// O historico inteiro (nao so a ultima linha) e o que faz a IA lembrar
|
|
382
|
+
// do que ja foi dito nesta conversa.
|
|
383
|
+
const result = await chat({
|
|
384
|
+
messages: trimHistory(conversationMessages()),
|
|
385
|
+
model: modelOverride || storage.getConfig().model,
|
|
386
|
+
});
|
|
387
|
+
if (!result.ok) {
|
|
388
|
+
console.log(chalk.red(`\n${result.error}`));
|
|
389
|
+
} else {
|
|
390
|
+
const answer = String(result.text || "").trim() || "(resposta vazia)";
|
|
391
|
+
console.log(chalk.green("\nAnswer:"));
|
|
392
|
+
console.log(answer);
|
|
393
|
+
pushHistory("assistant", answer);
|
|
394
|
+
}
|
|
395
|
+
savePlainSession();
|
|
284
396
|
}
|
|
285
397
|
} catch (err) {
|
|
286
398
|
console.log(chalk.red(`\nError: ${err.message}`));
|
|
287
399
|
}
|
|
288
|
-
rl.prompt();
|
|
400
|
+
if (!rl.closed) rl.prompt();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Num pipe o stdin chega inteiro de uma vez: processar as linhas em paralelo
|
|
404
|
+
// embaralhava o historico (a segunda nao via a resposta da primeira).
|
|
405
|
+
let pending = Promise.resolve();
|
|
406
|
+
rl.prompt();
|
|
407
|
+
rl.on("line", (raw) => {
|
|
408
|
+
pending = pending.then(() => handleLine(raw));
|
|
409
|
+
return pending;
|
|
289
410
|
});
|
|
290
411
|
|
|
291
|
-
rl.on("close", () => {
|
|
292
|
-
|
|
412
|
+
rl.on("close", async () => {
|
|
413
|
+
// EOF do pipe nao pode cortar a ultima resposta no meio: antes o processo
|
|
414
|
+
// saia antes do POST /api/chat terminar e o chat ficava sem resposta.
|
|
415
|
+
await pending.catch(() => {});
|
|
416
|
+
savePlainSession();
|
|
417
|
+
if (plainSession && conversationMessages().length) {
|
|
418
|
+
console.log(chalk.dim(`\nConversa salva: kafox resume ${plainSession.id}`));
|
|
419
|
+
}
|
|
420
|
+
console.log(chalk.dim("See you."));
|
|
293
421
|
process.exit(0);
|
|
294
422
|
});
|
|
295
423
|
}
|
|
296
424
|
|
|
297
425
|
async function runREPL(options = {}) {
|
|
298
426
|
// Sem TTY (pipe/CI), mantem o REPL linha a linha.
|
|
299
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) return runPlainREPL();
|
|
427
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return runPlainREPL(options);
|
|
300
428
|
|
|
301
429
|
const cfg = storage.getConfig();
|
|
302
430
|
const token = await ensureSession().catch(() => null);
|
|
@@ -314,7 +442,13 @@ async function runREPL(options = {}) {
|
|
|
314
442
|
}
|
|
315
443
|
|
|
316
444
|
return runTUI({
|
|
317
|
-
|
|
445
|
+
// /image e /paste so valem na interface: e ela que desenha o preview e
|
|
446
|
+
// manda a imagem junto da mensagem. O REPL sem TTY continua so texto.
|
|
447
|
+
commands: {
|
|
448
|
+
...COMMANDS,
|
|
449
|
+
"/image": "anexa imagem ao proximo turno (/image <arquivo>; clear limpa)",
|
|
450
|
+
"/paste": "cola a imagem da area de transferencia (atalho: Ctrl+V)",
|
|
451
|
+
},
|
|
318
452
|
onCommand: handleCommand,
|
|
319
453
|
// /theme e /background rodam dentro da TUI; os demais abrem prompt no terminal.
|
|
320
454
|
interactiveCommands: new Set(["/login", "/key"]),
|
|
@@ -332,5 +466,6 @@ module.exports = {
|
|
|
332
466
|
clearContext,
|
|
333
467
|
showStatus,
|
|
334
468
|
handleCommand,
|
|
469
|
+
conversationMessages,
|
|
335
470
|
COMMANDS,
|
|
336
471
|
};
|
package/src/clipboard.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Area de transferencia: colar imagem no chat.
|
|
3
|
+
*
|
|
4
|
+
* O Ctrl+V do terminal so entrega texto. A imagem que voce copiou de um print
|
|
5
|
+
* (Win+Shift+S no Windows, Ctrl+Shift+Cmd+4 no macOS, "Copiar imagem" no Linux)
|
|
6
|
+
* nao passa por esse caminho, entao a CLI le a area de transferencia do sistema
|
|
7
|
+
* pelos utilitarios de sempre:
|
|
8
|
+
*
|
|
9
|
+
* - Windows: PowerShell (System.Windows.Forms.Clipboard)
|
|
10
|
+
* - macOS: `pngpaste` se estiver instalado, senao `osascript` com «class PNGf»
|
|
11
|
+
* - Linux: `wl-paste` (Wayland) e `xclip` (X11)
|
|
12
|
+
*
|
|
13
|
+
* Nada aqui lanca: sem utilitario instalado, a TUI recebe `{ ok: false }` com
|
|
14
|
+
* uma mensagem que diz o que instalar, e o `/image <arquivo>` continua valendo.
|
|
15
|
+
*/
|
|
16
|
+
const { spawnSync } = require("child_process");
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const os = require("os");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const { sniffMime } = require("./images");
|
|
21
|
+
|
|
22
|
+
// Ordem de preferencia: PNG e o formato que todos os provedores aceitam e o
|
|
23
|
+
// unico que o preview desenha sem depender de dependencia opcional.
|
|
24
|
+
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
25
|
+
|
|
26
|
+
function run(command, args, options = {}) {
|
|
27
|
+
try {
|
|
28
|
+
const result = spawnSync(command, args, {
|
|
29
|
+
timeout: options.timeout || 6000,
|
|
30
|
+
windowsHide: true,
|
|
31
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
32
|
+
input: options.input,
|
|
33
|
+
});
|
|
34
|
+
if (result.error) return { ok: false, reason: result.error.code || result.error.message };
|
|
35
|
+
if (result.status !== 0) return { ok: false, reason: `exit ${result.status ?? "?"}` };
|
|
36
|
+
const buffer = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout || "");
|
|
37
|
+
if (!buffer.length) return { ok: false, reason: "vazio" };
|
|
38
|
+
return { ok: true, buffer };
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return { ok: false, reason: error.message };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Escolhe o tipo de imagem na lista que o gerenciador de clipboard publica
|
|
46
|
+
* (`xclip -t TARGETS -o`, `wl-paste --list-types`). Uma imagem e anunciada por
|
|
47
|
+
* varios tipos; PNG ganha dos outros para a colagem sair igual em todo modelo.
|
|
48
|
+
*/
|
|
49
|
+
function pickMime(list) {
|
|
50
|
+
const text = String(list || "");
|
|
51
|
+
for (const mime of IMAGE_TYPES) {
|
|
52
|
+
if (text.includes(mime)) return mime;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fromWayland() {
|
|
58
|
+
const types = run("wl-paste", ["--list-types"]);
|
|
59
|
+
if (!types.ok) return null;
|
|
60
|
+
const mime = pickMime(types.buffer.toString("utf8"));
|
|
61
|
+
if (!mime) return null;
|
|
62
|
+
const data = run("wl-paste", ["--no-newline", "--type", mime]);
|
|
63
|
+
if (!data.ok) return null;
|
|
64
|
+
return { buffer: data.buffer, mime };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fromX11() {
|
|
68
|
+
const targets = run("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"]);
|
|
69
|
+
if (!targets.ok) return null;
|
|
70
|
+
const mime = pickMime(targets.buffer.toString("utf8"));
|
|
71
|
+
if (!mime) return null;
|
|
72
|
+
const data = run("xclip", ["-selection", "clipboard", "-t", mime, "-o"]);
|
|
73
|
+
if (!data.ok) return null;
|
|
74
|
+
return { buffer: data.buffer, mime };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fromMac() {
|
|
78
|
+
const png = run("pngpaste", ["-"]);
|
|
79
|
+
if (png.ok && sniffMime(png.buffer)) return { buffer: png.buffer, mime: sniffMime(png.buffer) };
|
|
80
|
+
|
|
81
|
+
const file = path.join(os.tmpdir(), `kafox-paste-${process.pid}-${Date.now()}.png`);
|
|
82
|
+
const script = [
|
|
83
|
+
"set pngData to the clipboard as «class PNGf»",
|
|
84
|
+
`set f to open for access POSIX file "${file}" with write permission`,
|
|
85
|
+
"set eof f to 0",
|
|
86
|
+
"write pngData to f",
|
|
87
|
+
"close access f",
|
|
88
|
+
];
|
|
89
|
+
const args = [];
|
|
90
|
+
for (const line of script) args.push("-e", line);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
if (!run("osascript", args).ok) return null;
|
|
94
|
+
const buffer = fs.readFileSync(file);
|
|
95
|
+
return buffer.length ? { buffer, mime: sniffMime(buffer) || "image/png" } : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
} finally {
|
|
99
|
+
try {
|
|
100
|
+
fs.unlinkSync(file);
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fromWindows() {
|
|
106
|
+
const file = path.join(os.tmpdir(), `kafox-paste-${process.pid}-${Date.now()}.png`);
|
|
107
|
+
const script =
|
|
108
|
+
"Add-Type -AssemblyName System.Windows.Forms;" +
|
|
109
|
+
"Add-Type -AssemblyName System.Drawing;" +
|
|
110
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage();" +
|
|
111
|
+
"if ($img -eq $null) { exit 3 };" +
|
|
112
|
+
`$img.Save('${file.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png);`;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
if (!run("powershell", ["-NoProfile", "-NonInteractive", "-STA", "-Command", script]).ok) return null;
|
|
116
|
+
const buffer = fs.readFileSync(file);
|
|
117
|
+
return buffer.length ? { buffer, mime: sniffMime(buffer) || "image/png" } : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
} finally {
|
|
121
|
+
try {
|
|
122
|
+
fs.unlinkSync(file);
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function missingImageHint() {
|
|
128
|
+
if (process.platform === "darwin") {
|
|
129
|
+
return "nenhuma imagem copiada (copie um print com Ctrl+Shift+Cmd+4, ou use /image <arquivo>)";
|
|
130
|
+
}
|
|
131
|
+
if (process.platform === "win32") {
|
|
132
|
+
return "nenhuma imagem copiada (copie com Win+Shift+S, ou use /image <arquivo>)";
|
|
133
|
+
}
|
|
134
|
+
return "nenhuma imagem copiada (no Linux precisa de wl-clipboard ou xclip; /image <arquivo> sempre funciona)";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Le a imagem da area de transferencia.
|
|
139
|
+
* Retorna `{ ok: true, buffer, mime }` ou `{ ok: false, error }`.
|
|
140
|
+
*/
|
|
141
|
+
function readImage() {
|
|
142
|
+
const attempts =
|
|
143
|
+
process.platform === "win32"
|
|
144
|
+
? [fromWindows]
|
|
145
|
+
: process.platform === "darwin"
|
|
146
|
+
? [fromMac]
|
|
147
|
+
: [fromWayland, fromX11];
|
|
148
|
+
|
|
149
|
+
for (const attempt of attempts) {
|
|
150
|
+
let found = null;
|
|
151
|
+
try {
|
|
152
|
+
found = attempt();
|
|
153
|
+
} catch {
|
|
154
|
+
found = null;
|
|
155
|
+
}
|
|
156
|
+
if (found?.buffer?.length) {
|
|
157
|
+
return { ok: true, buffer: found.buffer, mime: sniffMime(found.buffer) || found.mime };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { ok: false, error: missingImageHint() };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Le o texto da area de transferencia (fallback do Ctrl+V sem imagem). */
|
|
165
|
+
function readText() {
|
|
166
|
+
const attempts =
|
|
167
|
+
process.platform === "win32"
|
|
168
|
+
? [["powershell", ["-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"]]]
|
|
169
|
+
: process.platform === "darwin"
|
|
170
|
+
? [["pbpaste", []]]
|
|
171
|
+
: [
|
|
172
|
+
["wl-paste", ["--no-newline"]],
|
|
173
|
+
["xclip", ["-selection", "clipboard", "-o"]],
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
for (const [command, args] of attempts) {
|
|
177
|
+
const result = run(command, args);
|
|
178
|
+
if (result.ok) return result.buffer.toString("utf8");
|
|
179
|
+
}
|
|
180
|
+
return "";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { IMAGE_TYPES, pickMime, readImage, readText };
|