primocode 7.12.2 → 7.14.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/bin/primocode.js CHANGED
@@ -78,6 +78,53 @@ ${c.bold('Onde ele trabalha')}
78
78
  ${c.dim('Pasta de projeto nova só nasce quando ele vai mexer em arquivo.')}
79
79
  `;
80
80
 
81
+ // ── Autopreenchimento dos comandos (estilo Claude Code) ────────────────
82
+ // Metade do comando já serve: Tab completa, e um prefixo único executa
83
+ // direto — "/str" vira "/streaming" sozinho. A lista é só a dos nomes
84
+ // canônicos; os apelidos (/cd, /stream…) continuam valendo por extenso.
85
+ const SLASH_COMANDOS = [
86
+ ['/help', 'todos os comandos'],
87
+ ['/project', 'lista/cria projetos em ~/primocode'],
88
+ ['/dir', 'trabalhar numa pasta sua'],
89
+ ['/mode', 'primocode | chat | auto'],
90
+ ['/model', 'top | main | fast'],
91
+ ['/attach', 'anexa arquivo ao pedido'],
92
+ ['/detach', 'remove os anexos'],
93
+ ['/files', 'lista os anexos'],
94
+ ['/tudo', 'tudo que ele sabe fazer'],
95
+ ['/tree', 'árvore do projeto'],
96
+ ['/read', 'mostra um arquivo'],
97
+ ['/run', 'roda um comando'],
98
+ ['/open', 'abre no navegador'],
99
+ ['/streaming', 'painel ao vivo — vídeo dele trabalhando'],
100
+ ['/status', 'checa o servidor'],
101
+ ['/name', 'como ele te chama'],
102
+ ['/mascot', 'primo | fox | cat'],
103
+ ['/config', 'suas preferências'],
104
+ ['/clear', 'limpa a conversa'],
105
+ ['/cls', 'limpa a tela'],
106
+ ['/uninstall', 'remove o PrimoCode'],
107
+ ['/version', 'versão'],
108
+ ['/exit', 'sair'],
109
+ ];
110
+
111
+ // O completer do readline: Tab completa o que der. Só age na palavra do
112
+ // comando — depois do primeiro espaço o argumento é seu, não dele.
113
+ function completarSlash(line) {
114
+ if (!line.startsWith('/') || /\s/.test(line)) return [[], line];
115
+ const baixa = line.toLowerCase();
116
+ const acertos = SLASH_COMANDOS.map(([nome]) => nome).filter((nome) => nome.startsWith(baixa));
117
+ return [acertos, line];
118
+ }
119
+
120
+ function mostrarMenuSlash() {
121
+ console.log('');
122
+ for (const [nome, desc] of SLASH_COMANDOS) {
123
+ console.log(` ${c.brand(ui.padRight(nome, 12))}${c.muted(desc)}`);
124
+ }
125
+ console.log(c.dim('\n Tab completa · metade do comando já serve: /str vira /streaming'));
126
+ }
127
+
81
128
  let rl = null;
82
129
 
83
130
  function makePrompt() {
@@ -605,7 +652,26 @@ async function handleSlash(text) {
605
652
  // rl.close() em vez de process.exit(): sair na marra descarta o que
606
653
  // ainda estava no buffer de saída.
607
654
  case 'exit': case 'quit': if (rl) rl.close(); break;
608
- default: console.log(c.err(`Comando desconhecido: /${cmd}. Digite /help.`));
655
+ default: {
656
+ // "/" sozinho abre o menu; um prefixo único autopreenche e executa
657
+ // ("/str" → /streaming); prefixo ambíguo mostra as opções.
658
+ if (!cmd) { mostrarMenuSlash(); break; }
659
+ const casam = SLASH_COMANDOS.map(([nome]) => nome.slice(1)).filter((n) => n.startsWith(cmd.toLowerCase()));
660
+ if (casam.length === 1) {
661
+ console.log(c.dim(` → /${casam[0]}`));
662
+ await handleSlash('/' + casam[0] + (rest ? ' ' + rest : ''));
663
+ break;
664
+ }
665
+ if (casam.length > 1) {
666
+ console.log(c.muted(` /${cmd}… pode ser:`));
667
+ for (const n of casam) {
668
+ const [, desc] = SLASH_COMANDOS.find(([nome]) => nome === '/' + n);
669
+ console.log(` ${c.brand(ui.padRight('/' + n, 12))}${c.dim(desc)}`);
670
+ }
671
+ break;
672
+ }
673
+ console.log(c.err(`Comando desconhecido: /${cmd}. Digite / para ver o menu.`));
674
+ }
609
675
  }
610
676
  }
611
677
 
@@ -682,7 +748,7 @@ async function main() {
682
748
  userName: state.config.userName,
683
749
  pastaPadrao: state.workDirOrigem === 'padrao',
684
750
  }));
685
- rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: makePrompt() });
751
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: makePrompt(), completer: completarSlash });
686
752
  rl.prompt();
687
753
 
688
754
  // Fila: o readline continua emitindo 'line' enquanto um pedido está sendo
package/lib/act.js CHANGED
@@ -185,6 +185,11 @@ async function runAct(userPrompt, opts) {
185
185
 
186
186
  let finalText = '';
187
187
 
188
+ // O pedido do usuário aparece na barra de comando do painel, digitado
189
+ // letra a letra — só o pedido principal; sub-tarefa de agente não é
190
+ // instrução de gente.
191
+ if (!subagent) { try { painel.instrucao(userPrompt); } catch {} }
192
+
188
193
  for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
189
194
  const sp = new Spinner(subagent ? 'subagente trabalhando' : 'Primo Code pensando').start();
190
195
 
package/lib/desktop.js CHANGED
@@ -659,25 +659,40 @@ function clickElement(nome, opts = {}) {
659
659
  }
660
660
 
661
661
  // ── Screenshot ────────────────────────────────────────
662
- function screenshot(saveTo) {
663
- const filename = saveTo || `desktop-${Date.now()}.png`;
664
- const done = (r) => (r.ok ? { ok: true, path: filename, bytes: sizeOf(filename) } : r);
662
+ // A ferramenta do Linux é resolvida UMA vez: o vídeo do /streaming chama isto
663
+ // várias vezes por segundo, e um `which` por frame seria desperdício.
664
+ let LINUX_SHOT;
665
+ function linuxShot() {
666
+ if (LINUX_SHOT === undefined) LINUX_SHOT = firstCmd(['scrot', 'maim', 'gnome-screenshot', 'spectacle', 'import']);
667
+ return LINUX_SHOT;
668
+ }
665
669
 
666
- if (PLATFORM === 'darwin') {
667
- return done(run(`screencapture -x "${filename}"`));
668
- }
670
+ /**
671
+ * O comando de shell que fotografa a tela para `filename` — ou null quando a
672
+ * plataforma não tem como. Existe separado do screenshot() para o painel do
673
+ * /streaming capturar em LOOP, assíncrono, sem travar o agente.
674
+ */
675
+ function screenshotCommand(filename) {
676
+ if (PLATFORM === 'darwin') return `screencapture -x "${filename}"`;
669
677
  if (PLATFORM === 'win32') {
670
678
  const ps = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bmp=New-Object System.Drawing.Bitmap($b.Width,$b.Height); $g=[System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$bmp.Size); $bmp.Save('${filename.replace(/'/g, "''")}'); $g.Dispose(); $bmp.Dispose()`;
