primocode 9.2.3 → 9.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v9.2.3
1
+ # PrimoCode v9.2.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 v9.2.3
12
+ ▐▛███▜▌ Primo Code v9.2.4
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/bin/primocode.js CHANGED
@@ -151,6 +151,7 @@ const SLASH_COMANDOS = [
151
151
  ['/account', 'sua conta, plano e computadores conectados'],
152
152
  ['/chats', 'suas conversas salvas, as mesmas do app e do celular'],
153
153
  ['/logout', 'desconecta este computador da conta'],
154
+ ['!comando', 'roda direto no shell, sem passar pelo agente (o campo fica âmbar)'],
154
155
  ['/voice', 'fica ouvindo em segundo plano — diga "ei, primo" e a orb aparece'],
155
156
  ['/voice parar', 'desliga o modo voz e fecha o microfone'],
156
157
  ['/skills', 'o que ele sabe fazer bem — ligue e desligue'],
@@ -1029,6 +1030,62 @@ async function runChat(promptForModel, userTextForMemory) {
1029
1030
 
1030
1031
  function isSlash(text) { return text.trim().startsWith('/'); }
1031
1032
 
1033
+ /* ── "!comando" RODA NO SHELL ────────────────────────────────────────────
1034
+ *
1035
+ * "quando eu mande uma mensagem que comece com ! ele rode em bash"
1036
+ *
1037
+ * Por que não mandar ao agente e deixar ele chamar run_shell: porque são
1038
+ * coisas diferentes. Pedir ao agente é dizer o QUE se quer e deixá-lo
1039
+ * decidir; o "!" é dizer o COMANDO. Quem digita `!git status` não quer que
1040
+ * alguém interprete a intenção — quer a saída do git status, agora, sem
1041
+ * volta ao modelo, sem gastar cota e sem o risco de o agente achar que
1042
+ * entendeu melhor.
1043
+ *
1044
+ * A saída sai CRUA, como no terminal: ela não passa pelo desenhador de
1045
+ * Markdown. Saída de comando tem alinhamento próprio (colunas do `ls`,
1046
+ * tabela do `docker ps`), e reformatá-la seria estragá-la.
1047
+ */
1048
+ function ehBash(text) { return /^\s*!/.test(String(text || '')); }
1049
+
1050
+ async function rodarBash(text) {
1051
+ const comando = String(text).replace(/^\s*!/, '').trim();
1052
+ if (!comando) return;
1053
+
1054
+ const pasta = state.workDir || process.cwd();
1055
+ console.log('');
1056
+ console.log(' ' + c.warn('$ ') + c.white(comando));
1057
+
1058
+ const inicio = Date.now();
1059
+ const r = await tools.execute('run_shell', { command: comando, timeout: 600000 },
1060
+ { projectDir: pasta });
1061
+ const segundos = ((Date.now() - inicio) / 1000).toFixed(1);
1062
+
1063
+ /* Crua, e inteira. `run_shell` corta em 10 MB, que é o teto do buffer;
1064
+ cortar mais aqui esconderia justamente a linha do erro, que costuma
1065
+ estar no fim. */
1066
+ const saida = [r.stdout, r.stderr].filter((x) => String(x || '').trim()).join('\n');
1067
+ if (saida) console.log(saida.replace(/\n$/, ''));
1068
+
1069
+ if (r.ok) {
1070
+ console.log(' ' + c.dim(`ok · ${segundos}s`));
1071
+ } else if (r.killed) {
1072
+ console.log(' ' + c.warn(`interrompido depois de ${segundos}s`));
1073
+ } else {
1074
+ console.log(' ' + c.warn(`saiu com ${r.code} · ${segundos}s`));
1075
+ }
1076
+ console.log('');
1077
+
1078
+ /* O agente FICA SABENDO, e isto é o que torna o "!" útil dentro de uma
1079
+ conversa: rodar o teste e depois perguntar "por que falhou?" só
1080
+ funciona se ele tiver visto a saída. Sem isto seriam dois mundos
1081
+ separados na mesma tela. */
1082
+ state.history.push({ role: 'user', content: `Rodei no shell: ${comando}` });
1083
+ state.history.push({
1084
+ role: 'assistant',
1085
+ content: `Saída (código ${r.code ?? 0}):\n${saida.slice(0, 4000) || '(vazia)'}`,
1086
+ });
1087
+ }
1088
+
1032
1089
  /* ── OS NOMES ANTIGOS ────────────────────────────────────────────────────
1033
1090
  * Os comandos passaram a ser todos em inglês. Metade já era; a outra metade
1034
1091
  * estava em português, e a mistura era o que a especificação chamou de
@@ -1430,7 +1487,14 @@ async function handleSlash(text) {
1430
1487
  }
1431
1488
 
1432
1489
  const pronto = vozModo.conferir();
1433
- if (!pronto.ok && !pronto.instalavel) {
1490
+ /* `.length`, e não a lista: [] é VERDADEIRO em JavaScript.
1491
+ Quando só falta o tkinter — que o pip não instala, porque ele
1492
+ vem com o Python ou num pacote do sistema — a lista de
1493
+ instaláveis volta VAZIA. Sem o `.length`, este ramo era pulado,
1494
+ o "Instalar agora?" aparecia, e o pip era chamado sem pacote
1495
+ nenhum: "You must give at least one requirement to install".
1496
+ Visto na máquina do dono. */
1497
+ if (!pronto.ok && !(pronto.instalavel && pronto.instalavel.length)) {
1434
1498
  console.log(c.err(` ${pronto.error}`));
1435
1499
  if (pronto.comoResolver) console.log(c.muted(` ${pronto.comoResolver}`));
1436
1500
  break;
@@ -2070,7 +2134,8 @@ async function main() {
2070
2134
  const text = queue.shift().trim();
2071
2135
  if (!text) continue;
2072
2136
  try {
2073
- if (isSlash(text)) await handleSlash(text);
2137
+ if (ehBash(text)) await rodarBash(text);
2138
+ else if (isSlash(text)) await handleSlash(text);
2074
2139
  else await sendPrompt(text);
2075
2140
  } catch (e) {
2076
2141
  fala.problema(e);
package/lib/entrada.js CHANGED
@@ -115,16 +115,29 @@ function montar(e) {
115
115
  toda linha e a preencher o resto com espaço, e é ela que faz o texto
116
116
  colado parecer desalinhado quando o terminal é estreito. Sem as laterais
117
117
  o campo ganha as colunas de volta e a emenda some. */
118
+ /* MODO BASH: a linha que começa com "!" roda no shell, e a caixa INTEIRA
119
+ muda de cor para avisar.
120
+
121
+ O aviso é o ponto. Um comando de shell faz coisa irreversível, e a
122
+ diferença entre "pedir ao agente" e "rodar direto" não pode depender de
123
+ a pessoa lembrar do que digitou três segundos atrás: ela tem de VER, no
124
+ momento em que digita, que aquele Enter vai ao shell. Daí o âmbar — a
125
+ mesma cor que o resto do programa usa para "atenção, não é o caminho
126
+ comum". */
127
+ const bash = /^\s*!/.test(String(e.texto));
128
+
118
129
  const out = [];
119
- const regua = c.dim(BORDA.h.repeat(largura));
130
+ const regua = (bash ? c.warn : c.dim)(BORDA.h.repeat(largura));
120
131
  out.push(regua);
121
132
 
122
133
  const vazio = !String(e.texto).length;
123
134
  linhas.forEach((l, i) => {
124
135
  const marca = i === 0
125
- ? (e.ocupado ? c.dim('❯ ') : c.brand('❯ '))
136
+ ? (e.ocupado ? c.dim('❯ ') : bash ? c.warn('$ ') : c.brand('❯ '))
126
137
  : c.dim('┆ ');
127
- let conteudo = c.white(l.texto);
138
+ // O "!" some do texto desenhado: quem vê "$ " já sabe que é shell, e
139
+ // mostrar os dois é mostrar a mesma informação duas vezes.
140
+ let conteudo = c.white(i === 0 && bash ? l.texto.replace(/^\s*!/, '') : l.texto);
128
141
  // O fantasma só existe na última linha e só com o cursor no fim dela:
129
142
  // desenhado no meio do texto, ele empurraria o que vem depois.
130
143
  if (i === linhas.length - 1 && e.fantasma && cursor === String(e.texto).length) {
@@ -162,12 +175,22 @@ function montar(e) {
162
175
  }
163
176
  }
164
177
 
178
+ /* A linha que explica, só no modo bash. Ela ocupa o lugar do rodapé
179
+ normal de propósito: no instante em que a pessoa vai rodar um comando,
180
+ o que importa não é lembrar os atalhos, é saber onde ele vai rodar. */
181
+ if (bash) {
182
+ out.push(' ' + c.warn('roda no shell') + c.dim(`, em ${e.pasta || 'a pasta atual'}`)
183
+ + c.dim(' · apague o ! para voltar ao normal'));
184
+ }
165
185
  for (const linha of (e.status || [])) out.push(' ' + linha);
166
186
 
167
187
  return {
168
188
  linhas: out,
169
189
  cursorLinha: 1 + cursorLinha, // +1 pela régua de cima
170
- cursorColuna: 1 + 2 + cursorColuna, // espaço + '❯ '
190
+ /* No modo bash o "!" não é desenhado, então tudo depois dele anda uma
191
+ coluna para a esquerda — e o cursor tem de andar junto, ou ele
192
+ aparece um caractere à frente de onde o texto realmente está. */
193
+ cursorColuna: 1 + 2 + cursorColuna - (bash && cursorLinha === 0 ? 1 : 0),
171
194
  };
172
195
  }
173
196
 
package/lib/skills.js CHANGED
@@ -47,6 +47,7 @@
47
47
  const fs = require('fs');
48
48
  const os = require('os');
49
49
  const path = require('path');
50
+ const crypto = require('crypto');
50
51
 
51
52
  const ORIGEM = path.join(__dirname, '..', 'skills');
52
53
  const CASA = path.join(os.homedir(), '.primocode', 'skills');
@@ -90,24 +91,98 @@ function salvarEstado(estado) {
90
91
  } catch { return false; }
91
92
  }
92
93
 
93
- /* As que vêm com o produto são copiadas na primeira vez. Copiar SEMPRE por
94
- cima seria desfazer a edição de quem melhorou uma delas; copiar só o que
95
- falta deixa o usuário dono do que ele mexeu. */
94
+ /* A impressão digital do conteúdo. Serve para responder UMA pergunta: o
95
+ arquivo que está na casa da pessoa é o que o produto instalou, ou ela
96
+ mexeu nele? */
97
+ function impressao(texto) {
98
+ return crypto.createHash('sha256').update(String(texto || ''), 'utf8').digest('hex').slice(0, 16);
99
+ }
100
+
101
+ /**
102
+ * Traz as skills do produto para a casa da pessoa — e ATUALIZA as que ela não
103
+ * tocou.
104
+ *
105
+ * ── O DEFEITO QUE ISTO CONSERTA ─────────────────────────────────────────
106
+ * Era `if (fs.existsSync(destino)) continue;`. Uma vez instalada, a skill do
107
+ * produto NUNCA mais era atualizada. Quem rodou o PrimoCode um dia ficou com
108
+ * a versão daquele dia para sempre, e toda melhoria que veio depois — texto
109
+ * melhor, gatilho novo, regra corrigida — nunca chegou a ninguém. Do lado de
110
+ * quem usa, o sintoma é "as skills não estão funcionando": elas rodam, só que
111
+ * são as de antes.
112
+ *
113
+ * ── E POR QUE NÃO SOBRESCREVER SEMPRE ───────────────────────────────────
114
+ * Porque a pessoa pode ter editado. Sobrescrever apagaria o trabalho dela sem
115
+ * avisar, que é pior que a skill desatualizada. Então guarda-se, ao instalar,
116
+ * a impressão digital do que foi instalado: se o arquivo ainda bate com ela,
117
+ * ninguém mexeu e dá para atualizar em silêncio. Se não bate, a edição é
118
+ * dela e fica como está.
119
+ *
120
+ * A skill CRIADA pela pessoa não tem impressão nenhuma, e por isso nunca é
121
+ * tocada — não veio do produto.
122
+ */
96
123
  function prepararCasa() {
97
124
  fs.mkdirSync(CASA, { recursive: true });
98
125
  let nascidas = 0;
126
+ let atualizadas = 0;
127
+ const preservadas = [];
128
+ const guardadas = [];
99
129
  let origens = [];
100
130
  try { origens = fs.readdirSync(ORIGEM, { withFileTypes: true }); } catch { return 0; }
131
+
101
132
  for (const item of origens) {
102
133
  if (!item.isDirectory()) continue;
103
134
  const destino = path.join(CASA, item.name);
104
- if (fs.existsSync(destino)) continue;
135
+ const arquivoDestino = path.join(destino, 'SKILL.md');
136
+ const marca = path.join(destino, '.origem.json');
137
+ let novo;
138
+ try { novo = fs.readFileSync(path.join(ORIGEM, item.name, 'SKILL.md'), 'utf8'); }
139
+ catch { continue; }
140
+
105
141
  try {
106
- fs.mkdirSync(destino, { recursive: true });
107
- fs.copyFileSync(path.join(ORIGEM, item.name, 'SKILL.md'), path.join(destino, 'SKILL.md'));
108
- nascidas++;
142
+ if (!fs.existsSync(arquivoDestino)) {
143
+ fs.mkdirSync(destino, { recursive: true });
144
+ fs.writeFileSync(arquivoDestino, novo, 'utf8');
145
+ fs.writeFileSync(marca, JSON.stringify({ sha: impressao(novo) }), 'utf8');
146
+ nascidas++;
147
+ continue;
148
+ }
149
+
150
+ const atual = fs.readFileSync(arquivoDestino, 'utf8');
151
+ if (impressao(atual) === impressao(novo)) continue; // já é a nova
152
+
153
+ let instalada = null;
154
+ try { instalada = JSON.parse(fs.readFileSync(marca, 'utf8')).sha; } catch { /* sem marca */ }
155
+
156
+ if (instalada && instalada === impressao(atual)) {
157
+ // Intacta desde a instalação: pode atualizar sem perder nada.
158
+ fs.writeFileSync(arquivoDestino, novo, 'utf8');
159
+ fs.writeFileSync(marca, JSON.stringify({ sha: impressao(novo) }), 'utf8');
160
+ atualizadas++;
161
+ } else if (instalada) {
162
+ // Tem marca e não bate: a pessoa editou. Fica como está — o
163
+ // trabalho dela vale mais que a atualização.
164
+ preservadas.push(item.name);
165
+ } else {
166
+ /* SEM MARCA: instalada antes desta correção existir.
167
+ Aqui não dá para saber se a pessoa editou ou se é só uma
168
+ versão antiga do produto — e as duas saídas puras são
169
+ ruins: preservar congela a skill para sempre (que é o
170
+ defeito que estamos consertando, e atingiria justamente
171
+ quem já usa), sobrescrever pode apagar o trabalho dela.
172
+
173
+ Então atualiza E GUARDA O ANTERIOR ao lado. Ninguém perde
174
+ nada, todo mundo recebe a versão nova, e quem tinha algo
175
+ próprio encontra o arquivo ali para trazer de volta. Vale
176
+ uma vez só: da próxima já existe marca. */
177
+ try { fs.writeFileSync(arquivoDestino + '.anterior', atual, 'utf8'); } catch {}
178
+ fs.writeFileSync(arquivoDestino, novo, 'utf8');
179
+ fs.writeFileSync(marca, JSON.stringify({ sha: impressao(novo) }), 'utf8');
180
+ atualizadas++;
181
+ guardadas.push(item.name);
182
+ }
109
183
  } catch { /* uma que falha não impede as outras */ }
110
184
  }
185
+ prepararCasa.ultimo = { nascidas, atualizadas, preservadas, guardadas };
111
186
  return nascidas;
112
187
  }
113
188
 
@@ -323,6 +398,7 @@ function montar(texto, op = {}) {
323
398
  }
324
399
 
325
400
  module.exports = {
401
+ impressao,
326
402
  CASA, ORIGEM, MAX_CARACTERES,
327
403
  separar, listar, ler, criar, ligar, apagar, instalarDe, apelidar,
328
404
  paraOPedido, montar, pontuar, prepararCasa,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.2.3",
3
+ "version": "9.3.0",
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": {
@@ -1,28 +1,75 @@
1
1
  ---
2
2
  nome: Página que converte
3
- descricao: estrutura e acabamento de landing page, site e página de venda
4
- quando: landing, landing page, site, página, pagina, home, hero, seção, secao, cta, conversão, conversao, vendas, institucional
3
+ descricao: estrutura, direção de arte e acabamento de landing page, site e página de venda
4
+ quando: landing, landing page, site, página, pagina, home, hero, seção, secao, cta, conversão, conversao, vendas, institucional, portfólio, portfolio, one page, onepage
5
5
  autor: PrimoCode
6
6
  ---
7
- Uma página que converte tem ordem, não mais elementos.
7
+ Evitar erro não produz beleza. A página genérica não nasce de um erro: nasce
8
+ da AUSÊNCIA de decisão — quem não escolhe tipografia usa a do sistema, quem não
9
+ escolhe paleta usa azul, quem não escolhe ritmo centraliza tudo. Então decida,
10
+ por escrito, ANTES de abrir o editor:
8
11
 
9
- **A ordem que funciona**: promessa clara em até 8 palavras → uma linha que
10
- explica para quem é → prova (número real, print, nome de cliente) → o que a
11
- pessoa ganha, em 3 blocos → objeção respondida → um único pedido de ação,
12
- repetido no fim.
12
+ **1. A DIREÇÃO, em uma frase.** "Editorial e severa, como um jornal econômico."
13
+ "Técnica e escura, como uma ferramenta de desenvolvedor." "Calorosa e
14
+ artesanal." Ela decide tudo o que vem depois, e assuntos diferentes não podem
15
+ receber a mesma.
13
16
 
14
- **Um CTA por página.** Dois pedidos de ação competem entre si e a pessoa não
15
- faz nenhum.
17
+ **2. A TIPOGRAFIA — duas famílias, e elas têm de CONTRASTAR.** Duas grotescas
18
+ parecidas é o que dá cara de modelo pronto. Combinações que funcionam:
16
19
 
17
- **O que denuncia página genérica** — não faça nada disto:
18
- gradiente roxo-para-azul no fundo, tudo centralizado do topo ao rodapé,
19
- sombra em todo cartão, emoji no lugar de ícone, "Bem-vindo ao nosso site",
20
- "Soluções inovadoras", texto de exemplo em qualquer lugar.
20
+ | Direção | Título | Texto |
21
+ |---|---|---|
22
+ | Editorial, autoridade | Playfair Display, Fraunces | Inter, Source Serif |
23
+ | Técnica, produto | Space Grotesk, Sora | Inter, IBM Plex Sans |
24
+ | Impacto, anúncio | Anton, Archivo Black | Inter, DM Sans |
25
+ | Calorosa, humana | Instrument Serif, Lora | Karla, Figtree |
21
26
 
22
- **Acabamento**: escala de espaço consistente (4/8/12/16/24/32/48), no máximo
23
- dois pesos de fonte, contraste de 4.5:1 no texto, botão com hover e
24
- focus-visible, largura de leitura entre 60 e 75 caracteres. Funciona de 360px
25
- a 1440px sem rolagem horizontal.
27
+ Título grande é grande DE VERDADE: `clamp(2.5rem, 6vw, 5.5rem)`, peso 700+,
28
+ `letter-spacing: -0.03em` (fonte grande precisa apertar; fonte pequena, não).
29
+ Texto em 17–19px, `line-height: 1.6`, largura de 60 a 75 caracteres.
26
30
 
27
- Escreva o texto de verdade, sobre o assunto de verdade. Uma página bonita com
31
+ **3. A PALETA — do ASSUNTO, nunca a padrão.** Uma cor de marca, uma neutra
32
+ escura para o texto, uma clara para o fundo, e UMA de destaque usada com
33
+ avareza — no CTA e em mais nada. Se a empresa existe, as cores são dela: leia
34
+ do site com `estudio_site`. Inventar a cor de uma marca real é erro que chega
35
+ pronto ao cliente.
36
+
37
+ **4. O RITMO VERTICAL.** Seção não tem todas a mesma altura nem o mesmo
38
+ tratamento. Alterne: fundo claro → fundo escuro → claro → imagem sangrando de
39
+ borda a borda. Respiro grande entre seções (96–160px), pequeno dentro delas.
40
+
41
+ **5. QUEBRE A SIMETRIA.** Tudo centralizado do topo ao rodapé é o sinal número
42
+ um de página feita às pressas. Herói em duas colunas 7/5, número grande
43
+ alinhado à esquerda com o texto encostado nele, imagem que ultrapassa a
44
+ margem. Grade de 12 colunas, e use-a de verdade.
45
+
46
+ **6. MOVIMENTO, com parcimônia.** Entrada por `IntersectionObserver`:
47
+ `opacity 0 → 1` e `translateY(16px → 0)`, 400ms, `cubic-bezier(.16,1,.3,1)`,
48
+ escalonado em 60ms entre irmãos. Estado de hover em tudo que é clicável.
49
+ Sempre dentro de `@media (prefers-reduced-motion: reduce)`.
50
+
51
+ **7. OS DETALHES QUE SEPARAM PROFISSIONAL DE AMADOR.**
52
+ Um raio de borda só, repetido. Sombra em camadas
53
+ (`0 1px 2px rgba(0,0,0,.06), 0 8px 24px rgba(0,0,0,.08)`), nunca a sombra
54
+ padrão em tudo. `font-variant-numeric: tabular-nums` em número. `text-wrap:
55
+ balance` em título. Ícone de biblioteca de verdade (Lucide, Phosphor), nunca
56
+ emoji. `:focus-visible` visível. Imagem com `width`/`height` declarados para
57
+ a página não pular ao carregar.
58
+
59
+ **8. ESTRUTURA que converte**: promessa em até 8 palavras → para quem é →
60
+ prova (número real, nome de cliente) → o que a pessoa ganha, em 3 blocos →
61
+ objeção respondida → UM pedido de ação, repetido no fim. Um CTA por página:
62
+ dois competem entre si e a pessoa não faz nenhum.
63
+
64
+ **O QUE DENUNCIA PÁGINA GENÉRICA** — nenhum destes:
65
+ gradiente roxo-para-azul, tudo centralizado, sombra igual em todo cartão,
66
+ emoji no lugar de ícone, "Bem-vindo ao nosso site", "Soluções inovadoras",
67
+ três cartões idênticos lado a lado, texto de exemplo em qualquer lugar.
68
+
69
+ **ANTES DE ENTREGAR**: abra no navegador e olhe. Confira em 360px, 768px e
70
+ 1440px, sem rolagem horizontal. Contraste de 4.5:1 no texto. Se a página
71
+ servisse para qualquer outra empresa trocando o logotipo, ela está genérica —
72
+ volte ao passo 1.
73
+
74
+ Escreva o texto de verdade, sobre o assunto de verdade. Página bonita com
28
75
  texto vazio não convence ninguém.
@@ -1,60 +1,90 @@
1
- """orbe.py — a interface inteira: um núcleo pequeno e um fundo fosco.
2
-
3
- "Redesenhar a interface: nada de fundo escuro cheio de elementos. Apenas
4
- um núcleo pequeno, tipo uma orb, com um fundo fosco/desfocado por trás.
5
- Ocultar transcrição, chat e qualquer painel. É só a orb e o áudio."
6
-
7
- ── O QUE ISTO NÃO TEM ──────────────────────────────────────────────────────
8
- Não há transcrição do que você falou, não há legenda do que ele está fazendo,
9
- não há resposta escrita, não há janela de conversa, não há barra de comando.
10
- Não é economia de trabalho: ler a resposta de um assistente de voz é trabalho,
11
- e ouvir não é. Se a interface mostrasse o texto, a pessoa leria — e aí o modo
12
- voz vira um chat com um microfone.
13
-
14
- O que a orb comunica é o ESTADO, por cor e movimento: parada quando espera,
15
- pulsando quando ouve, girando devagar quando pensa, brilhando quando fala.
16
- Quatro estados, nenhuma palavra.
17
-
18
- ── POR QUE tkinter ─────────────────────────────────────────────────────────
19
- Ele vem com o Python. Qualquer outra escolha (Qt, GTK, pywebview, Electron)
20
- significaria dezenas ou centenas de megabytes para desenhar um círculo — e o
21
- `/voice` já precisa instalar sounddevice e numpy. Cada dependência a mais é
22
- uma chance a mais de o modo voz não abrir na máquina de alguém.
23
-
24
- ── O FUNDO FOSCO, E O QUE ELE É DE VERDADE ─────────────────────────────────
25
- Desfoque real do que está ATRÁS da janela não existe de forma portátil: no
26
- macOS é NSVisualEffectView, no Windows é DwmEnableBlurBehindWindow, no Linux
27
- depende do compositor. Nenhum deles está no Python padrão.
28
-
29
- O que dá para fazer em todo lugar é uma camada escura semitransparente por
30
- cima da tela inteira. Não é desfoque; é escurecimento. A diferença aparece num
31
- fundo cheio de detalhe, e some num fundo comum.
32
-
33
- Onde o desfoque de verdade existe sem custo — macOS com pyobjc já instalado —
34
- ele é usado. Onde não existe, a camada escura entra, e a orb continua sendo o
35
- que importa. O que não se faz é fingir: nada aqui chama de "desfoque" o que é
36
- só uma sombra.
1
+ """orbe.py — a única coisa que aparece na tela no modo voz.
2
+
3
+ "ia ser a flutuante ali na parte inferior da tela, bem centralizada,
4
+ toda cheia de partículas, toda bonita."
5
+
6
+ ── POR QUE A PRIMEIRA VERSÃO FICOU FEIA, E A CULPA É DE QUATRO COISAS ──────
7
+
8
+ 1. `-transparentcolor` SÓ EXISTE NO WINDOWS. No macOS ele levanta TclError,
9
+ que o código engolia — e a janela ficava com o fundo opaco pintado nela.
10
+ Era o "quadrado feio": não era desenho, era a janela inteira aparecendo.
11
+ No macOS a transparência de verdade se pede de outro jeito:
12
+ `-transparent` mais o fundo mágico `systemTransparent`.
13
+
14
+ 2. O VÉU cobria a tela inteira com 72% de preto. A ideia era destacar a orb;
15
+ o efeito é escurecer o trabalho da pessoa enquanto ela fala. Saiu.
16
+
17
+ 3. Ela nascia no CENTRO da tela — bem no meio do que a pessoa está fazendo.
18
+ Agora mora embaixo, centralizada na horizontal, como uma barra flutuante.
19
+
20
+ 4. Não havia partícula nenhuma. Havia três círculos concêntricos.
21
+
22
+ ── O QUE ELA DESENHA ───────────────────────────────────────────────────────
23
+ Um núcleo com halo suave e um enxame de partículas em órbita. A cor diz o
24
+ estado; o MOVIMENTO diz a intensidade — as partículas se afastam e aceleram
25
+ com a voz, e respiram devagar quando ele está pensando. Nenhuma palavra: quem
26
+ está falando não lê.
27
+
28
+ ── O QUE NÃO DÁ PARA FAZER EM TKINTER, E COMO SE CONTORNA ─────────────────
29
+ Não há desfoque nem gradiente: o Canvas do Tk desenha forma chapada. O halo é
30
+ feito de círculos concêntricos com a cor interpolada contra o fundo — o
31
+ mesmo truque de sempre, e o motivo de o fundo precisar ser conhecido. Onde a
32
+ janela é transparente de verdade, o "fundo" para a mistura é o preto, que é o
33
+ que mais se aproxima de qualquer tela.
37
34
  """
38
35
  from __future__ import annotations
39
36
 
40
37
  import math
41
38
  import queue
39
+ import random
42
40
  import sys
43
- import threading
44
41
  import tkinter as tk
45
42
  from typing import Callable, Optional
46
43
 
47
44
  # Os quatro estados, e a cor de cada um. A paleta é a do PrimoCode (ciano de
48
45
  # marca, âmbar de aviso) para o modo voz não parecer outro produto.
49
46
  CORES = {
50
- "esperando": ("#0891b2", "#164e63"), # ciano recuado: pronto, sem pressa
47
+ "esperando": ("#0891b2", "#0e3a4a"), # ciano recuado: pronto, sem pressa
51
48
  "ouvindo": ("#22d3ee", "#0e7490"), # ciano vivo: a vez é sua
52
49
  "pensando": ("#fbbf24", "#78350f"), # âmbar: ele está trabalhando
53
50
  "falando": ("#67e8f9", "#0891b2"), # ciano claro: a voz é dele
54
51
  }
55
52
 
56
- TAMANHO = 220 # a janela da orb, em pixels
57
- RAIO_BASE = 46 # o núcleo
53
+ LARGURA = 340 # a janela, deitada: é uma barra flutuante, não um disco
54
+ ALTURA = 170
55
+ RAIO_BASE = 26 # o núcleo
56
+ MARGEM_INFERIOR = 72 # distância do pé da tela
57
+ PARTICULAS = 34
58
+ FUNDO_FALSO = "#05070a" # só onde o sistema não dá transparência
59
+
60
+
61
+ def _mistura(a: str, b: str, quanto: float) -> str:
62
+ """Duas cores hex, uma fração — a cor no meio do caminho."""
63
+ quanto = max(0.0, min(1.0, quanto))
64
+ ra, ga, ba = int(a[1:3], 16), int(a[3:5], 16), int(a[5:7], 16)
65
+ rb, gb, bb = int(b[1:3], 16), int(b[3:5], 16), int(b[5:7], 16)
66
+ return "#%02x%02x%02x" % (
67
+ int(ra + (rb - ra) * quanto),
68
+ int(ga + (gb - ga) * quanto),
69
+ int(ba + (bb - ba) * quanto),
70
+ )
71
+
72
+
73
+ class Particula:
74
+ """Um ponto em órbita.
75
+
76
+ Cada uma tem ângulo, raio e velocidade PRÓPRIOS, sorteados uma vez. É o
77
+ que separa um enxame de um relógio: com os mesmos valores para todas,
78
+ elas giram em formação e o olho lê como engrenagem, não como vida.
79
+ """
80
+
81
+ def __init__(self, rnd: random.Random) -> None:
82
+ self.ang = rnd.uniform(0, math.tau)
83
+ self.raio = rnd.uniform(1.0, 2.2) # múltiplo do raio do núcleo
84
+ self.giro = rnd.uniform(-0.9, 0.9) or 0.4
85
+ self.tam = rnd.uniform(1.2, 3.0)
86
+ self.fase = rnd.uniform(0, math.tau)
87
+ self.brilho = rnd.uniform(0.35, 1.0)
58
88
 
59
89
 
60
90
  class Orbe:
@@ -70,61 +100,76 @@ class Orbe:
70
100
  self._fila = queue.Queue()
71
101
  self._ao_fechar = ao_fechar
72
102
  self._vivo = True
103
+ self._rnd = random.Random(7) # semente fixa: o enxame é sempre o mesmo
104
+ self._particulas = [Particula(self._rnd) for _ in range(PARTICULAS)]
73
105
 
74
- self._montar_veu()
75
- self._montar_orbe()
106
+ self._montar()
76
107
  self.raiz.protocol("WM_DELETE_WINDOW", self.fechar)
77
108
  self.raiz.bind("<Escape>", lambda e: self.fechar())
78
109
  self._pintar()
79
110
 
80
- # ── o véu ────────────────────────────────────────────────────────────
81
- def _montar_veu(self) -> None:
82
- """A camada escura sobre a tela inteira.
111
+ # ── a janela ─────────────────────────────────────────────────────────
112
+ def _montar(self) -> None:
113
+ """Sem moldura, embaixo, centralizada — e transparente onde dá.
83
114
 
84
- `-alpha` é o único jeito portátil de ter transparência de janela em
85
- tkinter, e ele vale para a janela INTEIRA — por isso o véu é uma
86
- janela separada da orb: se fossem a mesma, a orb ficaria translúcida
87
- junto com o fundo, e um núcleo translúcido some.
115
+ A transparência se pede de um jeito em cada sistema, e pedir errado
116
+ não avisa: devolve TclError, que engolido deixa a janela OPACA. Era
117
+ exatamente o "quadrado feio". Por isso aqui cada caminho é explícito,
118
+ e o fundo falso só entra quando nenhum funcionou.
88
119
  """
89
- self.veu = tk.Toplevel(self.raiz)
90
- self.veu.overrideredirect(True)
91
- largura = self.veu.winfo_screenwidth()
92
- altura = self.veu.winfo_screenheight()
93
- self.veu.geometry(f"{largura}x{altura}+0+0")
94
- self.veu.configure(bg="#05070a")
95
- try:
96
- self.veu.attributes("-alpha", 0.72)
97
- self.veu.attributes("-topmost", True)
98
- except tk.TclError:
99
- pass
100
- # O véu não recebe clique: a pessoa continua usando o computador por
101
- # baixo dele. Onde o sistema não deixa, ele simplesmente cobre.
102
- try:
103
- self.veu.attributes("-transparent", True)
104
- except tk.TclError:
105
- pass
106
-
107
- # ── a orb ────────────────────────────────────────────────────────────
108
- def _montar_orbe(self) -> None:
109
120
  self.raiz.overrideredirect(True)
110
- largura = self.raiz.winfo_screenwidth()
111
- altura = self.raiz.winfo_screenheight()
112
- x = (largura - TAMANHO) // 2
113
- y = (altura - TAMANHO) // 2
114
- self.raiz.geometry(f"{TAMANHO}x{TAMANHO}+{x}+{y}")
115
- self.raiz.configure(bg="#05070a")
121
+ larguraTela = self.raiz.winfo_screenwidth()
122
+ alturaTela = self.raiz.winfo_screenheight()
123
+ x = (larguraTela - LARGURA) // 2
124
+ y = alturaTela - ALTURA - MARGEM_INFERIOR
125
+ self.raiz.geometry(f"{LARGURA}x{ALTURA}+{x}+{max(0, y)}")
126
+
127
+ self.fundo = FUNDO_FALSO
128
+ transparente = False
129
+
130
+ if sys.platform == "darwin":
131
+ # macOS: `-transparent` mais o fundo mágico. Os dois juntos, ou
132
+ # nenhum dos dois funciona.
133
+ try:
134
+ self.raiz.attributes("-transparent", True)
135
+ self.fundo = "systemTransparent"
136
+ transparente = True
137
+ except tk.TclError:
138
+ pass
139
+ elif sys.platform == "win32":
140
+ try:
141
+ self.raiz.attributes("-transparentcolor", FUNDO_FALSO)
142
+ transparente = True
143
+ except tk.TclError:
144
+ pass
145
+
116
146
  try:
117
147
  self.raiz.attributes("-topmost", True)
118
- # A cor de fundo vira transparente onde o sistema deixa (Windows),
119
- # e no resto ela é a mesma do véu — o que dá o mesmo efeito.
120
- self.raiz.attributes("-transparentcolor", "#05070a")
121
148
  except tk.TclError:
122
149
  pass
123
150
 
124
- self.tela = tk.Canvas(self.raiz, width=TAMANHO, height=TAMANHO,
125
- bg="#05070a", highlightthickness=0)
151
+ self.raiz.configure(bg=self.fundo)
152
+ self.tela = tk.Canvas(self.raiz, width=LARGURA, height=ALTURA,
153
+ bg=self.fundo, highlightthickness=0, bd=0)
126
154
  self.tela.pack()
127
155
 
156
+ # Para a MISTURA de cor do halo, o fundo tem de ser um valor de cor —
157
+ # e "systemTransparent" não é. Contra o preto é a aproximação que
158
+ # some melhor em qualquer tela.
159
+ self.fundoDaMistura = FUNDO_FALSO if transparente else self.fundo
160
+
161
+ # Sem moldura não há barra de título para arrastar: o arrasto é na
162
+ # janela toda. Quem quiser a orb noutro canto, leva.
163
+ self.raiz.bind("<Button-1>", self._pegar)
164
+ self.raiz.bind("<B1-Motion>", self._arrastar)
165
+ self._pego = (0, 0)
166
+
167
+ def _pegar(self, e) -> None:
168
+ self._pego = (e.x_root - self.raiz.winfo_x(), e.y_root - self.raiz.winfo_y())
169
+
170
+ def _arrastar(self, e) -> None:
171
+ self.raiz.geometry(f"+{e.x_root - self._pego[0]}+{e.y_root - self._pego[1]}")
172
+
128
173
  # ── o desenho ────────────────────────────────────────────────────────
129
174
  def _pintar(self) -> None:
130
175
  if not self._vivo:
@@ -132,124 +177,112 @@ class Orbe:
132
177
  while True:
133
178
  try:
134
179
  estado, nivel = self._fila.get_nowait()
135
- if estado == '__esconder__':
136
- self._aplicar_visibilidade(False)
180
+ if estado == "__esconder__":
181
+ self._aplicarVisibilidade(False)
137
182
  continue
138
- if estado == '__mostrar__':
139
- self._aplicar_visibilidade(True)
183
+ if estado == "__mostrar__":
184
+ self._aplicarVisibilidade(True)
140
185
  continue
186
+ if estado:
187
+ self.estado = estado
188
+ if nivel is not None:
189
+ self.nivel = max(0.0, min(1.0, nivel))
141
190
  except queue.Empty:
142
191
  break
143
- if estado:
144
- self.estado = estado
145
- if nivel is not None:
146
- self.nivel = max(0.0, min(1.0, nivel))
147
192
 
148
- self.tela.delete("all")
193
+ self._fase += 0.045
149
194
  vivo, fundo = CORES.get(self.estado, CORES["esperando"])
150
- centro = TAMANHO / 2
151
- self._fase += 0.06
195
+ cx, cy = LARGURA / 2, ALTURA / 2
196
+ self.tela.delete("all")
152
197
 
153
- # O movimento é diferente em cada estado, e é ele que diz o que está
154
- # acontecendo — sem uma palavra escrita.
198
+ # A respiração de cada estado. Quem espera respira devagar; quem
199
+ # pensa, num ritmo próprio; quem ouve pulsa com a VOZ, não com o
200
+ # relógio — é o que faz a orb parecer estar escutando de verdade.
155
201
  if self.estado == "ouvindo":
156
- # Responde à VOZ: o núcleo cresce com o que ela está falando.
157
- raio = RAIO_BASE * (1 + 0.35 * self.nivel)
158
- pulso = 1.0
202
+ pulso = 0.25 + self.nivel * 1.15
159
203
  elif self.estado == "pensando":
160
- # Respiração lenta: trabalho em andamento, sem pressa aparente.
161
- raio = RAIO_BASE * (1 + 0.06 * math.sin(self._fase * 0.8))
162
- pulso = 0.8 + 0.2 * math.sin(self._fase * 0.8)
204
+ pulso = 0.42 + 0.30 * math.sin(self._fase * 2.1)
163
205
  elif self.estado == "falando":
164
- raio = RAIO_BASE * (1 + 0.12 * math.sin(self._fase * 2.4))
165
- pulso = 1.0
206
+ pulso = 0.55 + 0.38 * math.sin(self._fase * 3.4)
166
207
  else:
167
- raio = RAIO_BASE * (1 + 0.03 * math.sin(self._fase * 0.5))
168
- pulso = 0.55
169
-
170
- # O halo: anéis concêntricos cada vez mais fracos. É o que faz a orb
171
- # parecer luz em vez de um círculo pintado — tkinter não tem gradiente,
172
- # então o degradê é feito com camadas.
173
- camadas = 7
174
- for i in range(camadas, 0, -1):
175
- r = raio * (1 + i * 0.28)
176
- cor = self._misturar(fundo, "#05070a", i / camadas * 0.85)
177
- self.tela.create_oval(centro - r, centro - r, centro + r, centro + r,
178
- fill=cor, outline="")
179
-
180
- self.tela.create_oval(centro - raio, centro - raio, centro + raio, centro + raio,
181
- fill=self._misturar(vivo, fundo, 1 - pulso), outline="")
182
- # O brilho de cima, deslocado: dá volume à esfera.
183
- br = raio * 0.42
184
- self.tela.create_oval(centro - br, centro - raio * 0.62,
185
- centro + br * 0.5, centro - raio * 0.05,
186
- fill=self._misturar("#ffffff", vivo, 0.62), outline="")
208
+ pulso = 0.16 + 0.10 * math.sin(self._fase * 0.9)
209
+
210
+ raio = RAIO_BASE * (1 + pulso * 0.42)
211
+
212
+ # HALO: círculos concêntricos, do mais largo e apagado ao núcleo. É o
213
+ # desfoque que o Canvas não tem — e por isso a cor de cada anel é
214
+ # interpolada contra o fundo, em vez de ter opacidade.
215
+ for i in range(11, 0, -1):
216
+ f = i / 11
217
+ r = raio * (1 + f * 2.6)
218
+ cor = _mistura(self.fundoDaMistura, fundo, (1 - f) ** 2 * 0.85)
219
+ self.tela.create_oval(cx - r, cy - r, cx + r, cy + r, fill=cor, outline="")
220
+
221
+ # PARTÍCULAS: entre o halo e o núcleo, para o núcleo ficar por cima.
222
+ for p in self._particulas:
223
+ p.ang += p.giro * 0.012 * (0.6 + pulso)
224
+ # O raio respira junto com a voz: quanto mais alto se fala, mais
225
+ # longe o enxame se abre.
226
+ osc = math.sin(self._fase * 1.7 + p.fase)
227
+ r = raio * (p.raio + 0.30 * osc + pulso * 0.55)
228
+ px = cx + math.cos(p.ang) * r * 1.55 # elipse: a janela é deitada
229
+ py = cy + math.sin(p.ang) * r * 0.80
230
+ t = p.tam * (0.7 + pulso * 0.8)
231
+ cor = _mistura(self.fundoDaMistura, vivo, p.brilho * (0.35 + pulso * 0.65))
232
+ self.tela.create_oval(px - t, py - t, px + t, py + t, fill=cor, outline="")
233
+
234
+ # NÚCLEO: dois discos, o de dentro mais claro. Dá volume sem gradiente.
235
+ self.tela.create_oval(cx - raio, cy - raio, cx + raio, cy + raio,
236
+ fill=_mistura(fundo, vivo, 0.72), outline="")
237
+ rn = raio * 0.55
238
+ self.tela.create_oval(cx - rn, cy - rn, cx + rn, cy + rn,
239
+ fill=_mistura(vivo, "#ffffff", 0.35), outline="")
187
240
 
188
241
  self.raiz.after(33, self._pintar)
189
242
 
190
- @staticmethod
191
- def _misturar(a: str, b: str, quanto: float) -> str:
192
- """`a` misturado com `b`. tkinter não tem alpha por forma, então a
193
- transparência é resolvida na cor."""
194
- quanto = max(0.0, min(1.0, quanto))
195
- ra, ga, ba = int(a[1:3], 16), int(a[3:5], 16), int(a[5:7], 16)
196
- rb, gb, bb = int(b[1:3], 16), int(b[3:5], 16), int(b[5:7], 16)
197
- r = round(ra + (rb - ra) * quanto)
198
- g = round(ga + (gb - ga) * quanto)
199
- bl = round(ba + (bb - ba) * quanto)
200
- return f"#{r:02x}{g:02x}{bl:02x}"
201
-
202
- # ── de fora ──────────────────────────────────────────────────────────
203
- def mudar(self, estado: Optional[str] = None, nivel: Optional[float] = None) -> None:
204
- """Chamado de OUTRAS threads. tkinter não é seguro fora da thread
205
- principal, então o que chega vai para uma fila e é lido no desenho."""
206
- self._fila.put((estado, nivel))
207
-
243
+ # ── visibilidade ─────────────────────────────────────────────────────
208
244
  def esconder(self) -> None:
209
245
  """Some da tela sem morrer.
210
246
 
211
- O modo de espera fica MINUTOS ou horas sem nada acontecer, e uma orbe
212
- parada no meio do monitor todo esse tempo é um estorvo — ela cobre o
213
- que a pessoa está fazendo. Então ela some e volta quando chamada.
214
-
215
- Destruir e recriar a janela a cada despertar seria o caminho óbvio, e é
216
- o errado: recriar uma Tk leva centenas de milissegundos, pisca, e em
217
- alguns sistemas rouba o foco da janela onde a pessoa estava digitando.
218
- `withdraw` tira da tela e guarda tudo de pé.
247
+ No modo de espera ele fica horas sem nada acontecer, e uma orb parada
248
+ na tela todo esse tempo é estorvo. Destruir e recriar a janela a cada
249
+ despertar seria o caminho óbvio, e é o errado: recriar uma Tk leva
250
+ centenas de milissegundos, pisca, e em alguns sistemas rouba o foco da
251
+ janela onde a pessoa estava digitando. `withdraw` guarda tudo de pé.
219
252
  """
220
253
  self._fila.put(("__esconder__", None))
221
254
 
222
255
  def mostrar(self) -> None:
223
- """Volta para a tela, no centro, por cima de tudo."""
224
256
  self._fila.put(("__mostrar__", None))
225
257
 
226
- def _aplicar_visibilidade(self, visivel: bool) -> None:
258
+ def _aplicarVisibilidade(self, visivel: bool) -> None:
227
259
  # Só na thread da interface: tkinter não perdoa chamada de fora.
228
260
  try:
229
261
  if visivel:
230
- self.veu.deiconify()
231
262
  self.raiz.deiconify()
232
263
  self.raiz.lift()
233
264
  if sys.platform != "linux":
234
265
  self.raiz.attributes("-topmost", True)
235
266
  else:
236
267
  self.raiz.withdraw()
237
- self.veu.withdraw()
238
268
  except Exception:
239
269
  pass # janela já fechando: nada a fazer
240
270
  self.visivel = visivel
241
271
 
272
+ # ── ciclo de vida ────────────────────────────────────────────────────
273
+ def mudar(self, estado: Optional[str] = None, nivel: Optional[float] = None) -> None:
274
+ self._fila.put((estado, nivel))
275
+
242
276
  def fechar(self) -> None:
243
277
  if not self._vivo:
244
278
  return
245
279
  self._vivo = False
246
- if self._ao_fechar:
247
- try:
280
+ try:
281
+ if self._ao_fechar:
248
282
  self._ao_fechar()
249
- except Exception:
250
- pass
283
+ except Exception:
284
+ pass
251
285
  try:
252
- self.raiz.quit()
253
286
  self.raiz.destroy()
254
287
  except Exception:
255
288
  pass