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 +2 -2
- package/bin/primocode.js +10 -2
- package/lib/claude-engine.js +1 -1
- package/lib/entrada.js +44 -7
- package/lib/local/motor-cli.js +14 -11
- package/lib/tela.js +14 -1
- package/lib/ui.js +73 -5
- package/lib/voz.js +2 -0
- package/package.json +1 -1
- package/voz/primo_voz/__main__.py +60 -0
- package/voz/primo_voz/orbe.py +444 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v10.4.
|
|
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.
|
|
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());
|
package/lib/claude-engine.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
package/lib/local/motor-cli.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
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 =
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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 =
|
|
397
|
+
const m = mascoteGrande(style);
|
|
365
398
|
const l = logo();
|
|
366
|
-
|
|
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.
|
|
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()
|