671
- return done(pwshRun(ps));
679
+ return pwshCmd(ps);
672
680
  }
673
- // Linux: tenta as ferramentas comuns, na ordem.
674
- const scrot = firstCmd(['scrot', 'gnome-screenshot', 'spectacle', 'import', 'maim']);
675
- if (scrot === 'scrot') return done(run(`scrot -o "${filename}"`));
676
- if (scrot === 'maim') return done(run(`maim "${filename}"`));
677
- if (scrot === 'gnome-screenshot') return done(run(`gnome-screenshot -f "${filename}"`));
678
- if (scrot === 'spectacle') return done(run(`spectacle -b -n -o "${filename}"`));
679
- if (scrot === 'import') return done(run(`import -window root "${filename}"`)); // ImageMagick
680
- return { ok: false, error: 'Screenshot indisponível no Linux. Instale scrot, gnome-screenshot, spectacle, maim ou ImageMagick (import).' };
681
+ const tool = linuxShot();
682
+ if (tool === 'scrot') return `scrot -o "${filename}"`;
683
+ if (tool === 'maim') return `maim "${filename}"`;
684
+ if (tool === 'gnome-screenshot') return `gnome-screenshot -f "${filename}"`;
685
+ if (tool === 'spectacle') return `spectacle -b -n -o "${filename}"`;
686
+ if (tool === 'import') return `import -window root "${filename}"`; // ImageMagick
687
+ return null;
688
+ }
689
+
690
+ function screenshot(saveTo) {
691
+ const filename = saveTo || `desktop-${Date.now()}.png`;
692
+ const cmd = screenshotCommand(filename);
693
+ if (!cmd) return { ok: false, error: 'Screenshot indisponível no Linux. Instale scrot, maim, gnome-screenshot, spectacle ou ImageMagick (import).' };
694
+ const r = run(cmd);
695
+ return r.ok ? { ok: true, path: filename, bytes: sizeOf(filename) } : r;
681
696
  }
682
697
 
