kafox-cli 0.10.3 → 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 +12 -2
- package/src/clipboard.js +183 -0
- package/src/sessions.js +64 -14
- package/src/tui.js +65 -2
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
|
@@ -110,6 +110,7 @@ async function handleCommand(line) {
|
|
|
110
110
|
console.log(` ${chalk.bold("Esc Esc")} ${chalk.dim("para a IA na hora (o primeiro Esc avisa)")}`);
|
|
111
111
|
console.log(` ${chalk.bold("PgUp/PgDn")} ${chalk.dim("ou a roda do mouse rola a conversa")}`);
|
|
112
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")}`);
|
|
113
114
|
console.log(` ${chalk.bold("F2")} ${chalk.dim("solta o mouse para selecionar texto com o terminal")}`);
|
|
114
115
|
console.log(` ${chalk.bold("Ctrl+C")} ${chalk.dim("sai")}`);
|
|
115
116
|
console.log("");
|
|
@@ -239,8 +240,11 @@ async function handleCommand(line) {
|
|
|
239
240
|
}
|
|
240
241
|
|
|
241
242
|
async function clearContext() {
|
|
242
|
-
|
|
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).
|
|
243
245
|
savePlainSession();
|
|
246
|
+
history = [];
|
|
247
|
+
plainSession = { id: sessions.newId(), startedAt: new Date().toISOString() };
|
|
244
248
|
console.log(chalk.green("Context cleared."));
|
|
245
249
|
}
|
|
246
250
|
|
|
@@ -438,7 +442,13 @@ async function runREPL(options = {}) {
|
|
|
438
442
|
}
|
|
439
443
|
|
|
440
444
|
return runTUI({
|
|
441
|
-
|
|
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
|
+
},
|
|
442
452
|
onCommand: handleCommand,
|
|
443
453
|
// /theme e /background rodam dentro da TUI; os demais abrem prompt no terminal.
|
|
444
454
|
interactiveCommands: new Set(["/login", "/key"]),
|
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 };
|
package/src/sessions.js
CHANGED
|
@@ -57,6 +57,23 @@ function dayMonth(value) {
|
|
|
57
57
|
return `${day}/${month}`;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A conversa tem pelo menos uma fala do usuario ou da IA?
|
|
62
|
+
*
|
|
63
|
+
* Sessao sem nenhuma fala e so um codigo reservado: gravar isso criava
|
|
64
|
+
* dezenas de "conversa sem titulo" iguais no `history`.
|
|
65
|
+
*/
|
|
66
|
+
function hasConversation(messages, context) {
|
|
67
|
+
const speaks = (item) => {
|
|
68
|
+
if (item?.role !== "user" && item?.role !== "assistant") return false;
|
|
69
|
+
if (Array.isArray(item.content)) {
|
|
70
|
+
return item.content.some((part) => part?.type === "text" && String(part.text || "").trim());
|
|
71
|
+
}
|
|
72
|
+
return Boolean(String(item?.text ?? item?.content ?? "").trim());
|
|
73
|
+
};
|
|
74
|
+
return [messages, context].some((list) => (Array.isArray(list) ? list : []).some(speaks));
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
/** Titulo curto: primeira mensagem do usuario, em uma linha. */
|
|
61
78
|
function titleFrom(messages, fallback = "") {
|
|
62
79
|
const first = (messages || []).find(
|
|
@@ -94,9 +111,13 @@ function contextFromMessages(messages) {
|
|
|
94
111
|
|
|
95
112
|
function save({ id, cwd, model, theme, allowAll, messages, context, startedAt, title }) {
|
|
96
113
|
if (!id) return null;
|
|
114
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
115
|
+
// Conversa vazia nao vira arquivo: `/clear` e sessoes abertas sem pergunta
|
|
116
|
+
// enchiam o `history` de "conversa sem titulo" iguais.
|
|
117
|
+
if (!hasConversation(list, context)) return null;
|
|
118
|
+
const existed = fs.existsSync(fileFor(id));
|
|
97
119
|
ensureDir();
|
|
98
120
|
const now = new Date().toISOString();
|
|
99
|
-
const list = Array.isArray(messages) ? messages : [];
|
|
100
121
|
const data = {
|
|
101
122
|
id,
|
|
102
123
|
title: title || titleFrom(list),
|
|
@@ -113,7 +134,8 @@ function save({ id, cwd, model, theme, allowAll, messages, context, startedAt, t
|
|
|
113
134
|
const tmp = `${fileFor(id)}.tmp`;
|
|
114
135
|
fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
115
136
|
fs.renameSync(tmp, fileFor(id));
|
|
116
|
-
|
|
137
|
+
// A poda varre e le a pasta inteira: so vale quando o arquivo e novo.
|
|
138
|
+
if (!existed) prune();
|
|
117
139
|
return data;
|
|
118
140
|
}
|
|
119
141
|
|
|
@@ -130,7 +152,8 @@ function load(id) {
|
|
|
130
152
|
}
|
|
131
153
|
}
|
|
132
154
|
|
|
133
|
-
|
|
155
|
+
/** Tudo o que esta no disco, inclusive as sessoes sem nenhuma fala. */
|
|
156
|
+
function entries() {
|
|
134
157
|
let names = [];
|
|
135
158
|
try {
|
|
136
159
|
names = fs.readdirSync(chatsDir()).filter((name) => name.endsWith(".json"));
|
|
@@ -142,17 +165,30 @@ function list() {
|
|
|
142
165
|
const id = name.replace(/\.json$/, "");
|
|
143
166
|
const data = load(id);
|
|
144
167
|
if (!data) continue;
|
|
145
|
-
chats.push({
|
|
146
|
-
id,
|
|
147
|
-
title: data.title || titleFrom(data.messages),
|
|
148
|
-
cwd: data.cwd,
|
|
149
|
-
model: data.model,
|
|
150
|
-
allowAll: Boolean(data.allowAll),
|
|
151
|
-
updatedAt: data.updatedAt,
|
|
152
|
-
messages: (data.messages || []).length,
|
|
153
|
-
});
|
|
168
|
+
chats.push({ id, data });
|
|
154
169
|
}
|
|
155
|
-
return chats
|
|
170
|
+
return chats;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function summarize({ id, data }) {
|
|
174
|
+
return {
|
|
175
|
+
id,
|
|
176
|
+
title: data.title || titleFrom(data.messages),
|
|
177
|
+
cwd: data.cwd,
|
|
178
|
+
model: data.model,
|
|
179
|
+
allowAll: Boolean(data.allowAll),
|
|
180
|
+
updatedAt: data.updatedAt,
|
|
181
|
+
messages: (data.messages || []).length,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const byNewest = (a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt));
|
|
186
|
+
|
|
187
|
+
function list() {
|
|
188
|
+
return entries()
|
|
189
|
+
.filter(({ data }) => hasConversation(data.messages, data.context))
|
|
190
|
+
.map(summarize)
|
|
191
|
+
.sort(byNewest);
|
|
156
192
|
}
|
|
157
193
|
|
|
158
194
|
function latest() {
|
|
@@ -174,11 +210,24 @@ function find(value) {
|
|
|
174
210
|
}
|
|
175
211
|
|
|
176
212
|
function prune() {
|
|
177
|
-
|
|
213
|
+
const all = entries();
|
|
214
|
+
const chats = all
|
|
215
|
+
.filter(({ data }) => hasConversation(data.messages, data.context))
|
|
216
|
+
.map(summarize)
|
|
217
|
+
.sort(byNewest);
|
|
218
|
+
for (const chat of chats.slice(MAX_SESSIONS)) {
|
|
178
219
|
try {
|
|
179
220
|
fs.unlinkSync(fileFor(chat.id));
|
|
180
221
|
} catch {}
|
|
181
222
|
}
|
|
223
|
+
// Arquivos sem nenhuma fala sao lixo de versoes antigas: nao aparecem no
|
|
224
|
+
// `history` e, fora daqui, nunca seriam podados.
|
|
225
|
+
for (const { id, data } of all) {
|
|
226
|
+
if (hasConversation(data.messages, data.context)) continue;
|
|
227
|
+
try {
|
|
228
|
+
fs.unlinkSync(fileFor(id));
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
182
231
|
}
|
|
183
232
|
|
|
184
233
|
module.exports = {
|
|
@@ -187,6 +236,7 @@ module.exports = {
|
|
|
187
236
|
chatsDir,
|
|
188
237
|
newId,
|
|
189
238
|
titleFrom,
|
|
239
|
+
hasConversation,
|
|
190
240
|
normalizeId,
|
|
191
241
|
dayMonth,
|
|
192
242
|
contextFromMessages,
|
package/src/tui.js
CHANGED
|
@@ -28,7 +28,8 @@ const sessions = require("./sessions");
|
|
|
28
28
|
const promptHistory = require("./prompt-history");
|
|
29
29
|
const wallpaper = require("./wallpaper");
|
|
30
30
|
const { listProviders } = require("./providers");
|
|
31
|
-
const { MAX_IMAGES, loadImage, humanSize } = require("./images");
|
|
31
|
+
const { MAX_IMAGES, loadImage, buildAttachment, humanSize } = require("./images");
|
|
32
|
+
const clipboard = require("./clipboard");
|
|
32
33
|
|
|
33
34
|
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
34
35
|
const stripAnsi = (value) => String(value).replace(ANSI, "");
|
|
@@ -501,6 +502,8 @@ const CONTROL_KEYS = {
|
|
|
501
502
|
12: { name: "l", ctrl: true },
|
|
502
503
|
13: { name: "return" },
|
|
503
504
|
21: { name: "u", ctrl: true },
|
|
505
|
+
// Ctrl+V: cola o que esta na area de transferencia (imagem vira anexo).
|
|
506
|
+
22: { name: "v", ctrl: true },
|
|
504
507
|
// Ctrl+R: atualiza saldo da conta e dos provedores externos agora.
|
|
505
508
|
18: { name: "r", ctrl: true },
|
|
506
509
|
// Ctrl+Q: desiste das mensagens que estao na fila (nao cancela a atual).
|
|
@@ -1478,7 +1481,9 @@ function inputBlock(state, width, { hero = false, tight = false } = {}) {
|
|
|
1478
1481
|
|
|
1479
1482
|
lines.push(edge("┌" + "─".repeat(inner) + "┐"));
|
|
1480
1483
|
|
|
1481
|
-
|
|
1484
|
+
// Na tela inicial o preview tambem aparece: colar uma imagem antes da
|
|
1485
|
+
// primeira pergunta precisa de retorno visual na caixa.
|
|
1486
|
+
const previews = hero && !(state.images || []).length ? [] : imagePreviewLines(state, inner);
|
|
1482
1487
|
for (const preview of previews) lines.push(preview);
|
|
1483
1488
|
|
|
1484
1489
|
const prompt = hero ? " " : "› ";
|
|
@@ -2321,6 +2326,46 @@ async function runTUI({
|
|
|
2321
2326
|
return render();
|
|
2322
2327
|
}
|
|
2323
2328
|
|
|
2329
|
+
// Cola o que esta na area de transferencia: imagem entra como anexo do
|
|
2330
|
+
// proximo turno (Ctrl+V ou /paste) e texto sem imagem vai para a caixa de
|
|
2331
|
+
// escrita, como um paste comum. E isso que faz `Ctrl+V` de um print
|
|
2332
|
+
// funcionar mesmo o terminal entregando so texto.
|
|
2333
|
+
async function pasteFromClipboard() {
|
|
2334
|
+
const items = state.images || (state.images = []);
|
|
2335
|
+
if (items.length >= MAX_IMAGES) {
|
|
2336
|
+
state.imageError = `limite de ${MAX_IMAGES} imagens por mensagem`;
|
|
2337
|
+
return render();
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
const shot = clipboard.readImage();
|
|
2341
|
+
if (shot.ok) {
|
|
2342
|
+
const result = buildAttachment(shot.buffer, {
|
|
2343
|
+
name: shot.mime === "image/jpeg" ? "colagem.jpg" : "colagem.png",
|
|
2344
|
+
mime: shot.mime,
|
|
2345
|
+
});
|
|
2346
|
+
if (!result.ok) {
|
|
2347
|
+
state.imageError = `colagem: ${result.error}`;
|
|
2348
|
+
return render();
|
|
2349
|
+
}
|
|
2350
|
+
items.push(result.attachment);
|
|
2351
|
+
state.imageError = null;
|
|
2352
|
+
return render();
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
const text = String(clipboard.readText() || "")
|
|
2356
|
+
.replace(/[\r\n]+/g, " ")
|
|
2357
|
+
.replace(/[\x00-\x1f\x7f]/g, "");
|
|
2358
|
+
if (text.trim()) {
|
|
2359
|
+
return setInput(
|
|
2360
|
+
state.input.slice(0, state.cursor) + text + state.input.slice(state.cursor),
|
|
2361
|
+
state.cursor + text.length
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
state.imageError = shot.error;
|
|
2366
|
+
return render();
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2324
2369
|
async function runCommand(line) {
|
|
2325
2370
|
const parts = line.trim().split(/\s+/);
|
|
2326
2371
|
const name = parts[0];
|
|
@@ -2329,6 +2374,12 @@ async function runTUI({
|
|
|
2329
2374
|
// /models e /model sem argumento abrem o mesmo seletor navegavel.
|
|
2330
2375
|
if ((name === "/model" || name === "/models") && !parts[1]) return openModelPicker();
|
|
2331
2376
|
|
|
2377
|
+
// /paste (atalho Ctrl+V) cola a imagem copiada; sem imagem, cola o texto.
|
|
2378
|
+
// Serve tambem para terminais que consomem o Ctrl+V antes da CLI.
|
|
2379
|
+
if (name === "/paste" || name === "/colar" || name === "/clipboard") {
|
|
2380
|
+
return pasteFromClipboard();
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2332
2383
|
// /image <arquivo> anexa uma imagem ao proximo turno (ate MAX_IMAGES).
|
|
2333
2384
|
// /image (sem argumento) lista o que esta anexado; /image clear limpa tudo.
|
|
2334
2385
|
if (name === "/image" || name === "/images" || name === "/anexar") {
|
|
@@ -2411,11 +2462,21 @@ async function runTUI({
|
|
|
2411
2462
|
}
|
|
2412
2463
|
|
|
2413
2464
|
if (name === "/clear") {
|
|
2465
|
+
// Limpar o contexto e comecar outra conversa, nao continuar a mesma: com
|
|
2466
|
+
// o codigo antigo tudo o que vinha depois do /clear era gravado no mesmo
|
|
2467
|
+
// arquivo, entao conversas diferentes apareciam como uma so no history.
|
|
2414
2468
|
state.messages = [];
|
|
2415
2469
|
state.context = [];
|
|
2416
2470
|
state.streaming = null;
|
|
2471
|
+
state.streamedText = "";
|
|
2417
2472
|
state.files = [];
|
|
2473
|
+
state.images = [];
|
|
2474
|
+
state.imageError = null;
|
|
2475
|
+
state.retry = null;
|
|
2476
|
+
state.tokens = 0;
|
|
2418
2477
|
state.scroll = 0;
|
|
2478
|
+
state.sessionId = sessions.newId();
|
|
2479
|
+
state.sessionStartedAt = new Date().toISOString();
|
|
2419
2480
|
return render();
|
|
2420
2481
|
}
|
|
2421
2482
|
if (name === "/compact") {
|
|
@@ -3006,6 +3067,8 @@ async function runTUI({
|
|
|
3006
3067
|
if (key.name === "f2") return toggleMouse();
|
|
3007
3068
|
if (key.ctrl && key.name === "y") return copyLastAnswer();
|
|
3008
3069
|
if (key.ctrl && key.name === "r") return refreshBalance();
|
|
3070
|
+
// Ctrl+V cola imagem (ou texto) da area de transferencia.
|
|
3071
|
+
if (key.ctrl && key.name === "v") return pasteFromClipboard();
|
|
3009
3072
|
|
|
3010
3073
|
// Modal de texto: qualquer tecla fecha — e se for texto, ele vai para o
|
|
3011
3074
|
// input em vez de ser engolido (quem digita nao perde o que escreveu).
|