primocode 10.4.2 → 10.4.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
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.4.2
1
+ # PrimoCode v10.4.4
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v10.4.2
12
+ ▐▛███▜▌ Primo Code v10.4.4
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/bin/primocode.js CHANGED
@@ -305,6 +305,8 @@ function sliderEffort(atual) {
305
305
  let anima = null;
306
306
 
307
307
  const pintar = (primeira) => {
308
+ // Na tela cheia o slider desenha no painel dela, acima da caixa.
309
+ if (rl && rl.cheia) return rl.painel(effort.desenharEffort(idx, fase));
308
310
  if (!primeira) process.stdout.write(`\x1b[${ALTURA}A\r`);
309
311
  process.stdout.write(effort.desenharEffort(idx, fase).split('\n').map((l) => '\x1b[2K' + l).join('\n') + '\n');
310
312
  };
@@ -466,7 +468,7 @@ function barraDeStatus() {
466
468
  : uso.pct >= 70 ? c.warn(`${uso.pct}% ctx`)
467
469
  : c.dim(`${uso.pct}% ctx`));
468
470
  }
469
- direita.push(state.trabalhando
471
+ direita.push(state.trabalhando && rl && rl.ocupado
470
472
  ? c.warn('trabalhando') + c.dim(' · ') + c.white('esc esc') + c.dim(' para')
471
473
  : c.white('tab') + c.dim(' modos ') + c.white('ctrl+p') + c.dim(' comandos'));
472
474
 
@@ -920,6 +922,7 @@ function abrirMarketplace() {
920
922
  const texto = marketplace.montar(estado, {
921
923
  colunas: process.stdout.columns || 80, altura: altura(),
922
924
  });
925
+ if (rl && rl.cheia) return rl.painel(texto);
923
926
  const linhas = texto.split('\n');
924
927
  // Redesenha POR CIMA do quadro anterior em vez de rolar a tela:
925
928
  // sem isso, cada seta empurraria a tela inteira para cima e o
@@ -2760,6 +2763,8 @@ async function main() {
2760
2763
  vazio: ui.convite(),
2761
2764
  telaCheia,
2762
2765
  lateral: barraLateral,
2766
+ logoAnimado: (quadro, trabalhando) => ui.logoAnimado(state.config.mascot, quadro, trabalhando),
2767
+ agora: () => (ui.agora.texto && Date.now() - ui.agora.desde < 10 * 60 * 1000 ? ui.agora.texto : ''),
2763
2768
  versao: VERSION,
2764
2769
  logo: ui.logoComMascote(state.config.mascot),
2765
2770
  pasta: () => {
@@ -2852,6 +2857,9 @@ async function main() {
2852
2857
  const text = queue.shift().trim();
2853
2858
  if (!text) continue;
2854
2859
  ui.titulo(`⠋ PrimoCode — ${text}`);
2860
+ // Comando (/effort, /help) não é trabalho do agente: a caixa e a
2861
+ // barra da direita só dizem "trabalhando" quando ele trabalha.
2862
+ if (rl.trabalhando) rl.trabalhando(!isSlash(text));
2855
2863
  try {
2856
2864
  if (ehBash(text)) await rodarBash(text);
2857
2865
  else if (isSlash(text)) await handleSlash(text);
@@ -2867,7 +2875,7 @@ async function main() {
2867
2875
  vozNaTarefa = false;
2868
2876
  const ultima = state.history.slice().reverse().find((m) => m.role === 'assistant');
2869
2877
  if (ultima && ultima.content) vozEmEspera.falar(ultima.content);
2870
- }
2878
+ } else if (vozEmEspera && vozEmEspera.estado) vozEmEspera.estado('esperando');
2871
2879
  // Recado que chegou tarde demais para a tarefa (ela já tinha
2872
2880
  // acabado): vira o próximo pedido, na ordem. Nada se perde.
2873
2881
  queue.unshift(...sobrasDaCaixa());
@@ -668,6 +668,6 @@ module.exports = {
668
668
  MODELO_CLAUDE, argsDoModelo, argsDoMotor,
669
669
  SKILL_DIR, SKILL_PATH, garantirSkill,
670
670
  temClaudeCode, temOrca, orcaAlcancavel, escolherCaminho,
671
- garantirEngine, requestAct, emitirDecisao,
671
+ garantirEngine, requestAct, emitirDecisao, rodarClaude,
672
672
  _emitirDecisao: emitirDecisao,
673
673
  };
package/lib/entrada.js CHANGED
@@ -41,6 +41,7 @@ const readline = require('readline');
41
41
  const { EventEmitter } = require('events');
42
42
  const { c, visibleWidth, truncate } = require('./ui.js');
43
43
  const tela = require('./tela.js');
44
+ const stripAnsiLocal = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
44
45
 
45
46
  // ── O desenho ────────────────────────────────────────────────────────────
46
47
 
@@ -190,6 +191,10 @@ function montar(e) {
190
191
  if (st.linhas) {
191
192
  for (const linha of st.linhas) out.push(' ' + truncate(linha, largura - 2));
192
193
  } else {
194
+ /* Uma linha vazia entre o painel e o rodapé. Colado, o rodapé parecia
195
+ SOBREPOSTO ao painel em terminais de entrelinha apertada (o Orca):
196
+ o fundo do painel encostava nas letras da pasta. */
197
+ out.push('');
193
198
  const esq = st.esquerda || '';
194
199
  const dir = st.direita || '';
195
200
  const sobra = largura - 2 - visibleWidth(esq) - visibleWidth(dir);
@@ -323,7 +328,6 @@ class Entrada extends EventEmitter {
323
328
  e quem põe na tela é o próximo quadro. Suspensa (o painel do
324
329
  /effort desenhando por cima), passa direto. */
325
330
  if (eu.cheia) {
326
- if (eu.suspenso) return eu._stdoutOriginal.call(this, texto, ...resto);
327
331
  tela.acrescentar(eu.conversa, texto);
328
332
  eu._agendar();
329
333
  const fim = resto.find((r) => typeof r === 'function');
@@ -388,13 +392,23 @@ class Entrada extends EventEmitter {
388
392
  /* Enquanto outro desenho é dono da tela — o painel do /effort, uma tela
389
393
  do marketplace — a caixa sai da frente e larga o teclado. Sem isso, os
390
394
  dois desenhariam no mesmo lugar e as setas moveriam os dois. */
391
- suspender() { this.suspenso = true; if (this.cheia) this._sairDaTela(); else this._apagar(); }
395
+ /* Na tela cheia, suspender só LARGA O TECLADO: a tela continua no ar, e
396
+ quem suspendeu desenha pelo `painel()`. Sair da tela alternativa aqui
397
+ (o jeito antigo) jogava o /effort por cima do terminal velho, e a
398
+ volta deixava tudo bagunçado. */
399
+ suspender() { this.suspenso = true; if (this.cheia) this._agendar(); else this._apagar(); }
392
400
  retomar() {
393
401
  this.suspenso = false;
394
- if (this.cheia && this.ativo && !this.fechado) { this._entrarNaTela(); return this._pintar(); }
402
+ if (this.cheia && this.ativo && !this.fechado) { this.painelTexto = null; return this._pintar(); }
395
403
  if (this.ativo && !this.fechado) this._desenhar();
396
404
  }
397
405
 
406
+ /** Um comando com tela própria desenha aqui (na tela cheia). null fecha. */
407
+ painel(texto) {
408
+ this.painelTexto = texto == null ? null : String(texto).replace(/\n+$/, '');
409
+ this._pintar();
410
+ }
411
+
398
412
  // ── a tela cheia ─────────────────────────────────────────────────────
399
413
 
400
414
  /* A tela alternativa (a do vim): o que estava no terminal fica guardado e
@@ -412,7 +426,7 @@ class Entrada extends EventEmitter {
412
426
  this._colunasReais = process.stdout.columns || 80;
413
427
  Object.defineProperty(process.stdout, 'columns', {
414
428
  configurable: true,
415
- get() { return eu._naTela && !eu.suspenso ? tela.larguraDaConversa(eu._colunasReais) : eu._colunasReais; },
429
+ get() { return eu._naTela ? tela.larguraDaConversa(eu._colunasReais) : eu._colunasReais; },
416
430
  set(v) { eu._colunasReais = v; }, // o Node escreve aqui quando a janela muda
417
431
  });
418
432
  }
@@ -440,14 +454,36 @@ class Entrada extends EventEmitter {
440
454
  /** O estado do quadro, para a tela cheia (função pura em lib/tela.js). */
441
455
  _quadroCheio() {
442
456
  const conversa = this.conversa.some((l) => /\S/.test(l));
457
+ const trabalhando = !!(this.ocupado || this.spinnerTexto);
458
+ const quadro = Date.now() / 140;
459
+ /* O topo da barra da direita: o logo do PrimoCode (que anima enquanto
460
+ ele trabalha) e o ESTADO — trabalhando, há quanto tempo, no quê. */
461
+ const topo = this.op.logoAnimado ? this.op.logoAnimado(quadro, trabalhando) : [];
462
+ topo.push('');
463
+ if (trabalhando) {
464
+ const giro = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][Math.floor(quadro) % 10];
465
+ topo.push(c.warn(giro + ' Trabalhando') + (this._desde ? c.dim(` · ${Math.floor((Date.now() - this._desde) / 1000)}s`) : ''));
466
+ const agora = this.op.agora ? this.op.agora() : '';
467
+ if (agora) topo.push(c.muted(agora));
468
+ if (this.spinnerTexto) topo.push(c.dim(stripAnsiLocal(this.spinnerTexto).replace(/^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s*/, '')));
469
+ } else {
470
+ topo.push(c.brand('● ') + c.muted('Pronto') + c.dim(' · esperando você'));
471
+ }
472
+ if (trabalhando && !this._animando) {
473
+ // Enquanto trabalha, a barra se redesenha sozinha (o logo anima
474
+ // e o relógio anda), mesmo que nada novo chegue na conversa.
475
+ this._animando = setInterval(() => this._pintar(), 140);
476
+ if (this._animando.unref) this._animando.unref();
477
+ } else if (!trabalhando && this._animando) { clearInterval(this._animando); this._animando = null; }
443
478
  return tela.quadro({
444
479
  colunas: this._colunasReais || 80,
445
480
  linhas: process.stdout.rows || 24,
446
481
  conversa: this.conversa,
447
482
  rolagem: this.rolagem,
448
- home: !this.mandou && !conversa,
483
+ home: !this.mandou && !conversa && !this.painelTexto,
449
484
  caixa: (colunas, centro) => montar({ ...this._estado(), colunas, centro, menuAcima: true }),
450
- lateral: { titulo: this.titulo, ...(this.op.lateral ? this.op.lateral() : {}) },
485
+ lateral: { titulo: this.titulo, topo, ...(this.op.lateral ? this.op.lateral() : {}) },
486
+ painel: this.painelTexto,
451
487
  versao: this.op.versao || '',
452
488
  logo: this.op.logo,
453
489
  pasta: this.op.pasta ? this.op.pasta() : '',
@@ -455,7 +491,7 @@ class Entrada extends EventEmitter {
455
491
  }
456
492
 
457
493
  _pintar() {
458
- if (!this.cheia || this.suspenso || this.fechado || !this._naTela) return;
494
+ if (!this.cheia || this.fechado || !this._naTela) return;
459
495
  const q = this._quadroCheio();
460
496
  this.rolagem = Math.min(this.rolagem, q.rolagemMax);
461
497
  let out = '\x1b[?25l';
@@ -477,6 +513,7 @@ class Entrada extends EventEmitter {
477
513
  trabalhando(sim) {
478
514
  if (this.ocupado === !!sim) return;
479
515
  this.ocupado = !!sim;
516
+ this._desde = sim ? Date.now() : 0;
480
517
  if (this.desenhado) this._redesenhar();
481
518
  }
482
519
 
@@ -16,11 +16,8 @@
16
16
 
17
17
  'use strict';
18
18
 
19
- const { execFileSync } = require('child_process');
20
19
  const cerebro = require('./cerebro.json');
21
20
 
22
- const SEM_APARECER = { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true };
23
-
24
21
  function motorDe(id) {
25
22
  if (id === 'codex') {
26
23
  /* O padrão antigo do motor (gpt-5-mini) é recusado por conta de
@@ -33,13 +30,19 @@ function motorDe(id) {
33
30
  return require('../claude-engine.js');
34
31
  }
35
32
 
36
- /** Uma pergunta de texto, resposta de texto. Sem protocolo, sem ferramenta. */
37
- function perguntar(id, texto) {
38
- if (id === 'codex') return String(motorDe('codex').chamarCodex(texto) || '').trim();
33
+ /** Uma pergunta de texto, resposta de texto. Sem protocolo, sem ferramenta.
34
+ *
35
+ * ASSÍNCRONA. Era `execFileSync('claude', …)`: enquanto o Claude pensava, o
36
+ * PrimoCode inteiro parava — a tela cheia congelava em "Trabalhando · 0s",
37
+ * sem animação, sem Esc Esc, sem dar para digitar. O agente (requestAct) já
38
+ * rodava em paralelo; o chat e o resumo eram os que travavam. E o Codex
39
+ * devolvia "[object Promise]": a chamada dele já era assíncrona e o
40
+ * resultado virava texto sem esperar. */
41
+ async function perguntar(id, texto) {
42
+ if (id === 'codex') return String((await motorDe('codex').chamarCodex(texto)) || '').trim();
39
43
  const claude = motorDe('claude-code');
40
- const saida = execFileSync('claude', ['-p', '--output-format', 'json', ...claude.argsDoMotor()], {
41
- encoding: 'utf-8', timeout: 120000, input: texto, maxBuffer: 8 * 1024 * 1024, ...SEM_APARECER,
42
- });
44
+ const saida = await claude.rodarClaude(['-p', '--output-format', 'json', ...claude.argsDoMotor()], texto,
45
+ { timeout: 180000, tentativas: 1 });
43
46
  try { return String(JSON.parse(saida).result || '').trim(); } catch { return String(saida || '').trim(); }
44
47
  }
45
48
 
@@ -54,7 +57,7 @@ function criar(id) {
54
57
  const conversa = turnos.filter((m) => m && m.role && typeof m.content === 'string')
55
58
  .map((m) => `${m.role === 'user' ? 'PESSOA' : 'VOCÊ'}: ${m.content}`).join('\n');
56
59
  try {
57
- const resposta = perguntar(id, `${cerebro.SYSTEM_CHAT}\n\nResponda só à última fala da PESSOA.\n\n${conversa}`);
60
+ const resposta = await perguntar(id, `${cerebro.SYSTEM_CHAT}\n\nResponda só à última fala da PESSOA.\n\n${conversa}`);
58
61
  if (resposta) emit({ delta: resposta });
59
62
  } catch (e) {
60
63
  emit({ erro: e.message });
@@ -69,7 +72,7 @@ function criar(id) {
69
72
  + novas.map((m) => `${m.role === 'user' ? 'USUÁRIO' : m.role === 'assistant' ? 'AGENTE' : 'FERRAMENTA'}: `
70
73
  + String(typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '')).slice(0, 1200)).join('\n');
71
74
  try {
72
- return { resumo: perguntar(id, `${cerebro.SYSTEM_RESUMO}\n\n${corpo}`) || String(resumo || '') };
75
+ return { resumo: (await perguntar(id, `${cerebro.SYSTEM_RESUMO}\n\n${corpo}`)) || String(resumo || '') };
73
76
  } catch (e) {
74
77
  return { resumo: String(resumo || ''), erro: e.message };
75
78
  }
package/lib/tela.js CHANGED
@@ -116,6 +116,8 @@ function caber(s, w, fundo) {
116
116
  function lateral(lat, alto) {
117
117
  const w = LATERAL_W - 4; // 2 de margem de cada lado
118
118
  const corpo = [''];
119
+ // O topo: o logo (animado enquanto trabalha) e o estado do Primo agora.
120
+ if (lat.topo && lat.topo.length) corpo.push(...lat.topo, '');
119
121
  for (const l of quebrarComCor(c.bold(c.white(lat.titulo || 'Nova sessão')), w)) corpo.push(l);
120
122
  for (const s of (lat.secoes || [])) {
121
123
  corpo.push('');
@@ -170,7 +172,9 @@ function quadro(e) {
170
172
  while (linhas.length < H - 1) linhas.push('');
171
173
  linhas.length = H - 1;
172
174
  const esq = ' ' + c.dim(e.pasta || '');
173
- const dir = c.dim(e.versao || '') + ' ';
175
+ // Folga à direita: terminais como o Orca põem um botão flutuante no
176
+ // canto de baixo, e ele cobria a versão ("10.").
177
+ const dir = c.dim(e.versao || '') + ' ';
174
178
  linhas.push(esq + ' '.repeat(Math.max(1, W - visibleWidth(esq) - visibleWidth(dir))) + dir);
175
179
  return {
176
180
  linhas: linhas.map((l) => caber(l, W)),
@@ -179,6 +183,10 @@ function quadro(e) {
179
183
  };
180
184
  }
181
185
 
186
+ /* O PAINEL de um comando que desenha a própria tela (o slider do
187
+ /effort, o marketplace): fica no pé da conversa, logo acima da caixa,
188
+ por cima do que estiver ali — a tela cheia nunca sai do ar. */
189
+ const painel = (e.painel ? String(e.painel).split('\n') : []);
182
190
  const comLateral = W >= LATERAL_MIN;
183
191
  const mainW = comLateral ? W - LATERAL_W : W;
184
192
  /* Uma coluna de margem dos dois lados, para a conversa e para a caixa: o
@@ -207,6 +215,11 @@ function quadro(e) {
207
215
  const conversa = [];
208
216
  for (let i = janela.length; i < altoConversa; i++) conversa.push('');
209
217
  for (const l of janela) conversa.push(l);
218
+ if (painel.length) {
219
+ const inicio = Math.max(0, conversa.length - painel.length - 1);
220
+ conversa.splice(inicio, conversa.length - inicio, '', ...painel.map((l) => ' ' + l));
221
+ conversa.length = altoConversa;
222
+ }
210
223
  if (rolagem > 0) {
211
224
  conversa[0] = ' ' + c.dim(`↑ ${rolagem} linha${rolagem > 1 ? 's' : ''} abaixo · PgDn ou a roda do mouse para voltar`);
212
225
  }
package/lib/ui.js CHANGED
@@ -358,12 +358,75 @@ function logo() {
358
358
  }
359
359
  const LOGO_W = visibleWidth(logo()[0]);
360
360
 
361
- /** O logo com o mascote do PrimoCode na frente — o desenho de 3 linhas
362
- * alinhado às 3 linhas do corpo das letras (a 4ª é só a perna do p). */
361
+ /* ── O mascote GRANDE ─────────────────────────────────────────────────
362
+ O desenho de sempre tem 3 linhas — pequeno ao lado do logo de 4. Para a
363
+ home ele cresce 2×: cada quarto de bloco (▘ ▝ ▖ ▗) vira um bloco cheio, e
364
+ cada bloco cheio vira 2×2. Ficam 6 linhas × 18 colunas, com a cor de cada
365
+ pedaço preservada — o mesmo desenho, não um desenho novo. */
366
+ const QUARTOS = {
367
+ ' ': [0, 0, 0, 0], '█': [1, 1, 1, 1], '▀': [1, 1, 0, 0], '▄': [0, 0, 1, 1],
368
+ '▌': [1, 0, 1, 0], '▐': [0, 1, 0, 1], '▘': [1, 0, 0, 0], '▝': [0, 1, 0, 0],
369
+ '▖': [0, 0, 1, 0], '▗': [0, 0, 0, 1], '▛': [1, 1, 1, 0], '▜': [1, 1, 0, 1],
370
+ '▙': [1, 0, 1, 1], '▟': [0, 1, 1, 1], '▚': [1, 0, 0, 1], '▞': [0, 1, 1, 0],
371
+ };
372
+ function mascoteGrande(style) {
373
+ const saida = [];
374
+ for (const linha of mascot(style)) {
375
+ // [caractere, cor] de cada coluna da linha colorida
376
+ const cels = [];
377
+ let cor = '';
378
+ for (const m of linha.matchAll(/(\x1b\[[0-9;]*m)|([^\x1b])/gu)) {
379
+ if (m[1]) { if (m[1] !== PALETTE.reset) cor = m[1]; continue; }
380
+ cels.push([m[2], cor]);
381
+ }
382
+ const cima = [], baixo = [];
383
+ for (const [ch, cr] of cels) {
384
+ const q = QUARTOS[ch] || (ch === ' ' ? QUARTOS[' '] : QUARTOS['█']);
385
+ const bloco = (b) => (b ? `${cr}█${PALETTE.reset}` : ' ');
386
+ cima.push(bloco(q[0]) + bloco(q[1]));
387
+ baixo.push(bloco(q[2]) + bloco(q[3]));
388
+ }
389
+ saida.push(cima.join(''), baixo.join(''));
390
+ }
391
+ while (saida.length && !saida[saida.length - 1].trim()) saida.pop(); // a linha vazia de baixo
392
+ return saida;
393
+ }
394
+
395
+ /** O logo da home: o mascote grande e, centrado na altura dele, o letreiro. */
363
396
  function logoComMascote(style) {
364
- const m = mascot(style);
397
+ const m = mascoteGrande(style);
365
398
  const l = logo();
366
- return l.map((linha, i) => (m[i] || ' '.repeat(ART_W)) + ' ' + linha);
399
+ const desce = Math.floor((m.length - l.length) / 2);
400
+ const linhas = m.map((linha, i) => linha + ' ' + (l[i - desce] || ''));
401
+ // Todas com a MESMA largura: a home centraliza linha por linha, e uma
402
+ // linha mais curta (a dos pés) sairia deslocada do resto do desenho.
403
+ const larg = Math.max(...linhas.map(visibleWidth));
404
+ return linhas.map((x) => x + ' '.repeat(larg - visibleWidth(x)));
405
+ }
406
+
407
+ /* ── O logo animado da barra da direita ──────────────────────────────────
408
+ Enquanto o Primo trabalha, o mascote pulsa (a cor viva respira entre três
409
+ tons de ciano), os pés andam, e um brilho corre pelo nome "PrimoCode".
410
+ Parado, fica quieto: animação sem trabalho é ruído. `quadro` é um número
411
+ que cresce com o tempo (o chamador passa Date.now()/120). */
412
+ const PULSO = ['\x1b[38;5;45m', '\x1b[38;5;51m', '\x1b[38;5;87m', '\x1b[38;5;51m'];
413
+ function logoAnimado(style, quadro, trabalhando) {
414
+ const f = Math.floor(quadro || 0);
415
+ const m = MASCOTS[style] || MASCOTS.primo;
416
+ // Os pés andam: a linha dos pés troca de passo a cada quadro.
417
+ const linhas = m.linhas.map((l) => (trabalhando && f % 2 && l === PES ? ' ▝▘ ▘▝ ' : l));
418
+ let art = linhas.map((l, i) => shade(l, m, m.mascara && m.mascara[i]));
419
+ if (trabalhando) art = art.map((l) => l.split(PALETTE.brand).join(PULSO[f % PULSO.length]));
420
+ const nome = 'PrimoCode';
421
+ const brilho = f % (nome.length + 8);
422
+ let texto = '';
423
+ [...nome].forEach((ch, i) => {
424
+ const base = i < 5 ? '\x1b[38;5;255m' : '\x1b[38;5;45m';
425
+ const aceso = trabalhando && Math.abs(i - brilho) <= 0;
426
+ const perto = trabalhando && Math.abs(i - brilho) === 1;
427
+ texto += (aceso ? '\x1b[1m\x1b[38;5;231m' : perto ? '\x1b[1m\x1b[38;5;159m' : '\x1b[1m' + base) + ch + PALETTE.reset;
428
+ });
429
+ return [art[0], art[1] + ' ' + texto, art[2]];
367
430
  }
368
431
 
369
432
  /** Centraliza uma linha (com cor) em `cols` colunas. */
@@ -467,6 +530,8 @@ function titulo(texto) {
467
530
  try { escrever(`\x1b]0;${limpo}\x07`); } catch { /* terminal fechado */ }
468
531
  }
469
532
 
533
+ const agora = { texto: '', desde: 0 };
534
+
470
535
  // O rótulo de cada tool na tela — o que a pessoa lê no lugar do nome interno.
471
536
  const ROTULOS = {
472
537
  read_file:'Read', write_file:'Write', edit_file:'Edit', multi_edit:'MultiEdit',
@@ -508,6 +573,9 @@ function actionHeader(name, args) {
508
573
  // virava "[object Object]" na tela.
509
574
  if (argStr && typeof argStr === 'object') argStr = JSON.stringify(argStr).slice(0, 60);
510
575
  argStr = String(argStr || '').replace(/\s+/g, ' ').trim();
576
+ // O que ele está fazendo AGORA — a barra da direita da tela cheia mostra.
577
+ agora.texto = label + (argStr ? ' · ' + argStr.slice(0, 60) : '');
578
+ agora.desde = Date.now();
511
579
  // Uma tool sem argumento nenhum caía no JSON.stringify e desenhava "{}"
512
580
  // ao lado do nome — ruído que não diz nada.
513
581
  if (argStr === '{}' || argStr === 'null' || argStr === 'undefined') argStr = '';
@@ -641,4 +709,4 @@ function friendlyError(raw) {
641
709
 
642
710
 
643
711
  module.exports = {
644
- titulo, c, logo, logoComMascote, letreiro, convite, EXEMPLOS, mascot, MASCOTES, resolverMascote, mascoteInfo, galeriaMascotes, reacao, welcomeBox, Spinner, PENSANDO, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
712
+ titulo, agora, c, logo, logoComMascote, mascoteGrande, logoAnimado, letreiro, convite, EXEMPLOS, mascot, MASCOTES, resolverMascote, mascoteInfo, galeriaMascotes, reacao, welcomeBox, Spinner, PENSANDO, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
package/lib/voz.js CHANGED
@@ -278,6 +278,8 @@ function abrir({ python: py, servidor, token, voz, aoOuvir, mostrar = () => {},
278
278
  pronto,
279
279
  falar: (texto) => { const t = paraFalar(texto); if (t) manda({ tipo: 'falar', texto: t }); },
280
280
  calar: () => manda({ tipo: 'calar' }),
281
+ // A bolinha na tela: 'esperando' quando o terminal termina sem falar.
282
+ estado: (e) => manda({ tipo: 'estado', estado: e }),
281
283
  };
282
284
  }
283
285
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.4.2",
3
+ "version": "10.4.4",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). 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": {
@@ -44,6 +44,50 @@ sys.path.insert(0, str(AQUI.parent))
44
44
  from primo_voz import escuta, transcrever # noqa: E402
45
45
 
46
46
 
47
+ class Bolinha:
48
+ """A orb (orbe.py) num processo à parte: este só diz o estado a ela.
49
+
50
+ Sem tkinter, ou se ela morrer, o modo voz segue igual — a bolinha é o
51
+ rosto, não o ouvido. Mudanças repetidas não são reenviadas, e o nível da
52
+ voz vai no máximo 15 vezes por segundo."""
53
+
54
+ def __init__(self) -> None:
55
+ self.p = None
56
+ self.ultimo = None
57
+ self.quando = 0.0
58
+ if os.environ.get("PRIMOCODE_SEM_ORB") == "1":
59
+ return
60
+ try:
61
+ self.p = subprocess.Popen([sys.executable, "-m", "primo_voz.orbe"], cwd=str(AQUI.parent),
62
+ stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
63
+ env={**os.environ, "PYTHONPATH": str(AQUI.parent)}, text=True)
64
+ except Exception:
65
+ self.p = None
66
+
67
+ def estado(self, estado: str, nivel: float = None) -> None:
68
+ if not self.p or self.p.poll() is not None:
69
+ return
70
+ agora = time.time()
71
+ if estado == self.ultimo and (nivel is None or agora - self.quando < 1 / 15):
72
+ return
73
+ self.ultimo, self.quando = estado, agora
74
+ msg = {"estado": estado}
75
+ if nivel is not None:
76
+ msg["nivel"] = round(min(1.0, nivel), 3)
77
+ try:
78
+ self.p.stdin.write(json.dumps(msg) + "\n")
79
+ self.p.stdin.flush()
80
+ except Exception:
81
+ pass
82
+
83
+ def fechar(self) -> None:
84
+ if self.p:
85
+ try:
86
+ self.p.stdin.close()
87
+ except Exception:
88
+ pass
89
+
90
+
47
91
  def falar_para_o_node(obj: dict) -> None:
48
92
  """Uma linha de JSON no stdout. Nada mais pode sair por aí."""
49
93
  sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
@@ -100,6 +144,7 @@ class Voz:
100
144
  transcrever.aquecer()
101
145
  self.gravador = escuta.Gravador()
102
146
  self.corte = escuta.Corte()
147
+ self.bolinha = Bolinha()
103
148
  self._blocos: "queue.Queue" = queue.Queue()
104
149
  self._falas: "queue.Queue" = queue.Queue()
105
150
  self._vivo = True
@@ -138,6 +183,7 @@ class Voz:
138
183
  self.parar()
139
184
  return
140
185
  falar_para_o_node({"tipo": "pronto"})
186
+ self.bolinha.estado("esperando")
141
187
  while self._vivo:
142
188
  self.gravador.comecar()
143
189
  acabou = False
@@ -147,6 +193,10 @@ class Voz:
147
193
  except queue.Empty:
148
194
  continue
149
195
  acabou = self.gravador.alimentar(bloco)
196
+ # A bolinha acende com a voz da pessoa, no tamanho da voz.
197
+ if self.gravador._falando and not self._falando:
198
+ lim = max(1e-6, self.gravador._limiar())
199
+ self.bolinha.estado("ouvindo", min(1.0, escuta.rms(bloco) / lim * 0.35))
150
200
  if not self._vivo:
151
201
  break
152
202
  if self.gravador.encerrou_por == "ninguem-falou" or self.gravador.segundos < 0.4:
@@ -154,9 +204,14 @@ class Voz:
154
204
  wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
155
205
  if not wav:
156
206
  continue
207
+ self.bolinha.estado("pensando")
157
208
  texto = transcrever.limpar(self._transcrever(wav))
158
209
  if texto:
210
+ # Pensando até o terminal dizer o contrário (a resposta em
211
+ # voz, ou {"tipo":"estado"} quando ele termina sem falar).
159
212
  falar_para_o_node({"tipo": "ouvi", "texto": texto})
213
+ else:
214
+ self.bolinha.estado("esperando")
160
215
  mic.fechar()
161
216
 
162
217
  def _transcrever(self, wav: Path) -> str:
@@ -213,10 +268,12 @@ class Voz:
213
268
  return
214
269
  self._falando = True
215
270
  self._parar_fala.clear()
271
+ self.bolinha.estado("falando", 0.6)
216
272
  try:
217
273
  self._tocar(mp3)
218
274
  finally:
219
275
  self._falando = False
276
+ self.bolinha.estado("esperando")
220
277
  self.corte.nivel_dele = 0.0
221
278
  # O que o microfone pegou enquanto ele falava é a voz dele.
222
279
  self._esvaziar_blocos()
@@ -283,6 +340,8 @@ class Voz:
283
340
  self._falas.get_nowait()
284
341
  except queue.Empty:
285
342
  break
343
+ elif tipo == "estado":
344
+ self.bolinha.estado(str(msg.get("estado") or "esperando"))
286
345
  elif tipo == "fim":
287
346
  break
288
347
  self.parar()
@@ -292,6 +351,7 @@ class Voz:
292
351
  return
293
352
  self._vivo = False
294
353
  self._parar_fala.set()
354
+ self.bolinha.fechar()
295
355
  falar_para_o_node({"tipo": "fim"})
296
356
 
297
357
  def rodar(self) -> None:
@@ -0,0 +1,444 @@
1
+ """orbe.py — a única coisa que aparece na tela no modo voz.
2
+
3
+ "Quero que ele fique na parte inferior da tela, bem centralizado, um pouco
4
+ maior [...] mas ele tá rodando em uma caixa, deve ser totalmente
5
+ transparente." (23/09/2026)
6
+
7
+ ── O QUE ERA A "CAIXA" ─────────────────────────────────────────────────────
8
+ Medido com uma captura da própria janela, no Tk 9 do macOS:
9
+
10
+ 1. `overrideredirect(True)` NÃO tira a moldura: a janela nascia com barra de
11
+ título, os três botões e o nome "Primo Code Voice".
12
+ 2. O fundo `systemTransparent` saía PRETO.
13
+ 3. O Canvas não apaga o quadro anterior sobre fundo transparente: cada
14
+ partícula deixava um rastro, e em segundos a janela virava um borrão.
15
+
16
+ Nenhum dos três se conserta de dentro do Tk. Então, no macOS, a orb é
17
+ desenhada pelo próprio sistema (PyObjC): uma NSWindow sem borda, sem fundo e
18
+ sem sombra, e uma NSView que limpa e pinta cada quadro com transparência de
19
+ verdade — o brilho é um gradiente com alfa, não discos misturados contra uma
20
+ cor de fundo adivinhada. Só a orb aparece; o resto da janela não existe.
21
+
22
+ O Tk continua sendo quem roda o laço de eventos (é ele que o resto do modo
23
+ voz já usa), e segue como desenho de reserva fora do macOS ou sem PyObjC.
24
+
25
+ ── ONDE ELA MORA ───────────────────────────────────────────────────────────
26
+ Embaixo, centralizada na ÁREA ÚTIL da tela — acima do Dock, não atrás dele.
27
+ A posição é aplicada depois de a janela existir: dada cedo demais, a janela
28
+ sem moldura ignora.
29
+
30
+ ── O QUE ELA DESENHA ───────────────────────────────────────────────────────
31
+ Um núcleo com brilho suave e um enxame de partículas em órbita. A cor diz o
32
+ estado; o MOVIMENTO diz a intensidade — as partículas se afastam e aceleram
33
+ com a voz, e respiram devagar quando ele está pensando. Nenhuma palavra: quem
34
+ está falando não lê.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import math
39
+ import queue
40
+ import random
41
+ import sys
42
+ import tkinter as tk
43
+ from typing import Callable, List, Optional, Tuple
44
+
45
+ # Os quatro estados, e a cor de cada um. A paleta é a do PrimoCode (ciano de
46
+ # marca, âmbar de aviso) para o modo voz não parecer outro produto.
47
+ CORES = {
48
+ "esperando": ("#22b8d8", "#0e3a4a"), # ciano calmo: pronto, sem pressa
49
+ "ouvindo": ("#22d3ee", "#0e7490"), # ciano vivo: a vez é sua
50
+ "pensando": ("#fbbf24", "#78350f"), # âmbar: ele está trabalhando
51
+ "falando": ("#67e8f9", "#0891b2"), # ciano claro: a voz é dele
52
+ }
53
+
54
+ LARGURA = 360 # só a área da orb e do brilho — o resto é transparente
55
+ ALTURA = 220
56
+ RAIO_BASE = 34 # o núcleo
57
+ MARGEM_INFERIOR = 24 # acima da área útil (o Dock já fica de fora)
58
+ PARTICULAS = 44
59
+ FUNDO_FALSO = "#05070a" # só no desenho de reserva, onde não há alfa
60
+
61
+
62
+ def _rgb(cor: str) -> Tuple[float, float, float]:
63
+ return int(cor[1:3], 16) / 255, int(cor[3:5], 16) / 255, int(cor[5:7], 16) / 255
64
+
65
+
66
+ def _mistura(a: str, b: str, quanto: float) -> str:
67
+ """Duas cores hex, uma fração — a cor no meio do caminho."""
68
+ quanto = max(0.0, min(1.0, quanto))
69
+ ra, ga, ba = int(a[1:3], 16), int(a[3:5], 16), int(a[5:7], 16)
70
+ rb, gb, bb = int(b[1:3], 16), int(b[3:5], 16), int(b[5:7], 16)
71
+ return "#%02x%02x%02x" % (
72
+ int(ra + (rb - ra) * quanto),
73
+ int(ga + (gb - ga) * quanto),
74
+ int(ba + (bb - ba) * quanto),
75
+ )
76
+
77
+
78
+ class Particula:
79
+ """Um ponto em órbita.
80
+
81
+ Cada uma tem ângulo, raio e velocidade PRÓPRIOS, sorteados uma vez. É o
82
+ que separa um enxame de um relógio: com os mesmos valores para todas,
83
+ elas giram em formação e o olho lê como engrenagem, não como vida.
84
+ """
85
+
86
+ def __init__(self, rnd: random.Random) -> None:
87
+ self.ang = rnd.uniform(0, math.tau)
88
+ self.raio = rnd.uniform(1.25, 2.3) # múltiplo do raio do núcleo
89
+ self.giro = rnd.uniform(-0.9, 0.9) or 0.4
90
+ self.tam = rnd.uniform(1.3, 3.0)
91
+ self.fase = rnd.uniform(0, math.tau)
92
+ self.brilho = rnd.uniform(0.45, 1.0)
93
+
94
+
95
+ class Quadro:
96
+ """Um quadro da animação, sem saber quem vai pintá-lo."""
97
+
98
+ def __init__(self, vivo: str, fundo: str, raio: float, pulso: float,
99
+ particulas: List[Tuple[float, float, float, float]]):
100
+ self.vivo, self.fundo = vivo, fundo
101
+ self.raio, self.pulso = raio, pulso
102
+ self.particulas = particulas # (x, y, tamanho, intensidade 0..1)
103
+
104
+
105
+ # ── O desenho nativo do macOS ────────────────────────────────────────────
106
+
107
+ def _criar_nativa(orbe: "Orbe"):
108
+ """A janela sem borda e sem fundo, desenhada pelo macOS. None se não der."""
109
+ if sys.platform != "darwin":
110
+ return None
111
+ try:
112
+ import objc
113
+ from AppKit import (NSBackingStoreBuffered, NSBezierPath, NSColor,
114
+ NSCompositingOperationClear, NSGradient, NSMakeRect,
115
+ NSRectFillUsingOperation, NSScreen, NSView, NSWindow)
116
+ except Exception:
117
+ return None
118
+
119
+ def cor(hexa: str, alfa: float):
120
+ r, g, b = _rgb(hexa)
121
+ return NSColor.colorWithSRGBRed_green_blue_alpha_(r, g, b, max(0.0, min(1.0, alfa)))
122
+
123
+ # A classe Objective-C só pode ser registrada uma vez por processo.
124
+ try:
125
+ Vista = objc.lookUpClass("PrimoOrbeVista")
126
+ except objc.nosuchclass_error:
127
+ class PrimoOrbeVista(NSView):
128
+ def isOpaque(self):
129
+ return False
130
+
131
+ def acceptsFirstMouse_(self, evento):
132
+ return True
133
+
134
+ def drawRect_(self, rect):
135
+ NSColor.clearColor().set()
136
+ NSRectFillUsingOperation(self.bounds(), NSCompositingOperationClear)
137
+ q = getattr(self, "quadro", None)
138
+ if q is None:
139
+ return
140
+ cx, cy = LARGURA / 2, ALTURA / 2
141
+ centro = (cx, cy)
142
+
143
+ # BRILHO: um gradiente radial com alfa, que some no nada. É o
144
+ # que o Tk não sabia fazer — e o que tira o "disco escuro".
145
+ alcance = q.raio * (2.6 + q.pulso * 0.9)
146
+ brilho = NSGradient.alloc().initWithColors_(
147
+ [cor(q.vivo, 0.34 + q.pulso * 0.20), cor(q.vivo, 0.10), cor(q.vivo, 0.0)])
148
+ brilho.drawFromCenter_radius_toCenter_radius_options_(centro, q.raio * 0.6, centro, alcance, 0)
149
+
150
+ # PARTÍCULAS
151
+ for (px, py, t, forca) in q.particulas:
152
+ cor(q.vivo, 0.25 + forca * 0.75).set()
153
+ NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(px - t, py - t, 2 * t, 2 * t)).fill()
154
+
155
+ # NÚCLEO: gradiente do claro ao vivo, com um anel fino por fora.
156
+ r = q.raio
157
+ nucleo = NSGradient.alloc().initWithColors_(
158
+ [cor(_mistura(q.vivo, "#ffffff", 0.55), 1.0), cor(q.vivo, 1.0),
159
+ cor(_mistura(q.vivo, q.fundo, 0.45), 1.0)])
160
+ nucleo.drawFromCenter_radius_toCenter_radius_options_(
161
+ (cx - r * 0.25, cy + r * 0.25), 0, centro, r, 0)
162
+ cor(q.vivo, 0.55).set()
163
+ anel = NSBezierPath.bezierPathWithOvalInRect_(
164
+ NSMakeRect(cx - r * 1.18, cy - r * 1.18, r * 2.36, r * 2.36))
165
+ anel.setLineWidth_(1.5)
166
+ anel.stroke()
167
+
168
+ Vista = PrimoOrbeVista
169
+
170
+ try:
171
+ janela = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
172
+ NSMakeRect(0, 0, LARGURA, ALTURA), 0, NSBackingStoreBuffered, False) # 0 = sem borda
173
+ janela.setOpaque_(False)
174
+ janela.setBackgroundColor_(NSColor.clearColor())
175
+ janela.setHasShadow_(False)
176
+ janela.setLevel_(25) # NSStatusWindowLevel: por cima de tudo
177
+ # O clique ATRAVESSA a orb: ela mora no pé da tela, em cima do Dock e
178
+ # do que o cursor do Primo precisa clicar. Uma orb que segura o clique
179
+ # faria o agente clicar nela em vez de no app.
180
+ janela.setIgnoresMouseEvents_(True)
181
+ janela.setCollectionBehavior_(1 | 16) # todos os espaços; fora do Exposé
182
+ janela.setReleasedWhenClosed_(False)
183
+ vista = Vista.alloc().initWithFrame_(NSMakeRect(0, 0, LARGURA, ALTURA))
184
+ janela.setContentView_(vista)
185
+
186
+ # Embaixo, centralizada na área útil (acima do Dock). O Cocoa conta
187
+ # de baixo para cima, então a margem é somada ao pé da área útil.
188
+ util = NSScreen.mainScreen().visibleFrame()
189
+ x = util.origin.x + (util.size.width - LARGURA) / 2
190
+ y = util.origin.y + MARGEM_INFERIOR
191
+ janela.setFrameOrigin_((x, y))
192
+ janela.orderFrontRegardless()
193
+ return janela, vista
194
+ except Exception:
195
+ return None
196
+
197
+
198
+ class Orbe:
199
+ """A janela da orb. Roda na thread principal, como toda interface."""
200
+
201
+ def __init__(self, ao_fechar: Optional[Callable] = None):
202
+ self.raiz = tk.Tk()
203
+ self.raiz.title("Primo Code Voice")
204
+ self.estado = "esperando"
205
+ self.visivel = True
206
+ self.nivel = 0.0 # 0..1, o quanto a pessoa está falando
207
+ self._fase = 0.0
208
+ self._fila = queue.Queue()
209
+ self._ao_fechar = ao_fechar
210
+ self._vivo = True
211
+ self._rnd = random.Random(7) # semente fixa: o enxame é sempre o mesmo
212
+ self._particulas = [Particula(self._rnd) for _ in range(PARTICULAS)]
213
+
214
+ nativa = _criar_nativa(self)
215
+ if nativa:
216
+ # O macOS desenha; a janela do Tk fica escondida e só roda o laço.
217
+ self.janela, self.vista = nativa
218
+ self.nativa = True
219
+ self.raiz.withdraw()
220
+ else:
221
+ self.janela = self.vista = None
222
+ self.nativa = False
223
+ self._montar_tk()
224
+
225
+ self.raiz.protocol("WM_DELETE_WINDOW", self.fechar)
226
+ self.raiz.bind("<Escape>", lambda e: self.fechar())
227
+ self._pintar()
228
+
229
+ # ── a reserva em Tk (fora do macOS, ou sem PyObjC) ───────────────────
230
+ def _montar_tk(self) -> None:
231
+ self.raiz.overrideredirect(True)
232
+ self.fundo = FUNDO_FALSO
233
+ if sys.platform == "win32":
234
+ # No Windows o Tk dá transparência por cor-chave, e ela funciona.
235
+ try:
236
+ self.raiz.attributes("-transparentcolor", FUNDO_FALSO)
237
+ except tk.TclError:
238
+ pass
239
+ try:
240
+ self.raiz.attributes("-topmost", True)
241
+ except tk.TclError:
242
+ pass
243
+ self.raiz.configure(bg=self.fundo)
244
+ self.tela = tk.Canvas(self.raiz, width=LARGURA, height=ALTURA,
245
+ bg=self.fundo, highlightthickness=0, bd=0)
246
+ self.tela.pack()
247
+ self.raiz.update_idletasks()
248
+ self._posicionar_tk()
249
+ self.raiz.after(120, self._posicionar_tk)
250
+ self.raiz.bind("<Button-1>", self._pegar)
251
+ self.raiz.bind("<B1-Motion>", self._arrastar)
252
+ self._pego = (0, 0)
253
+
254
+ def _posicionar_tk(self) -> None:
255
+ x = (self.raiz.winfo_screenwidth() - LARGURA) // 2
256
+ y = self.raiz.winfo_screenheight() - ALTURA - MARGEM_INFERIOR - 48
257
+ try:
258
+ self.raiz.geometry(f"{LARGURA}x{ALTURA}+{x}+{max(0, y)}")
259
+ except tk.TclError:
260
+ pass
261
+
262
+ def _pegar(self, e) -> None:
263
+ self._pego = (e.x_root - self.raiz.winfo_x(), e.y_root - self.raiz.winfo_y())
264
+
265
+ def _arrastar(self, e) -> None:
266
+ self.raiz.geometry(f"+{e.x_root - self._pego[0]}+{e.y_root - self._pego[1]}")
267
+
268
+ # ── a animação ───────────────────────────────────────────────────────
269
+ def _quadro(self) -> Quadro:
270
+ self._fase += 0.045
271
+ vivo, fundo = CORES.get(self.estado, CORES["esperando"])
272
+ cx, cy = LARGURA / 2, ALTURA / 2
273
+
274
+ # A respiração de cada estado. Quem espera respira devagar; quem
275
+ # pensa, num ritmo próprio; quem ouve pulsa com a VOZ, não com o
276
+ # relógio — é o que faz a orb parecer estar escutando de verdade.
277
+ if self.estado == "ouvindo":
278
+ pulso = 0.25 + self.nivel * 1.15
279
+ elif self.estado == "pensando":
280
+ pulso = 0.42 + 0.30 * math.sin(self._fase * 2.1)
281
+ elif self.estado == "falando":
282
+ pulso = 0.55 + 0.38 * math.sin(self._fase * 3.4)
283
+ else:
284
+ pulso = 0.16 + 0.10 * math.sin(self._fase * 0.9)
285
+ raio = RAIO_BASE * (1 + pulso * 0.30)
286
+
287
+ pontos = []
288
+ for p in self._particulas:
289
+ p.ang += p.giro * 0.012 * (0.6 + pulso)
290
+ # O raio respira junto com a voz: quanto mais alto se fala, mais
291
+ # longe o enxame se abre.
292
+ osc = math.sin(self._fase * 1.7 + p.fase)
293
+ r = raio * (p.raio + 0.25 * osc + pulso * 0.40)
294
+ # Elipse leve, mais larga que alta — e dentro da janela: o que
295
+ # passa da borda é cortado seco, e partícula cortada é defeito.
296
+ px = cx + math.cos(p.ang) * r * 1.2
297
+ py = cy + math.sin(p.ang) * r * 0.74
298
+ t = p.tam * (0.75 + pulso * 0.6)
299
+ pontos.append((px, py, t, p.brilho * (0.5 + pulso * 0.5)))
300
+ return Quadro(vivo, fundo, raio, pulso, pontos)
301
+
302
+ def _pintar(self) -> None:
303
+ if not self._vivo:
304
+ return
305
+ while True:
306
+ try:
307
+ estado, nivel = self._fila.get_nowait()
308
+ if estado == "__esconder__":
309
+ self._aplicarVisibilidade(False)
310
+ continue
311
+ if estado == "__mostrar__":
312
+ self._aplicarVisibilidade(True)
313
+ continue
314
+ if estado == "__fechar__":
315
+ self.fechar()
316
+ return
317
+ if estado:
318
+ self.estado = estado
319
+ if nivel is not None:
320
+ self.nivel = max(0.0, min(1.0, nivel))
321
+ except queue.Empty:
322
+ break
323
+
324
+ q = self._quadro()
325
+ if self.nativa:
326
+ self.vista.quadro = q
327
+ self.vista.setNeedsDisplay_(True)
328
+ else:
329
+ self._pintar_tk(q)
330
+ self.raiz.after(33, self._pintar)
331
+
332
+ def _pintar_tk(self, q: Quadro) -> None:
333
+ """A reserva: forma chapada, sem alfa. Misturas contra o fundo."""
334
+ cx, cy = LARGURA / 2, ALTURA / 2
335
+ self.tela.delete("all")
336
+ for i in range(6, 0, -1):
337
+ f = i / 6
338
+ r = q.raio * (1 + f * 1.3)
339
+ self.tela.create_oval(cx - r, cy - r, cx + r, cy + r,
340
+ outline=_mistura(self.fundo, q.vivo, (1 - f) ** 1.6 * 0.7), width=2)
341
+ for (px, py, t, forca) in q.particulas:
342
+ self.tela.create_oval(px - t, py - t, px + t, py + t,
343
+ fill=_mistura(self.fundo, q.vivo, 0.3 + forca * 0.7), outline="")
344
+ r = q.raio
345
+ self.tela.create_oval(cx - r, cy - r, cx + r, cy + r,
346
+ fill=_mistura(q.fundo, q.vivo, 0.8), outline="")
347
+ rn = r * 0.55
348
+ self.tela.create_oval(cx - rn, cy - rn, cx + rn, cy + rn,
349
+ fill=_mistura(q.vivo, "#ffffff", 0.4), outline="")
350
+
351
+ # ── visibilidade ─────────────────────────────────────────────────────
352
+ def esconder(self) -> None:
353
+ """Some da tela sem morrer.
354
+
355
+ No modo de espera ele fica horas sem nada acontecer, e uma orb parada
356
+ na tela todo esse tempo é estorvo. Destruir e recriar a janela a cada
357
+ despertar pisca e, em alguns sistemas, rouba o foco de onde a pessoa
358
+ estava digitando. Esconder guarda tudo de pé.
359
+ """
360
+ self._fila.put(("__esconder__", None))
361
+
362
+ def mostrar(self) -> None:
363
+ self._fila.put(("__mostrar__", None))
364
+
365
+ def _aplicarVisibilidade(self, visivel: bool) -> None:
366
+ # Só na thread da interface: nem o Tk nem o AppKit perdoam chamada de fora.
367
+ try:
368
+ if self.nativa:
369
+ if visivel:
370
+ self.janela.orderFrontRegardless()
371
+ else:
372
+ self.janela.orderOut_(None)
373
+ elif visivel:
374
+ self.raiz.deiconify()
375
+ self.raiz.lift()
376
+ if sys.platform != "linux":
377
+ self.raiz.attributes("-topmost", True)
378
+ else:
379
+ self.raiz.withdraw()
380
+ except Exception:
381
+ pass # janela já fechando: nada a fazer
382
+ self.visivel = visivel
383
+
384
+ # ── ciclo de vida ────────────────────────────────────────────────────
385
+ def mudar(self, estado: Optional[str] = None, nivel: Optional[float] = None) -> None:
386
+ self._fila.put((estado, nivel))
387
+
388
+ def fechar(self) -> None:
389
+ if not self._vivo:
390
+ return
391
+ self._vivo = False
392
+ try:
393
+ if self._ao_fechar:
394
+ self._ao_fechar()
395
+ except Exception:
396
+ pass
397
+ try:
398
+ if self.janela is not None:
399
+ self.janela.orderOut_(None)
400
+ self.janela.close()
401
+ except Exception:
402
+ pass
403
+ try:
404
+ self.raiz.destroy()
405
+ except Exception:
406
+ pass
407
+
408
+ def rodar(self) -> None:
409
+ self.raiz.mainloop()
410
+
411
+
412
+ # ── como processo próprio (10.4.4) ──────────────────────────────────────
413
+ # A orb voltou, mas SÓ como desenho: "é a bolinha do primo que aparece na
414
+ # tela quando falo" (Joel, 10/10). Ela saiu na 10.0 junto com o cérebro
415
+ # paralelo do modo voz; o cérebro continua fora. Roda num processo à parte
416
+ # (a janela precisa da thread principal, e a do primo_voz é do microfone),
417
+ # e só obedece: uma linha de JSON por mudança no stdin.
418
+ #
419
+ # {"estado": "ouvindo", "nivel": 0.6} esperando | ouvindo | pensando | falando
420
+ # {"esconder": true} / {"mostrar": true}
421
+ #
422
+ # O stdin fechar (o modo voz acabou) fecha a orb.
423
+ def _ler_comandos(orbe: "Orbe") -> None:
424
+ import json as _json
425
+ for linha in sys.stdin:
426
+ try:
427
+ msg = _json.loads(linha)
428
+ except ValueError:
429
+ continue
430
+ if msg.get("esconder"):
431
+ orbe.mudar("__esconder__")
432
+ elif msg.get("mostrar"):
433
+ orbe.mudar("__mostrar__")
434
+ else:
435
+ nivel = msg.get("nivel")
436
+ orbe.mudar(msg.get("estado"), float(nivel) if nivel is not None else None)
437
+ orbe.mudar("__fechar__")
438
+
439
+
440
+ if __name__ == "__main__":
441
+ import threading as _threading
442
+ _orbe = Orbe()
443
+ _threading.Thread(target=_ler_comandos, args=(_orbe,), daemon=True).start()
444
+ _orbe.rodar()