683
698
  function sizeOf(f) {
@@ -719,7 +734,7 @@ module.exports = {
719
734
  click, doubleClick, move, drag, scroll,
720
735
  type, key,
721
736
  openApp, listWindows, focusWindow, uiElements, clickElement,
722
- screenshot, getScreenSize,
737
+ screenshot, screenshotCommand, getScreenSize,
723
738
  TOOLS,
724
739
  // Puras, exportadas para o test/desktop.test.js provar a tradução de teclas
725
740
  // e a escolha de elemento sem depender do SO da máquina de teste.
package/lib/streaming.js CHANGED
@@ -19,6 +19,7 @@ const http = require('http');
19
19
  const fs = require('fs');
20
20
  const path = require('path');
21
21
  const os = require('os');
22
+ const { exec } = require('child_process');
22
23
  const desktop = require('./desktop');
23
24
 
24
25
  const DIR = path.join(os.homedir(), '.primocode', 'streaming');
@@ -40,11 +41,65 @@ const state = {
40
41
  abas: [], // [{ id, nome, passos: 0 }] — "Ao vivo" é virtual, não entra aqui
41
42
  abaAtual: null,
42
43
  log: [],
44
+ instrucoes: [], // o que o usuário pediu — a página autopreenche digitando
43
45
  erroFoto: null,
44
46
  ultimaFoto: 0,
45
47
  passo: 0,
48
+ // ── vídeo ao vivo ──
49
+ liveSeq: 0, // sobe a cada frame novo; a página troca a imagem quando muda
50
+ liveAt: 0, // quando o último frame de vídeo foi capturado
51
+ viewerAt: 0, // último sinal de que alguém está com a página aberta
52
+ liveTimer: null,
46
53
  };
47
54
 
55
+ const LIVE = () => path.join(DIR, 'live.png');
56
+ const LIVE_TMP = () => path.join(DIR, 'live-tmp.png');
57
+
58
+ // ── O vídeo: um loop de captura enquanto alguém assiste ──────────────────
59
+ // "Vídeo" aqui é o jeito honesto de fazer sem dependência: fotografar a tela
60
+ // em sequência (2 a 3 quadros por segundo, conforme a máquina aguenta) e a
61
+ // página trocar a imagem no ritmo — como um VNC simples. O loop é assíncrono
62
+ // (exec, nunca execSync) para não roubar tempo do agente, escreve num tmp e
63
+ // renomeia (troca atômica: o navegador nunca lê um PNG pela metade), e SÓ
64
+ // roda enquanto a página deu sinal de vida há menos de 8s — painel fechado
65
+ // não custa nada.
66
+ function viuAgora() {
67
+ state.viewerAt = Date.now();
68
+ agendarVideo(0);
69
+ }
70
+
71
+ function agendarVideo(ms) {
72
+ if (!state.on || state.liveTimer) return;
73
+ state.liveTimer = setTimeout(cicloVideo, ms);
74
+ state.liveTimer.unref();
75
+ }
76
+
77
+ function cicloVideo() {
78
+ state.liveTimer = null;
79
+ if (!state.on) return;
80
+ if (Date.now() - state.viewerAt > 8000) return; // ninguém olhando — o próximo /state religa
81
+
82
+ const cmd = desktop.screenshotCommand(LIVE_TMP());
83
+ if (!cmd) { if (!state.erroFoto) state.erroFoto = 'screenshot indisponível nesta máquina'; return; }
84
+
85
+ const t0 = Date.now();
86
+ exec(cmd, { timeout: 8000 }, (err) => {
87
+ const dur = Date.now() - t0;
88
+ if (!err) {
89
+ try {
90
+ fs.renameSync(LIVE_TMP(), LIVE());
91
+ state.liveSeq++;
92
+ state.liveAt = Date.now();
93
+ state.erroFoto = null;
94
+ } catch {}
95
+ } else if (!state.erroFoto) {
96
+ state.erroFoto = corta(String(err.message || err), 120);
97
+ }
98
+ // Máquina rápida ≈ 3 quadros/s; máquina lenta desacelera sozinha.
99
+ agendarVideo(Math.max(280, Math.min(1200, dur)));
100
+ });
101
+ }
102
+
48
103
  // Ações cujo efeito aparece NA TELA — são as que merecem uma foto nova.
49
104
  const ACOES_NA_TELA = new Set([
50
105
  'desktop_click', 'desktop_double_click', 'desktop_click_element',
@@ -123,6 +178,22 @@ function entrarNaAba(nome) {
123
178
  state.abaAtual = aba;
124
179
  }
125
180
 
181
+ /**
182
+ * A instrução que o usuário acabou de dar. A página autopreenche na barra de
183
+ * comando, letra por letra, e marca o começo dela na linha do tempo.
184
+ */
185
+ function instrucao(texto) {
186
+ if (!state.on) return;
187
+ const t = String(texto || '').trim();
188
+ if (!t) return;
189
+ state.instrucoes.push({ n: state.instrucoes.length + 1, hora: new Date().toTimeString().slice(0, 8), texto: corta(t, 300) });
190
+ if (state.instrucoes.length > 40) state.instrucoes.splice(0, state.instrucoes.length - 40);
191
+
192
+ state.passo++;
193
+ state.log.push({ n: state.passo, hora: new Date().toTimeString().slice(0, 8), tipo: 'instrucao', acao: corta(t, 160), ok: true, aba: null });
194
+ if (state.log.length > MAX_LOG) state.log.splice(0, state.log.length - MAX_LOG);
195
+ }
196
+
126
197
  /**
127
198
  * Chamada pelo loop do agente depois de CADA tool. Quando o streaming está
128
199
  * desligado é um no-op — o caminho normal não paga nada por isto.
@@ -162,6 +233,20 @@ function capturar(entrada) {
162
233
 
163
234
  const id = state.proximoFrame;
164
235
  const arquivo = path.join(DIR, `frame-${id}.png`);
236
+
237
+ // Com o vídeo rodando, o quadro mais novo já está no disco — copiar é
238
+ // grátis e não fotografa duas vezes a mesma tela.
239
+ if (agora - state.liveAt < 900) {
240
+ try { fs.copyFileSync(LIVE(), arquivo); } catch { return null; }
241
+ state.proximoFrame++;
242
+ state.frames.push({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba });
243
+ while (state.frames.length > MAX_FRAMES) {
244
+ const velho = state.frames.shift();
245
+ try { fs.unlinkSync(path.join(DIR, `frame-${velho.id}.png`)); } catch {}
246
+ }
247
+ return id;
248
+ }
249
+
165
250
  const r = desktop.screenshot(arquivo);
166
251
  if (!r.ok) { if (!state.erroFoto) state.erroFoto = r.error; return null; }
167
252
 
@@ -188,73 +273,115 @@ const MASCOTE_SVG = `<svg viewBox="0 0 9 3.6" aria-hidden="true">
188
273
  <g fill="#0b1220"><rect x="3" y=".35" width=".8" height=".5" rx=".1"/><rect x="5.2" y=".35" width=".8" height=".5" rx=".1"/><rect x="3.9" y="1.25" width="1.2" height=".28" rx=".14"/></g>
189
274
  </svg>`;
190
275
 
191
- // A página segue o desenho do dono, traço a traço: fundo grafite, mascote
192
- // inclinado no canto, "Primocode" branco no alto; a janela de borda branca
193
- // grossa e canto bem redondo; barra #232323 com as três bolinhas e as abas em
194
- // texto branco centralizado, separadas por "|"; dentro, a tela preta com a
195
- // imagem ao vivo. O histórico (miniaturas + passos) fica ABAIXO da janela,
196
- // discreto — a moldura fica idêntica ao desenho.
276
+ // A tela do painel, na identidade do app — a MESMA paleta do design system do
277
+ // CLI (lib/ui.js): primária cyan #22d3ee, neutros slate, rose para falha. A
278
+ // estrutura é a do desenho do dono: mascote inclinado no canto, "Primocode"
279
+ // no alto, a janela com as bolinhas e as abas separadas por "|", a tela
280
+ // embaixo. Dentro da tela roda o VÍDEO ao vivo (o loop de captura acima); as
281
+ // abas em cima são tudo que o agente abriu — clicáveis, cada uma com seus
282
+ // quadros e passos; e a barra de comando autopreenche a instrução digitando.
283
+ const FAVICON = 'data:image/svg+xml,' + encodeURIComponent(
284
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 3.6">${MASCOTE_SVG.replace(/^<svg[^>]*>|<\/svg>$/g, '')}</svg>`,
285
+ );
286
+
197
287
  const PAGINA = `<!doctype html>
198
288
  <html lang="pt-BR"><head><meta charset="utf-8">
199
289
  <meta name="viewport" content="width=device-width, initial-scale=1">
200
- <title>PrimoCode · Ao vivo</title>
290
+ <title>Primocode · Ao vivo</title>
291
+ <link rel="icon" href="${FAVICON}">
201
292
  <style>
202
293
  * { box-sizing: border-box; margin: 0; }
203
- body { background: #262626; color: #fff; font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 0 16px 30px; }
294
+ ::selection { background: rgba(34,211,238,.28); }
295
+ body {
296
+ background: radial-gradient(1100px 480px at 50% -120px, #12263f 0%, #0d1729 46%, #0b1220 100%) fixed #0b1220;
297
+ color: #e2e8f0; font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
298
+ min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 0 18px 34px;
299
+ }
204
300
 
205
- /* Cabeçalho: mascote inclinado no canto, nome centrado */
206
- #topo { position: relative; width: min(1100px, 100%); padding: 44px 0 30px; text-align: center; }
207
- #topo .mascote { position: absolute; left: 6px; top: 30px; width: 96px; height: 40px; transform: rotate(-32deg); }
301
+ /* ── Cabeçalho: mascote inclinado + nome, como no desenho ── */
302
+ #topo { position: relative; width: min(1120px, 100%); padding: 44px 0 26px; text-align: center; }
303
+ #topo .mascote { position: absolute; left: 4px; top: 26px; width: 100px; height: 42px; transform: rotate(-32deg); filter: drop-shadow(0 8px 20px rgba(34,211,238,.4)); animation: flutua 3.2s ease-in-out infinite alternate; }
304
+ @keyframes flutua { from { translate: 0 0 } to { translate: 0 5px } }
208
305
  #topo .mascote svg { width: 100%; height: 100%; display: block; }
209
- #topo h1 { font-size: clamp(34px, 6vw, 58px); font-weight: 600; letter-spacing: .5px; }
210
- #topo .vivo { position: absolute; right: 6px; top: 46px; display: inline-flex; align-items: center; gap: 7px; color: #8a8a8a; font-size: 12px; }
211
- .dot { width: 9px; height: 9px; border-radius: 50%; background: #46d160; animation: pulsa 1.4s infinite; }
212
- @keyframes pulsa { 50% { opacity: .25 } }
213
-
214
- /* A janela — borda branca grossa, canto bem redondo */
215
- #janela { width: min(1100px, 100%); background: #000; border: 10px solid #f5f5f5; border-radius: 46px; overflow: hidden; display: flex; flex-direction: column; }
216
- #barra { position: relative; display: flex; align-items: center; background: #232323; padding: 14px 26px; min-height: 58px; }
217
- #barra .b { flex: none; margin-right: 12px; }
218
- #barra .b.r { width: 17px; height: 17px; border-radius: 50%; background: #d40000; }
219
- #barra .b.y { width: 30px; height: 17px; border-radius: 50%; background: #ffdd00; }
220
- #barra .b.g { width: 24px; height: 14px; border-radius: 50%; background: #33cc33; }
221
-
222
- /* Abas: texto branco centralizado na barra, separadas por | */
223
- #abas { position: absolute; left: 50%; transform: translateX(-50%); max-width: 62%; display: flex; align-items: center; gap: 12px; overflow-x: auto; white-space: nowrap; scrollbar-width: none; font-size: clamp(16px, 2.4vw, 24px); font-weight: 500; }
306
+ #topo h1 { font-size: clamp(34px, 5.6vw, 54px); font-weight: 650; letter-spacing: .4px; color: #f1f5f9; text-shadow: 0 4px 28px rgba(34,211,238,.22); }
307
+ #topo h1 span { color: #22d3ee; }
308
+ #topo .vivo { position: absolute; right: 2px; top: 48px; display: inline-flex; align-items: center; gap: 8px; color: #94a3b8; font-size: 12.5px; background: rgba(15,23,42,.85); border: 1px solid #1e293b; border-radius: 999px; padding: 6px 14px; }
309
+ .pulso { width: 8px; height: 8px; border-radius: 50%; background: #22d3ee; box-shadow: 0 0 10px #22d3ee99; animation: pulsa 1.4s infinite; }
310
+ @keyframes pulsa { 50% { opacity: .3 } }
311
+
312
+ /* ── A janela ── */
313
+ #janela {
314
+ width: min(1120px, 100%); background: #000; border: 1px solid #24344d; border-radius: 22px;
315
+ overflow: hidden; display: flex; flex-direction: column;
316
+ box-shadow: 0 30px 80px rgba(2,6,23,.75), 0 0 60px rgba(34,211,238,.07), inset 0 1px 0 rgba(226,232,240,.07);
317
+ }
318
+ #barra { position: relative; display: flex; align-items: center; background: linear-gradient(#152238, #101a2c); padding: 13px 22px; min-height: 54px; border-bottom: 1px solid #0a1120; }
319
+ #barra .b { flex: none; width: 13px; height: 13px; border-radius: 50%; margin-right: 9px; box-shadow: inset 0 -1.5px 3px rgba(0,0,0,.35); }
320
+ #barra .b.r { background: #ff5f57; } #barra .b.y { background: #febc2e; } #barra .b.g { background: #28c840; }
321
+
322
+ /* Abas: tudo que ele abriu, clicável, separado por | como no desenho */
323
+ #abas { position: absolute; left: 50%; transform: translateX(-50%); max-width: 62%; display: flex; align-items: center; gap: 11px; overflow-x: auto; white-space: nowrap; scrollbar-width: none; font-size: clamp(14px, 1.9vw, 18px); font-weight: 550; }
224
324
  #abas::-webkit-scrollbar { display: none; }
225
- .aba { border: 0; background: none; cursor: pointer; font: inherit; color: rgba(255,255,255,.5); padding: 2px 2px; }
226
- .aba:hover { color: rgba(255,255,255,.8); }
227
- .aba.ativa { color: #fff; }
228
- .sep { color: rgba(255,255,255,.45); font-weight: 400; }
229
-
230
- /* A tela */
231
- #tela { position: relative; min-height: 470px; display: flex; align-items: center; justify-content: center; background: #000; }
232
- #tela img { width: 100%; max-height: 64vh; object-fit: contain; display: none; }
233
- #aviso { color: #fff; text-align: center; font-size: clamp(20px, 3.4vw, 34px); font-weight: 500; padding: 110px 20px; }
234
- #carimbo { position: absolute; left: 16px; bottom: 12px; background: rgba(0,0,0,.75); border: 1px solid #2e2e2e; color: #bdbdbd; font-size: 12px; padding: 3px 10px; border-radius: 8px; display: none; max-width: 85%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
235
-
236
- /* Histórico: fora da moldura, discreto, abaixo da janela */
237
- #historico { width: min(1100px, 100%); padding-top: 14px; }
238
- #filme { display: flex; gap: 6px; padding: 2px 2px 8px; overflow-x: auto; }
239
- .foto { flex: none; border: 1px solid #3a3a3a; border-radius: 6px; background: #000; cursor: pointer; padding: 0; width: 84px; height: 50px; overflow: hidden; opacity: .7; }
325
+ .aba { position: relative; border: 0; background: none; cursor: pointer; font: inherit; color: #64748b; padding: 4px 3px 7px; transition: color .18s ease; }
326
+ .aba:hover { color: #cbd5e1; }
327
+ .aba.ativa { color: #22d3ee; text-shadow: 0 0 18px rgba(34,211,238,.5); }
328
+ .aba.ativa::after { content: ''; position: absolute; left: 15%; right: 15%; bottom: 0; height: 2px; border-radius: 2px; background: #22d3ee; box-shadow: 0 0 10px #22d3ee; }
329
+ .aba .qtd { color: #475569; font-size: .68em; vertical-align: super; margin-left: 4px; font-weight: 600; }
330
+ .sep { color: #334155; font-weight: 300; }
331
+
332
+ /* ── Barra de comando: a instrução autopreenchendo ── */
333
+ #comando { display: flex; align-items: center; gap: 11px; background: #0a1120; border-bottom: 1px solid #060b16; padding: 11px 22px; font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; font-size: 14px; min-height: 44px; }
334
+ #comando .chevron { color: #22d3ee; font-weight: 700; flex: none; text-shadow: 0 0 10px rgba(34,211,238,.6); }
335
+ #comando .texto { color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
336
+ #comando .texto.vazio { color: #475569; font-style: italic; }
337
+ #comando .cursor { flex: none; width: 8px; height: 16px; background: #22d3ee; margin-left: 2px; animation: pisca 1s steps(1) infinite; box-shadow: 0 0 8px rgba(34,211,238,.7); }
338
+ @keyframes pisca { 50% { opacity: 0 } }
339
+ #comando .hora { margin-left: auto; color: #475569; font-size: 11.5px; flex: none; }
340
+
341
+ /* ── A tela: o vídeo ao vivo ── */
342
+ #tela { position: relative; min-height: 440px; display: flex; align-items: center; justify-content: center; background: #000; }
343
+ #tela img { width: 100%; max-height: 60vh; object-fit: contain; display: none; }
344
+ #aviso { text-align: center; padding: 84px 24px; }
345
+ #aviso .titulo { font-size: clamp(19px, 3vw, 28px); font-weight: 550; color: #f1f5f9; }
346
+ #aviso .sub { color: #64748b; font-size: 14px; margin-top: 13px; line-height: 1.7; }
347
+ #badge { position: absolute; top: 14px; right: 16px; display: none; align-items: center; gap: 7px; background: rgba(8,12,22,.78); backdrop-filter: blur(6px); border: 1px solid #1e293b; color: #f1f5f9; font-size: 11.5px; font-weight: 700; letter-spacing: 1.2px; padding: 6px 12px; border-radius: 999px; }
348
+ #badge .rec { width: 8px; height: 8px; border-radius: 50%; background: #fb7185; box-shadow: 0 0 9px #fb7185; animation: pulsa 1.1s infinite; }
349
+ #carimbo { position: absolute; left: 16px; bottom: 13px; background: rgba(8,12,22,.78); backdrop-filter: blur(6px); border: 1px solid #1e293b; color: #94a3b8; font-size: 12px; padding: 5px 13px; border-radius: 999px; display: none; max-width: 82%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
350
+ #carimbo b { color: #22d3ee; font-weight: 600; }
351
+
352
+ /* ── Linha do tempo ── */
353
+ #historico { width: min(1120px, 100%); padding-top: 18px; }
354
+ #filme { display: flex; gap: 9px; padding: 4px 2px 13px; overflow-x: auto; scrollbar-width: thin; scrollbar-color: #1e293b transparent; }
355
+ .foto { flex: none; border: 1.5px solid #1e293b; border-radius: 9px; background: #000; cursor: pointer; padding: 0; width: 100px; height: 60px; overflow: hidden; opacity: .6; transition: transform .16s ease, opacity .16s ease, border-color .16s ease, box-shadow .16s ease; }
356
+ .foto:hover { opacity: 1; transform: translateY(-2px); border-color: #475569; }
240
357
  .foto img { width: 100%; height: 100%; object-fit: cover; display: block; }
241
- .foto.ativa { border-color: #fff; opacity: 1; }
242
- #passos { max-height: 170px; overflow-y: auto; }
243
- .item { padding: 3px 6px; display: flex; gap: 8px; font-size: 13px; }
244
- .item .n { color: #6f6f6f; min-width: 3ch; text-align: right; }
245
- .item .ok { color: #46d160; } .item .falha { color: #e5484d; }
246
- .item .txt { flex: 1; word-break: break-word; color: #d6d6d6; }
247
- .item .txt .erro { color: #e5484d; font-size: 12px; display: block; }
248
- .item .hora { color: #6f6f6f; font-size: 11px; }
249
- .item.temfoto { cursor: pointer; } .item.temfoto:hover .txt { color: #fff; }
250
-
251
- footer { color: #6f6f6f; font-size: 12px; padding-top: 14px; }
358
+ .foto.ativa { border-color: #22d3ee; opacity: 1; box-shadow: 0 0 0 2px rgba(34,211,238,.3), 0 6px 18px rgba(2,6,23,.6); }
359
+
360
+ #cartao { background: #0f172a; border: 1px solid #1e293b; border-radius: 16px; overflow: hidden; }
361
+ #cartao header { display: flex; align-items: center; justify-content: space-between; padding: 11px 17px; border-bottom: 1px solid #16223a; color: #94a3b8; font-size: 13px; font-weight: 600; letter-spacing: .3px; }
362
+ #cartao header span b { color: #22d3ee; }
363
+ #passos { max-height: 236px; overflow-y: auto; padding: 5px 0 9px; scrollbar-width: thin; scrollbar-color: #1e293b transparent; }
364
+ #passos::-webkit-scrollbar { width: 8px; } #passos::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 4px; }
365
+ .item { padding: 5px 17px; display: flex; gap: 10px; font-size: 13.5px; align-items: baseline; transition: background .12s ease; }
366
+ .item:hover { background: #131f35; }
367
+ .item .n { color: #475569; min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; }
368
+ .item .ok { color: #22d3ee; } .item .falha { color: #fb7185; }
369
+ .item .txt { flex: 1; word-break: break-word; color: #cbd5e1; }
370
+ .item .txt .erro { color: #fb7185; font-size: 12px; display: block; margin-top: 1px; }
371
+ .item .hora { color: #475569; font-size: 11px; font-variant-numeric: tabular-nums; }
372
+ .item.temfoto { cursor: pointer; } .item.temfoto:hover .txt { color: #f1f5f9; }
373
+ .item.pedido { background: #0c1526; border-top: 1px solid #16223a; border-bottom: 1px solid #16223a; margin: 6px 0; padding: 8px 17px; }
374
+ .item.pedido .txt { color: #f1f5f9; font-weight: 600; }
375
+ .item.pedido .chev { color: #22d3ee; font-weight: 800; }
376
+
377
+ footer { color: #475569; font-size: 12px; padding-top: 17px; }
378
+ footer b { color: #64748b; font-weight: 600; }
252
379
  </style></head>
253
380
  <body>
254
381
  <div id="topo">
255
382
  <div class="mascote">${MASCOTE_SVG}</div>
256
- <h1>Primocode</h1>
257
- <span class="vivo"><span class="dot"></span><span id="st">conectando…</span></span>
383
+ <h1>Primo<span>code</span></h1>
384
+ <span class="vivo"><span class="pulso"></span><span id="st">conectando…</span></span>
258
385
  </div>
259
386
 
260
387
  <div id="janela">
@@ -262,82 +389,153 @@ const PAGINA = `<!doctype html>
262
389
  <span class="b r"></span><span class="b y"></span><span class="b g"></span>
263
390
  <nav id="abas"></nav>
264
391
  </div>
392
+ <div id="comando">
393
+ <span class="chevron">❯</span>
394
+ <span class="texto vazio" id="cmdtexto">aguardando a sua instrução no terminal…</span>
395
+ <span class="cursor"></span>
396
+ <span class="hora" id="cmdhora"></span>
397
+ </div>
265
398
  <section id="tela">
266
- <img id="frame" alt="tela que a IA está usando">
267
- <div id="aviso">Tela que a IA está</div>
399
+ <img id="frame" alt="tela do computador, ao vivo">
400
+ <div id="aviso">
401
+ <div class="titulo">A tela do computador aparece aqui, em vídeo</div>
402
+ <div class="sub">Dê uma instrução no terminal do PrimoCode e assista ele trabalhando, ao vivo.<br>Cada app que ele abrir vira uma aba lá em cima — clique nelas para rever o que já foi feito.</div>
403
+ </div>
404
+ <div id="badge"><span class="rec"></span>AO VIVO</div>
268
405
  <div id="carimbo"></div>
269
406
  </section>
270
407
  </div>
271
408
 
272
409
  <div id="historico">
273
410
  <div id="filme"></div>
274
- <div id="passos"></div>
411
+ <div id="cartao">
412
+ <header><span>Linha do tempo · <b id="abanome">Ao vivo</b></span><span id="resumo"></span></header>
413
+ <div id="passos"></div>
414
+ </div>
275
415
  </div>
276
- <footer>só nesta máquina (127.0.0.1) · /streaming desliga</footer>
416
+ <footer>só nesta máquina <b>(127.0.0.1)</b> · <b>/streaming</b> desliga</footer>
277
417
 
278
418
  <script>
279
419
  const VIVO = 0; // id da aba "Ao vivo"
280
- let dados = { abas: [], frames: [], log: [] };
420
+ let dados = { abas: [], frames: [], log: [], instrucoes: [], liveSeq: 0 };
281
421
  let abaSel = VIVO; // aba escolhida pelo usuário
282
- let fotoSel = null; // frame fixado no filmstrip (null = mais recente)
422
+ let fotoSel = null; // frame fixado (null = vídeo/mais recente)
283
423
  let ultimoEstado = '';
284
424
 
285
425
  function esc(s) { return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
286
426
 
427
+ // ── o vídeo: troca o quadro assim que um novo existe, sem piscar ──
428
+ let quadroNaTela = -1;
429
+ function modoVideo() { return abaSel === VIVO && fotoSel == null; }
430
+ function videoTick() {
431
+ if (modoVideo() && dados.liveSeq > 0 && dados.liveSeq !== quadroNaTela) {
432
+ const seq = dados.liveSeq;
433
+ const pre = new Image(); // pré-carrega fora da tela:
434
+ pre.onload = () => { // o quadro só entra inteiro
435
+ if (!modoVideo()) return;
436
+ const img = document.getElementById('frame');
437
+ img.src = pre.src; img.style.display = 'block';
438
+ document.getElementById('aviso').style.display = 'none';
439
+ document.getElementById('badge').style.display = 'flex';
440
+ document.getElementById('carimbo').style.display = 'none';
441
+ quadroNaTela = seq;
442
+ };
443
+ pre.src = '/live?' + seq;
444
+ }
445
+ setTimeout(videoTick, 280);
446
+ }
447
+
448
+ // ── a instrução autopreenchendo, letra por letra ──
449
+ let digitando = null, instrucaoNaTela = 0;
450
+ function autopreencher(inst) {
451
+ if (!inst || inst.n === instrucaoNaTela) return;
452
+ instrucaoNaTela = inst.n;
453
+ clearInterval(digitando);
454
+ const el = document.getElementById('cmdtexto');
455
+ el.classList.remove('vazio');
456
+ el.textContent = '';
457
+ document.getElementById('cmdhora').textContent = inst.hora;
458
+ let i = 0;
459
+ digitando = setInterval(() => {
460
+ i += 1 + Math.floor(Math.random() * 2); // cadência de gente digitando
461
+ el.textContent = inst.texto.slice(0, i);
462
+ if (i >= inst.texto.length) clearInterval(digitando);
463
+ }, 32);
464
+ }
465
+
287
466
  function framesDaAba(id) {
288
467
  return id === VIVO ? dados.frames : dados.frames.filter((f) => f.aba === id);
289
468
  }
290
469
  function logDaAba(id) {
291
- return id === VIVO ? dados.log : dados.log.filter((e) => e.aba === id);
470
+ return id === VIVO ? dados.log : dados.log.filter((e) => e.aba === id || e.tipo === 'instrucao');
292
471
  }
293
472
 
294
473
  function render() {
295
- // Abas: "Ao vivo" + uma por app/janela, texto separado por |
474
+ // Abas: "Ao vivo" + tudo que ele abriu, separadas por |
296
475
  const nav = document.getElementById('abas');
297
476
  const abas = [{ id: VIVO, nome: 'Ao vivo' }, ...dados.abas];
298
477
  nav.innerHTML = abas.map((a) =>
299
- '<button class="aba' + (a.id === abaSel ? ' ativa' : '') + '" data-id="' + a.id + '">' + esc(a.nome) + '</button>'
478
+ '<button class="aba' + (a.id === abaSel ? ' ativa' : '') + '" data-id="' + a.id + '">' + esc(a.nome)
479
+ + (a.passos ? '<span class="qtd">' + a.passos + '</span>' : '') + '</button>'
300
480
  ).join('<span class="sep">|</span>');
301
- nav.querySelectorAll('.aba').forEach((b) => b.onclick = () => { abaSel = Number(b.dataset.id); fotoSel = null; render(); });
481
+ nav.querySelectorAll('.aba').forEach((b) => b.onclick = () => {
482
+ abaSel = Number(b.dataset.id); fotoSel = null; quadroNaTela = -1; render();
483
+ });
484
+ const abaObj = abas.find((a) => a.id === abaSel) || abas[0];
485
+ document.getElementById('abanome').textContent = abaObj.nome;
302
486
 
303
- // Imagem: a fixada no filmstrip, senão a mais nova da aba
487
+ // Fora do modo vídeo (aba antiga ou foto fixada): mostra o quadro daquela aba
304
488
  const fs = framesDaAba(abaSel);
305
- const atual = fotoSel != null ? fs.find((f) => f.id === fotoSel) : fs[fs.length - 1];
306
489
  const img = document.getElementById('frame');
307
490
  const aviso = document.getElementById('aviso');
491
+ const badge = document.getElementById('badge');
308
492
  const carimbo = document.getElementById('carimbo');
309
- if (atual) {
310
- const src = '/frame?n=' + atual.id;
311
- if (img.dataset.n !== String(atual.id)) { img.src = src; img.dataset.n = String(atual.id); }
312
- img.style.display = 'block'; aviso.style.display = 'none';
313
- carimbo.style.display = 'block';
314
- carimbo.textContent = '#' + atual.id + ' · ' + atual.hora + ' · ' + atual.acao + (fotoSel != null ? ' (clique na miniatura de novo para voltar ao mais recente)' : '');
493
+ if (!modoVideo()) {
494
+ const atual = fotoSel != null ? fs.find((f) => f.id === fotoSel) : fs[fs.length - 1];
495
+ badge.style.display = 'none';
496
+ if (atual) {
497
+ if (img.dataset.n !== 'f' + atual.id) { img.src = '/frame?n=' + atual.id; img.dataset.n = 'f' + atual.id; }
498
+ img.style.display = 'block'; aviso.style.display = 'none';
499
+ carimbo.style.display = 'block';
500
+ carimbo.innerHTML = '<b>#' + atual.id + '</b> · ' + atual.hora + ' · ' + esc(atual.acao)
501
+ + (fotoSel != null ? ' — <b>clique na miniatura de novo para voltar</b>' : '');
502
+ } else {
503
+ img.style.display = 'none'; aviso.style.display = 'block'; carimbo.style.display = 'none';
504
+ }
315
505
  } else {
316
- img.style.display = 'none'; aviso.style.display = 'block'; carimbo.style.display = 'none';
317
- if (dados.erroFoto) aviso.textContent = 'Sem foto da tela: ' + dados.erroFoto;
506
+ carimbo.style.display = 'none';
507
+ if (dados.erroFoto) { aviso.style.display = 'block'; aviso.querySelector('.sub').textContent = 'Sem imagem da tela: ' + dados.erroFoto; }
318
508
  }
319
509
 
320
- // Filmstrip da aba (as últimas 14)
510
+ // Filmstrip da aba (as últimas 16)
321
511
  const filme = document.getElementById('filme');
322
- const tira = fs.slice(-14);
512
+ const tira = fs.slice(-16);
513
+ const marcada = fotoSel != null ? fotoSel : -1;
323
514
  filme.innerHTML = tira.map((f) =>
324
- '<button class="foto' + ((atual && f.id === atual.id) ? ' ativa' : '') + '" data-id="' + f.id + '" title="' + esc(f.acao) + '"><img loading="lazy" src="/frame?n=' + f.id + '"></button>').join('');
515
+ '<button class="foto' + (f.id === marcada ? ' ativa' : '') + '" data-id="' + f.id + '" title="' + esc(f.acao) + '"><img loading="lazy" src="/frame?n=' + f.id + '"></button>').join('');
325
516
  filme.querySelectorAll('.foto').forEach((b) => b.onclick = () => {
326
517
  const id = Number(b.dataset.id);
327
- fotoSel = (fotoSel === id) ? null : id; // clicar de novo volta ao "mais recente"
518
+ fotoSel = (fotoSel === id) ? null : id; // clicar de novo volta ao vídeo
519
+ quadroNaTela = -1;
328
520
  render();
329
521
  });
330
522
  if (fotoSel == null) filme.scrollLeft = filme.scrollWidth;
331
523
 
332
- // Passos da aba (mais novos primeiro)
333
- const passos = logDaAba(abaSel).slice(-40).reverse();
334
- document.getElementById('passos').innerHTML = passos.map((e) =>
335
- '<div class="item' + (e.frame ? ' temfoto" data-frame="' + e.frame : '') + '">'
336
- + '<span class="n">' + e.n + '</span>'
337
- + '<span class="' + (e.ok ? 'ok' : 'falha') + '">' + (e.ok ? '✔' : '✗') + '</span>'
338
- + '<span class="txt">' + esc(e.acao) + (e.erro ? '<span class="erro">' + esc(e.erro) + '</span>' : '') + '</span>'
339
- + '<span class="hora">' + e.hora + '</span></div>').join('');
340
- document.querySelectorAll('.item.temfoto').forEach((el) => el.onclick = () => { fotoSel = Number(el.dataset.frame); render(); });
524
+ // Linha do tempo da aba (mais novos primeiro); pedidos viram divisórias
525
+ const passos = logDaAba(abaSel).slice(-60).reverse();
526
+ document.getElementById('resumo').textContent = passos.filter((e) => e.tipo !== 'instrucao').length + ' passos';
527
+ document.getElementById('passos').innerHTML = passos.map((e) => {
528
+ if (e.tipo === 'instrucao') {
529
+ return '<div class="item pedido"><span class="n">' + e.n + '</span><span class="chev">❯</span>'
530
+ + '<span class="txt">' + esc(e.acao) + '</span><span class="hora">' + e.hora + '</span></div>';
531
+ }
532
+ return '<div class="item' + (e.frame ? ' temfoto" data-frame="' + e.frame : '') + '">'
533
+ + '<span class="n">' + e.n + '</span>'
534
+ + '<span class="' + (e.ok ? 'ok' : 'falha') + '">' + (e.ok ? '✔' : '✗') + '</span>'
535
+ + '<span class="txt">' + esc(e.acao) + (e.erro ? '<span class="erro">' + esc(e.erro) + '</span>' : '') + '</span>'
536
+ + '<span class="hora">' + e.hora + '</span></div>';
537
+ }).join('');
538
+ document.querySelectorAll('.item.temfoto').forEach((el) => el.onclick = () => { fotoSel = Number(el.dataset.frame); quadroNaTela = -1; render(); });
341
539
  }
342
540
 
343
541
  async function tick() {
@@ -345,14 +543,16 @@ async function tick() {
345
543
  const s = await (await fetch('/state')).json();
346
544
  dados = s;
347
545
  document.getElementById('st').textContent = 'ao vivo';
546
+ autopreencher(s.instrucoes[s.instrucoes.length - 1]);
348
547
  const assinatura = JSON.stringify([s.frames.length, s.log.length, s.abas.length, s.erroFoto]);
349
548
  if (assinatura !== ultimoEstado) { ultimoEstado = assinatura; render(); }
350
549
  } catch (e) {
351
550
  document.getElementById('st').textContent = 'reconectando…';
352
551
  }
353
- setTimeout(tick, 700);
552
+ setTimeout(tick, 600);
354
553
  }
355
554
  tick();
555
+ videoTick();
356
556
  </script>
357
557
  </body></html>`;
358
558
 
@@ -361,21 +561,33 @@ function start() {
361
561
  if (state.on) return Promise.resolve(state.url);
362
562
  try { fs.mkdirSync(DIR, { recursive: true }); } catch {}
363
563
  // Fotos de sessões passadas não pertencem a esta linha do tempo.
364
- try { for (const f of fs.readdirSync(DIR)) if (/^frame-\d+\.png$/.test(f)) fs.unlinkSync(path.join(DIR, f)); } catch {}
564
+ try { for (const f of fs.readdirSync(DIR)) if (/^(frame-\d+|live|live-tmp)\.png$/.test(f)) fs.unlinkSync(path.join(DIR, f)); } catch {}
365
565
 
366
566
  return new Promise((resolve, reject) => {
367
567
  const server = http.createServer((req, res) => {
368
568
  const u = new URL(req.url || '/', 'http://x');
369
569
  if (u.pathname === '/state') {
570
+ viuAgora(); // página aberta = vídeo ligado
370
571
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
371
572
  res.end(JSON.stringify({
372
573
  erroFoto: state.erroFoto,
373
574
  abas: state.abas,
374
575
  frames: state.frames,
375
576
  log: state.log.slice(-120),
577
+ instrucoes: state.instrucoes.slice(-20),
578
+ liveSeq: state.liveSeq,
376
579
  }));
377
580
  return;
378
581
  }
582
+ if (u.pathname === '/live') {
583
+ viuAgora();
584
+ fs.readFile(LIVE(), (err, buf) => {
585
+ if (err) { res.writeHead(404); res.end(); return; }
586
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });
587
+ res.end(buf);
588
+ });
589
+ return;
590
+ }
379
591
  if (u.pathname === '/frame') {
380
592
  // O id vem de fora: só serve o arquivo se for número — nada de
381
593
  // path traversal por aqui.
@@ -411,9 +623,10 @@ function start() {
411
623
 
412
624
  function stop() {
413
625
  if (state.server) { try { state.server.close(); } catch {} }
626
+ if (state.liveTimer) { clearTimeout(state.liveTimer); state.liveTimer = null; }
414
627
  state.server = null;
415
628
  state.on = false;
416
629
  state.url = '';
417
630
  }
418
631
 
419
- module.exports = { start, stop, step, isOn, url, descrever };
632
+ module.exports = { start, stop, step, instrucao, isOn, url, descrever };
package/lib/ui.js CHANGED
@@ -161,9 +161,9 @@ const TIPS = [
161
161
  ];
162
162
 
163
163
  const NEWS = [
164
- 'Agora ele INTERAGE com o computador: janelas, botões e ícones pelo nome',
165
- '/streaming abre um painel ao vivo — veja cada passo dele na tela',
166
- 'Dois provedores em corrente: acabou a cota de um, o outro atende',
164
+ '/streaming agora é VÍDEO ao vivo — assista ele trabalhando, aba por aba',
165
+ 'Comandos se autopreenchem: /str + Tab (ou Enter) vira /streaming',
166
+ 'Ele INTERAGE com o computador: janelas, botões e ícones pelo nome',
167
167
  ];
168
168
 
169
169
  // ── Cabeçalho ────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "7.12.2",
3
+ "version": "7.14.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {