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 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 mouse para selecionar texto.
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`, `/clear`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kafox-cli",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "description": "Kafox CLI: TUI com streaming, thinking, edit com diff, agent tools (files/shell), MCP e temas",
5
5
  "repository": {
6
6
  "type": "git",
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
- history = [];
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
- commands: COMMANDS,
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"]),
@@ -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
- prune();
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
- function list() {
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.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
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
- for (const chat of list().slice(MAX_SESSIONS)) {
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
- const previews = hero ? [] : imagePreviewLines(state, inner);
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).