primocode 10.3.0-beta.0 → 10.4.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 v10.3.0-beta.0
1
+ # PrimoCode v10.4.0
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.3.0-beta.0
12
+ ▐▛███▜▌ Primo Code v10.4.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -17,6 +17,25 @@ está no limite de pedidos, o próximo atende. Você não vê o erro, e não pag
17
17
  Personalize o mascote (`primo`, `fox`, `cat`) e o nome com que ele te chama —
18
18
  veja `/mascot` e `/name`.
19
19
 
20
+ ## O que mudou na 10.4
21
+
22
+ - **Vídeo gravado em 3D (`estilo: personagem`).** Personagens modelados — gente
23
+ ou robô — conversando num set (podcast, estúdio, escritório). A boca segue a
24
+ voz letra por letra, eles piscam, gesticulam e viram a cabeça para quem fala.
25
+ A câmera corta como num programa gravado: aberto, ombro, close e detalhe,
26
+ com o balanço de câmera na mão. Por cima: legenda grossa palavra por
27
+ palavra, o nome de quem fala, o cartão de destaque, o REC e o grão de filme.
28
+ Peça "um podcast", "uma entrevista" ou "o Claude falando em 3D".
29
+ - **A peça do site, tirada pelo código.** `estudio_midia acao:elementos` lê o
30
+ site e lista logo, menu, título, botões, cartões, preço; `acao:elemento` tira
31
+ uma delas sozinha, em PNG transparente no dobro da resolução, com o código
32
+ (HTML, fonte, cor, raio) para redesenhar se quiser.
33
+ - **Recorte de fundo com IA sem instalar nada.** Com o `uv` na máquina, o
34
+ `acao:recortar` usa o rembg num ambiente à parte, em vez do corte por cor.
35
+ - **O logo do Claude saiu do cardápio dos vídeos.**
36
+ - **Com Claude, Codex ou chave própria, o agente não esquece o arquivo que
37
+ acabou de ler** (antes relia o mesmo arquivo várias vezes).
38
+
20
39
  ## O que mudou na 10.0
21
40
 
22
41
  - **A potência vem do sistema, não do modelo.** O mesmo pedido de landing
package/bin/primocode.js CHANGED
@@ -427,30 +427,21 @@ function ramoDoGit(dir) {
427
427
  return ramo;
428
428
  }
429
429
 
430
+ /* O que a caixa mostra além do texto, no desenho do OpenCode: DENTRO do
431
+ painel, o modo e o esforço ("Auto · alto" — o "Build · Nemotron" de lá);
432
+ FORA, uma linha só, com a pasta e o ramo à esquerda e o contexto e os
433
+ atalhos à direita. A cor da barra do painel é a do modo. */
434
+ const COR_MODO = { auto: '\x1b[38;5;75m', primocode: '\x1b[38;5;216m', chat: '\x1b[38;5;45m' };
435
+ const NOME_MODO = { auto: 'Auto', primocode: 'Primo Code', chat: 'Chat' };
436
+
430
437
  function barraDeStatus() {
431
438
  const partes = [];
432
439
 
433
- /* O CONTEXTO. Só aparece depois que há conversa: um "ctx 0%" na abertura
434
- é ruído, e ruído que aparece sempre é o que a pessoa aprende a não
435
- enxergar — inclusive quando marcar 90%. Âmbar a partir de 70% e
436
- vermelho a partir de 100%, que é quando `/compact` deixa de ser
437
- conselho e vira o que fazer. */
438
- const uso = memoria.usoDoContexto(state.memoria, state.history);
439
- if (uso.pct >= 5) {
440
- partes.push(uso.pct >= 100 ? c.err(`${uso.pct}% ctx`)
441
- : uso.pct >= 70 ? c.warn(`${uso.pct}% ctx`)
442
- : c.dim(`${uso.pct}% ctx`));
443
- }
444
-
445
- // O esforço, que é o que escolhe o modelo. Não há provedor para mostrar:
446
- // a API vem com o produto.
447
- partes.push(c.muted(effort.normalizar(state.config.effort)));
448
-
449
- // A PASTA. O nome dela, e o caminho curto quando não é um projeto — quem
450
- // apontou com /dir para fora do ~/primocode precisa ver onde está.
440
+ // A PASTA, e o ramo do git colado nela ("~/primocode/loja:main"), como
441
+ // o rodapé do OpenCode. Quem apontou com /dir para fora do ~/primocode
442
+ // precisa ver onde está.
451
443
  const dir = state.workDir || process.cwd();
452
444
  partes.push(c.brandDim(shortPath(dir)));
453
-
454
445
  const ramo = ramoDoGit(dir);
455
446
  if (ramo) partes.push(c.dim('⎇ ' + ramo));
456
447
 
@@ -463,25 +454,43 @@ function barraDeStatus() {
463
454
  esquece que deu — e ela vale até o PrimoCode fechar. */
464
455
  if (state.controle && state.controle.liberado) partes.push(c.warn('controle do pc'));
465
456
 
466
- // No auto o rótulo mostra "Auto" e, depois do primeiro pedido, qual modo
467
- // ele acabou usando — senão você nunca sabe quem respondeu.
468
- let modo;
469
- if (state.mode === 'auto') {
470
- const ultimo = state.modoEfetivo === 'primocode' ? c.warn('primo')
471
- : state.modoEfetivo === 'chat' ? c.brandDim('chat') : '';
472
- modo = c.brand('auto') + (ultimo ? c.dim('·') + ultimo : '');
473
- } else {
474
- modo = state.mode === 'primocode' ? c.warn('primo code') : c.brandDim('chat');
457
+ /* O CONTEXTO. Só aparece depois que há conversa: um "ctx 0%" na abertura
458
+ é ruído, e ruído que aparece sempre é o que a pessoa aprende a não
459
+ enxergar — inclusive quando marcar 90%. Âmbar a partir de 70% e
460
+ vermelho a partir de 100%, que é quando `/compact` deixa de ser
461
+ conselho e vira o que fazer. */
462
+ const direita = [];
463
+ const uso = memoria.usoDoContexto(state.memoria, state.history);
464
+ if (uso.pct >= 5) {
465
+ direita.push(uso.pct >= 100 ? c.err(`${uso.pct}% ctx`)
466
+ : uso.pct >= 70 ? c.warn(`${uso.pct}% ctx`)
467
+ : c.dim(`${uso.pct}% ctx`));
475
468
  }
476
- partes.push(modo);
477
-
478
- const primeira = partes.join(c.dim(' | '));
469
+ direita.push(state.trabalhando
470
+ ? c.warn('trabalhando') + c.dim(' · ') + c.white('esc esc') + c.dim(' para')
471
+ : c.white('tab') + c.dim(' modos ') + c.white('ctrl+p') + c.dim(' comandos'));
472
+
473
+ /* O modo DENTRO do painel. No auto, depois do primeiro pedido, ele diz
474
+ qual modo acabou respondendo — senão você nunca sabe quem respondeu. */
475
+ const cor = COR_MODO[state.mode] || COR_MODO.auto;
476
+ let modo = `${cor}${NOME_MODO[state.mode] || 'Auto'}${c.reset}`;
477
+ if (state.mode === 'auto' && state.modoEfetivo) {
478
+ modo += c.dim(' → ') + c.muted(NOME_MODO[state.modoEfetivo] || state.modoEfetivo);
479
+ }
480
+ modo += c.dim(' · ') + c.white(effort.normalizar(state.config.effort)) + ' ' + c.dim('PrimoCode');
479
481
 
480
- const atalhos = state.trabalhando
481
- ? c.warn('▸▸ trabalhando') + c.dim(' (Esc Esc interrompe)')
482
- : c.dim('/ comandos · ↑ histórico · \\ ou alt+enter quebra linha · Esc Esc interrompe');
482
+ return { modo, cor, esquerda: partes.join(c.dim(' · ')), direita: direita.join(c.dim(' ')) };
483
+ }
483
484
 
484
- return [primeira, atalhos];
485
+ /* O Tab com a caixa vazia gira o modo, como o "tab agents" do OpenCode:
486
+ auto → primo code → chat → auto. A escolha fica salva, como a do /mode. */
487
+ function trocarModo() {
488
+ const ordem = ['auto', 'primocode', 'chat'];
489
+ const m = ordem[(ordem.indexOf(state.mode) + 1) % ordem.length];
490
+ state.mode = m;
491
+ state.modoEfetivo = null;
492
+ cfg.saveConfig({ mode: m });
493
+ refreshPrompt();
485
494
  }
486
495
 
487
496
  // A barra se redesenha sozinha a cada quadro; isto só pede um quadro novo.
@@ -1043,10 +1052,18 @@ async function sendPrompt(userText) {
1043
1052
 
1044
1053
  // PRIMOCODE_TEMPOS=1: uma linha no fim com o custo de cada fase.
1045
1054
  tempos.comecar();
1055
+ const inicio = Date.now();
1046
1056
  try { await enviarPedido(userText); }
1047
1057
  finally {
1048
1058
  const linha = tempos.relatorio();
1049
1059
  if (linha) console.log(c.dim(` ⏱ ${linha}`));
1060
+ /* O fecho de cada resposta, como no OpenCode ("▣ Build · Nemotron ·
1061
+ 4.0s"): quem respondeu, com que esforço, e quanto levou. */
1062
+ const m = state.mode === 'auto' ? (state.modoEfetivo || 'auto') : state.mode;
1063
+ if (!state.semTerminal) {
1064
+ console.log('\n ' + `${COR_MODO[m] || COR_MODO.auto}▣${c.reset} ` + c.white(NOME_MODO[m] || m)
1065
+ + c.dim(` · ${effort.normalizar(state.config.effort)} · ${((Date.now() - inicio) / 1000).toFixed(1)}s`) + '\n');
1066
+ }
1050
1067
  }
1051
1068
  }
1052
1069
 
@@ -2690,16 +2707,22 @@ async function main() {
2690
2707
  process.exit(0);
2691
2708
  }
2692
2709
 
2710
+ /* A abertura do OpenCode: o nome e a caixa no MEIO da tela, não no pé de
2711
+ um terminal cheio. Quebras de linha empurram o que havia para o
2712
+ histórico do terminal (nada se perde, dá para rolar), o cursor volta ao
2713
+ topo e desce até o ponto em que o logo e a caixa ficam centrados. */
2714
+ if (process.stdout.isTTY && process.stdout.rows >= 20) {
2715
+ const rows = process.stdout.rows;
2716
+ const alto = 8 + 6; // o logo com a versão + a caixa
2717
+ process.stdout.write('\n'.repeat(rows) + '\x1b[H' + '\n'.repeat(Math.max(0, Math.floor((rows - alto) / 2) - 1)));
2718
+ }
2693
2719
  console.log(welcomeBox(VERSION, state.workDir ? shortPath(state.workDir) : null, {
2694
- modo: state.mode,
2695
- mascot: state.config.mascot,
2696
- userName: state.config.userName,
2697
2720
  pastaPadrao: state.workDirOrigem === 'padrao',
2698
2721
  }));
2699
2722
  rl = entrada.criar({
2700
2723
  status: barraDeStatus,
2701
2724
  comandos: comandosDoMenu,
2702
- vazio: 'peça alguma coisa, ou / para os comandos',
2725
+ vazio: ui.convite(),
2703
2726
  });
2704
2727
  // O spinner desenha dentro da caixa (lib/entrada.js mostrarSpinner).
2705
2728
  if (rl.tty) Spinner.caixa = rl;
@@ -2739,6 +2762,7 @@ async function main() {
2739
2762
  // Ctrl+C com a caixa vazia: sair é sair, mas sem deixar o último turno
2740
2763
  // pela metade — o sayBye espera a gravação subir.
2741
2764
  rl.on('sigint', () => rl.close());
2765
+ rl.on('trocar-modo', trocarModo);
2742
2766
 
2743
2767
  function sayBye() {
2744
2768
  if (saidBye) return;
package/lib/act.js CHANGED
@@ -333,6 +333,16 @@ function summarizeForModel(result) {
333
333
  // que o modelo precisa para decidir o próximo passo; os velhos viram peso.
334
334
  const RESULTADOS_INTEIROS = 3;
335
335
  const RESTO_DO_RESULTADO = 300;
336
+ /* ── COM MOTOR PRÓPRIO, A MEMÓRIA NÃO É AMPUTADA ────────────────────────
337
+ * Os 3 e os 300 de cima são para o teto de ~6 mil tokens por minuto do
338
+ * servidor grátis. Com Claude Code, Codex ou chave própria (`provedor.atual()`)
339
+ * eles valiam igual, e o modelo perdia o arquivo que tinha acabado de ler:
340
+ * no jogo de carro de 09/10 o Claude leu o world.js (458 linhas) sete vezes,
341
+ * em fatias, porque a cada três ferramentas a leitura anterior virava 300
342
+ * caracteres. A pre-leitura já folgava com motor próprio; o compactar agora
343
+ * também. */
344
+ const RESULTADOS_INTEIROS_FOLGADO = 20;
345
+ const RESTO_DO_RESULTADO_FOLGADO = 1500;
336
346
  // A pesquisa é MATÉRIA-PRIMA, não passo intermediário: é dela que saem os
337
347
  // fatos do vídeo, do post, do slide. Ela sempre fica velha (pesquisa-se no
338
348
  // começo, escreve-se no fim), e a 300 caracteres não sobrava fato nenhum — o
@@ -404,15 +414,17 @@ function encolherEscritas(m) {
404
414
  * Nenhuma mensagem é REMOVIDA: a API exige que cada role:'tool' continue
405
415
  * pareada com o tool_call que a pediu. Só o texto encolhe.
406
416
  */
407
- function compactar(messages) {
417
+ function compactar(messages, { folgado = false } = {}) {
408
418
  const { inteiras, duplicadas } = repeticao.referenciasNaConversa(messages);
419
+ const quantosInteiros = folgado ? RESULTADOS_INTEIROS_FOLGADO : RESULTADOS_INTEIROS;
420
+ const resto = folgado ? RESTO_DO_RESULTADO_FOLGADO : RESTO_DO_RESULTADO;
409
421
 
410
422
  const indices = [];
411
423
  for (let i = 0; i < messages.length; i++) if (messages[i].role === 'tool') indices.push(i);
412
- if (indices.length <= RESULTADOS_INTEIROS && !duplicadas.size && !assistentesAntigas(messages).size) return messages;
424
+ if (indices.length <= quantosInteiros && !duplicadas.size && !assistentesAntigas(messages).size) return messages;
413
425
 
414
426
  const chamadas = repeticao.chamadasPorId(messages);
415
- const encolher = new Set(indices.slice(0, -RESULTADOS_INTEIROS));
427
+ const encolher = new Set(indices.slice(0, -quantosInteiros));
416
428
  const antigas = assistentesAntigas(messages);
417
429
  return messages.map((m, i) => {
418
430
  if (antigas.has(i)) return encolherEscritas(m);
@@ -421,7 +433,7 @@ function compactar(messages) {
421
433
  }
422
434
  if (inteiras.has(i) || !encolher.has(i)) return m;
423
435
  const chamada = chamadas.get(m.tool_call_id);
424
- const limite = chamada && MATERIA_PRIMA.has(chamada.nome) ? RESTO_DA_PESQUISA : RESTO_DO_RESULTADO;
436
+ const limite = chamada && MATERIA_PRIMA.has(chamada.nome) ? Math.max(RESTO_DA_PESQUISA, resto) : resto;
425
437
  const texto = String(m.content || '');
426
438
  if (texto.length <= limite) return m;
427
439
  return { ...m, content: texto.slice(0, limite) + `…(resultado antigo encurtado; eram ${texto.length} caracteres)` };
@@ -984,7 +996,9 @@ async function runAct(userPrompt, opts) {
984
996
  emVoo = new AbortController();
985
997
  const tRodada = Date.now();
986
998
  try {
987
- const enviadas = compactar(messages);
999
+ let folgado = false;
1000
+ try { folgado = !!require('./local/provedor.js').atual(); } catch { /* sem provedor próprio */ }
1001
+ const enviadas = compactar(messages, { folgado });
988
1002
  traco(`volta ${voltas}${subagent ? ' (agente)' : ''} · ${enviadas.length} mensagens · ${JSON.stringify(enviadas).length} caracteres`);
989
1003
  await motor.requestAct(server, { messages: enviadas, model, effort, token, projectDir: pastaAtual, projectName, subagent, sinal: emVoo.signal }, (evt) => {
990
1004
  if (evt.erro) { streamError = evt.erro; return; }
package/lib/catalogo.js CHANGED
@@ -94,8 +94,8 @@ const GRUPOS = [
94
94
  + 'logo{titulo} cores{cores:[{nome,hex}]} produto{titulo,texto,destaque,selo,barras}. Em qualquer parte: destaque (a palavra-tese), fonte (a fonte citada). O texto da TELA sai da FALA: o título usa palavras que a fala diz, cedo na frase (a tela entra quando a voz diz; palavra que a voz não diz deixa a tela esperando). '
95
95
  + 'visual:{titulo,texto,paleta} (hibrido, cinema, impacto): fonte e cor do cardápio (estudio_midia acao:fontes); sem visual, o PrimoCode sorteia sem repetir os últimos vídeos. '
96
96
  + 'estilo (SEM IMAGEM é o padrão: escolha o motion que serve a ESTE roteiro, ou deixe vazio e ele gira entre gerativo, fluido e impacto): hibrido (SÓ quando a pessoa pediu imagem: foto e vídeo reais + o motion por cima; dê busca:"termo em inglês" só nas partes em que a imagem agrega) | gerativo (= motion: sem template — a tipografia anima nos eixos da fonte numa linha do tempo contínua, composição, fonte, cor e som sorteados do roteiro; modelo3d fone|celular|notebook|relogio|garrafa|lata|caixa: o objeto gira em 3D numa batida — ponha quando o vídeo é de um produto ou objeto físico; ideia abstrata, história ou explicação: deixe vazio; o vídeo tem UMA identidade (2 chãos, 1 acento, 2 fontes) e o fundo só troca na virada de seção — se o roteiro tem atos, dê secao:"nome curto" nas partes, senão o PrimoCode divide; moldura cantos|completa|false: o quadro fixo do reel (cantos, o assunto no alto, "01 · seção", o tempo) — ponha só se o vídeo pede cara de peça de design; vazio, o PrimoCode alterna com memória e às vezes não põe; heroi em cada parte: o desenho da batida além do texto, tirado do CONTEÚDO — um ícone do Iconify do objeto que a fala cita ("mdi:bread-slice", "mdi:qrcode"; procure com estudio_midia acao:icones), um 3D do kit (raio|relogio|check|balao|escudo|coracao|estrela|sol|play|engrenagem|cadeado|barras|fone|onda|celular|seta|bateria) ou "nenhum" quando a tipografia basta; forma abstrata (explosao|poligono|esfera|toro|truchet|aneis|grade|rastro) só em assunto abstrato ou de tecnologia — numa conversa de padaria, nunca; ambiente limpo|luz|bokeh e acento traco|viagem (o ponto que viaja entre as batidas): vazio, o PrimoCode escolhe com memória e o bokeh quase não sai fora de tecnologia) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
97
- + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | fluido (minimalista: uma ideia por batida, a forma de uma vira a próxima, sem corte; paleta noite|breu|preto|cobalto (escuras) ou lavanda|creme|laranja|branco (claras) — escolha pelo assunto e NÃO repita a do vídeo anterior; vazio, o PrimoCode escolhe; fontesFluido aprovado|editorial|revista|carater|tecnico|classico, vazio gira) | impacto | cinema. '
98
- + 'marca e lancamento aceitam visual:orbe|vetor|estrela|esfera|grade|site|padrao|cromo|cartoes|painel|cores numa parte. marca.nome dá o nome. '
97
+ + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | fluido (minimalista: uma ideia por batida, a forma de uma vira a próxima, sem corte; paleta noite|breu|preto|cobalto (escuras) ou lavanda|creme|laranja|branco (claras) — escolha pelo assunto e NÃO repita a do vídeo anterior; vazio, o PrimoCode escolhe; fontesFluido aprovado|editorial|revista|carater|tecnico|classico, vazio gira) | impacto | cinema | personagem (o vídeo GRAVADO em 3D — cada parte é UMA fala {falante, texto, emocao?, destaque?}, sem tipo: personagens modelados — gente ou robô — conversando num set de podcast/estúdio/escritório, a boca segue a voz, gestos, e a câmera corta entre aberto, ombro e close; use quando o pedido fala em personagem, boneco, avatar, podcast, entrevista, ou alguém FALANDO em 3D. Com dois falantes (falante em cada parte) eles conversam; personagens:{"nome":{tipo:robo|pessoa, pele, cabelo:curto|cacheado|longo|topete|coque|careca, corCabelo, roupa, oculos, barba, rotulo}} — nome de IA vira robô sozinho; emocao feliz|serio|surpreso|pensativo|animado|curioso e destaque:"texto curto" (o cartão na tela) em cada parte; cenario podcast|estudio|escritorio|noite). '
98
+ + 'marca e lancamento aceitam visual:orbe|vetor|esfera|grade|site|padrao|cromo|cartoes|painel|cores numa parte. marca.nome dá o nome. '
99
99
  + 'voz (thalita|antonio|francisca). DUAS VOZES (o pedido fala em dois narradores, diálogo, conversa, entrevista): roteiro.vozes {"nome":"antonio","outro":"thalita"} e falante:"nome" em cada parte — cada um sai na sua voz. Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
100
100
  + 'cena (opcional): o número de UMA cena para trocar só ela, sem reescrever o resto. '
101
101
  + 'motivo (opcional): fica no caderno. voltarPara (opcional): nome de uma versão. '
@@ -104,13 +104,19 @@ const GRUPOS = [
104
104
  + '"anima": {propriedade: [{t,v,e}]} — quadros-chave em segundos. A cena tem "camera": {zoom,x,y,rotacao} e '
105
105
  + '"perspectiva" para 3D. Cena.transicao {tipo,duracao}: corte|esmaecer|deslizar|zoom|giro|mascara|morph|chicote|soco|flash|liquido, '
106
106
  + 'e as 3D virar|cubo|profundidade|inclinar (mexem nas duas cenas) — use 3D se o vídeo pede volume (produto, site, tecnologia); em editorial plano, prefira as planas. '
107
- + 'NÃO existe layout pronto nem tema — cada propriedade do vídeo é escolha sua, a cada vídeo.' },
107
+ + 'NÃO existe layout pronto nem tema — cada propriedade do vídeo é escolha sua, a cada vídeo. '
108
+ + 'ÁREA SEGURA: nada a menos de 6% da largura e 7% da altura da borda, e 4% do lado curto entre dois elementos no ar juntos — '
109
+ + 'o render empurra para dentro, encolhe o texto largo e afasta o colado; enfeite de fundo marque decorativo: true.' },
108
110
 
109
111
  { tool: 'estudio_midia', escreve: true, faz: 'procura e baixa ícone, imagem, música e efeito sonoro da internet, e lista as fontes',
110
- args: 'acao: imagem | icones | som | trilha | efeito | fontes | print. termo. id (para baixar). '
112
+ args: 'acao: imagem | icones | som | trilha | efeito | fontes | print | elementos | elemento | recortar. termo. id (para baixar). '
111
113
  + 'print: url — tira o print REAL do site e devolve o src e um elemento {tipo:"kit", peca:"printsite"} pronto '
112
114
  + '(o site de verdade numa janela 3D que rola a página; celular: true para a tela do celular; inteira: false só a primeira dobra). '
113
115
  + 'Use SE o pedido trouxer um site e o vídeo for SOBRE ele (lançamento, tour, case); vídeo que só cita a marca não precisa. '
116
+ + 'elementos: url — lê o CÓDIGO do site e lista as peças que dá para tirar (logo, menu, titulo, hero, botao, cartao, imagem, formulario, preco, rodape), cada uma com o seletor. '
117
+ + 'elemento: url + seletor (ou texto: o que está escrito nele) — tira SÓ aquela peça, em PNG com fundo transparente e no dobro da resolução, e devolve o código dela (html e estilo: fonte, cor, raio, sombra) e um elemento {tipo:"imagem"} pronto; '
118
+ + 'use para pôr o botão, o cartão de preço ou a logo do site animando no vídeo (ou redesenhe a peça como componente a partir do código). '
119
+ + 'recortar: arquivo — tira o fundo de qualquer foto com IA (rembg), PNG transparente. '
114
120
  + 'efeitos: lista os componentes guardados (os menos usados primeiro). usar-efeito {nome}: copia para o projeto e devolve o elemento — use se um deles servir a ESTE roteiro. '
115
121
  + 'guardar-efeito {arquivo, nome, descricao}: guarda um componente que você escreveu e ficou bom, para os próximos vídeos '
116
122
  + '(o componente: export default de uma função React que recebe {el, t, fps}, num arquivo só, importando só react, remotion, @remotion/*, three). '
package/lib/entrada.js CHANGED
@@ -47,7 +47,26 @@ const { c, visibleWidth, truncate } = require('./ui.js');
47
47
  const RE_ETIQUETA = /\[Colado #\d+ · [^\]]+\]/g;
48
48
  function pintarEtiquetas(s) { return String(s).replace(RE_ETIQUETA, (m) => c.brand(m) + '\x1b[38;5;254m'); }
49
49
 
50
- const BORDA = { ne: '╭', no: '╮', se: '╰', so: '╯', h: '─', v: '│' };
50
+ /* As cores do painel, no cubo de 256 cores. O fundo é um degrau acima do
51
+ preto do terminal (235); a barra é o azul do OpenCode; o escolhido do menu,
52
+ o pêssego dele com texto escuro. */
53
+ const COR = {
54
+ fundo: '\x1b[48;5;235m',
55
+ barra: '\x1b[38;5;75m',
56
+ warn: '\x1b[38;5;214m',
57
+ selFundo: '\x1b[48;5;216m',
58
+ selTexto: '\x1b[38;5;234m',
59
+ reset: '\x1b[0m',
60
+ };
61
+ /* Onde o texto começa: a barra, o espaço, e as duas colunas da marca ("$ "
62
+ no modo bash, vazias no normal). O cursor conta a partir daqui. */
63
+ const PREFIXO = 4;
64
+
65
+ /** Uma linha do painel: fundo até `w` colunas, religado depois de cada reset. */
66
+ function painel(s, w) {
67
+ const corpo = String(s).split(COR.reset).join(COR.reset + COR.fundo);
68
+ return COR.fundo + corpo + ' '.repeat(Math.max(0, w - visibleWidth(s))) + COR.reset;
69
+ }
51
70
 
52
71
  /** Preenche até `w` colunas VISÍVEIS (códigos de cor não ocupam coluna). */
53
72
  function encher(s, w) {
@@ -95,9 +114,13 @@ function quebrar(texto, w) {
95
114
  */
96
115
  function montar(e) {
97
116
  const colunas = Math.max(24, Math.min(e.colunas || 80, 200));
98
- const largura = colunas - 1; // a última coluna fica livre: escrever nela
99
- // faz o terminal quebrar a linha sozinho
100
- const w = Math.max(8, largura - 3); // espaço + '❯ ' — e mais nada à direita
117
+ /* Na ABERTURA (nada mandado ainda) a caixa é estreita e centrada embaixo
118
+ do logo, como a home do OpenCode; depois do primeiro pedido ela ocupa a
119
+ largura toda. `recuo` é a margem da esquerda, que entra em toda linha. */
120
+ const largura = e.centro ? Math.min(colunas - 1, 80) : colunas - 1; // a última coluna
121
+ // fica livre: escrever nela faz o terminal quebrar a linha
122
+ const recuo = e.centro ? Math.max(0, Math.floor((colunas - largura) / 2)) : 0;
123
+ const w = Math.max(8, largura - PREFIXO);
101
124
 
102
125
  const cursor = Math.max(0, Math.min(e.cursor | 0, String(e.texto).length));
103
126
  const linhas = quebrar(e.texto, w);
@@ -112,41 +135,39 @@ function montar(e) {
112
135
  if (cursor >= l.de && cursor <= fim) { cursorLinha = i; cursorColuna = cursor - l.de; }
113
136
  }
114
137
 
115
- /* DUAS RÉGUAS, NÃO UMA CAIXA.
116
- Era uma caixa fechada — cantos e barras laterais. O Claude Code, que é a
117
- referência pedida, usa só a linha de cima e a de baixo, e a diferença
118
- não é gosto: a barra lateral direita obriga a reservar duas colunas em
119
- toda linha e a preencher o resto com espaço, e é ela que faz o texto
120
- colado parecer desalinhado quando o terminal é estreito. Sem as laterais
121
- o campo ganha as colunas de volta e a emenda some. */
122
- /* MODO BASH: a linha que começa com "!" roda no shell, e a caixa INTEIRA
123
- muda de cor para avisar.
124
-
125
- O aviso é o ponto. Um comando de shell faz coisa irreversível, e a
138
+ /* O DESENHO DO OPENCODE, que é a referência pedida: um PAINEL — fundo um
139
+ tom mais claro que o terminal, uma barra grossa na esquerda na cor do
140
+ modo — e não réguas nem caixa de cantos. Dentro dele, o texto; uma
141
+ linha em branco; e embaixo, ainda dentro, o modo e o esforço ("Auto ·
142
+ alto"), que é o que muda o que acontece no Enter. Fora do painel, uma
143
+ linha só: a pasta à esquerda, os atalhos à direita.
144
+
145
+ O fundo vai até a última coluna útil em toda linha do painel. É ele que
146
+ desenha a caixa — sem borda nenhuma — e por isso cada `reset` de cor no
147
+ meio do texto tem de religar o fundo (ver `painel`). */
148
+ /* MODO BASH: a linha que começa com "!" roda no shell, e o painel muda de
149
+ cor para avisar. Um comando de shell faz coisa irreversível, e a
126
150
  diferença entre "pedir ao agente" e "rodar direto" não pode depender de
127
- a pessoa lembrar do que digitou três segundos atrás: ela tem de VER, no
128
- momento em que digita, que aquele Enter vai ao shell. Daí o âmbar — a
129
- mesma cor que o resto do programa usa para "atenção, não é o caminho
130
- comum". */
151
+ a pessoa lembrar do que digitou: ela tem de VER, enquanto digita, que
152
+ aquele Enter vai ao shell. Âmbar é o "atenção" do resto do programa. */
131
153
  const bash = /^\s*!/.test(String(e.texto));
154
+ const st = Array.isArray(e.status) ? { linhas: e.status } : (e.status || {});
155
+ const corBarra = bash ? COR.warn : (st.cor || COR.barra);
156
+ const barra = `${corBarra}┃${COR.reset}`;
157
+ const linhaDoPainel = (conteudo) => painel(barra + conteudo, largura);
132
158
 
133
159
  const out = [];
134
- const regua = (bash ? c.warn : c.dim)(BORDA.h.repeat(largura));
135
- /* O SPINNER MORA AQUI, na linha logo acima da caixa. Ele escrevia direto
136
- no terminal com "\r" a cada 80 ms, sem quebra de linha — e a caixa,
137
- que espera a linha fechar para se redesenhar, ficava apagada enquanto
138
- ele girasse: o que se digitava não aparecia, e a barra de status
139
- sumia ("não consigo digitar enquanto ele trabalha"). */
160
+ /* O SPINNER MORA AQUI, na linha logo acima do painel. Ele escrevia direto
161
+ no terminal com "\r" a cada 80 ms — e a caixa, que espera a linha fechar
162
+ para se redesenhar, ficava apagada enquanto ele girasse. */
140
163
  if (e.spinner) out.push(' ' + truncate(e.spinner, largura - 2));
141
- out.push(regua);
164
+
165
+ out.push(linhaDoPainel('')); // o respiro de cima
142
166
 
143
167
  const vazio = !String(e.texto).length;
144
168
  linhas.forEach((l, i) => {
145
- const marca = i === 0
146
- ? (e.ocupado ? c.dim('❯ ') : bash ? c.warn('$ ') : c.brand('❯ '))
147
- : c.dim('┆ ');
148
- // O "!" some do texto desenhado: quem vê "$ " já sabe que é shell, e
149
- // mostrar os dois é mostrar a mesma informação duas vezes.
169
+ // O "!" some do texto desenhado: quem vê "$ " já sabe que é shell.
170
+ const marca = i === 0 && bash ? c.warn('$ ') : ' ';
150
171
  let conteudo = pintarEtiquetas(c.white(i === 0 && bash ? l.texto.replace(/^\s*!/, '') : l.texto));
151
172
  // O fantasma só existe na última linha e só com o cursor no fim dela:
152
173
  // desenhado no meio do texto, ele empurraria o que vem depois.
@@ -155,56 +176,71 @@ function montar(e) {
155
176
  }
156
177
  if (i === 0 && vazio && e.ocupado) conteudo = c.dim('mande um recado — ele entra na tarefa depois do passo atual');
157
178
  else if (i === 0 && vazio && e.vazio) conteudo = c.dim(e.vazio);
158
- // Sem `encher`: sem barra à direita não há nada para alinhar, e o
159
- // espaço sobrando só apagaria o que estivesse embaixo.
160
- out.push(' ' + marca + conteudo);
179
+ out.push(linhaDoPainel(' ' + marca + truncate(conteudo, w)));
161
180
  });
162
181
 
163
- out.push(regua);
182
+ out.push(linhaDoPainel(''));
183
+ // O modo e o esforço, dentro do painel — "Build · Nemotron" no OpenCode.
184
+ out.push(linhaDoPainel(' ' + truncate(bash
185
+ ? c.warn('roda no shell') + c.dim(`, em ${e.pasta || 'a pasta atual'} · apague o ! para voltar ao normal`)
186
+ : (st.modo || c.dim('PrimoCode')), largura - 4)));
187
+
188
+ // ── a linha de baixo: pasta à esquerda, atalhos à direita ────────────
189
+ if (st.linhas) {
190
+ for (const linha of st.linhas) out.push(' ' + truncate(linha, largura - 2));
191
+ } else {
192
+ const esq = st.esquerda || '';
193
+ const dir = st.direita || '';
194
+ const sobra = largura - 2 - visibleWidth(esq) - visibleWidth(dir);
195
+ out.push(' ' + (sobra >= 2 ? esq + ' '.repeat(sobra) + dir : truncate(esq, largura - 2)));
196
+ }
164
197
 
165
198
  // ── o menu dos comandos ──────────────────────────────────────────────
166
- // Fica ABAIXO da caixa, não dentro: dentro, ele empurraria o campo de
167
- // digitar para cima a cada tecla, e o texto que a pessoa escreve saltaria
168
- // de lugar enquanto ela escreve.
199
+ // Fica ABAIXO de tudo, não em cima do painel: em cima, ele empurraria o
200
+ // campo de digitar para baixo a cada tecla, e o texto que a pessoa
201
+ // escreve saltaria de lugar enquanto ela escreve. O desenho é o do
202
+ // OpenCode: fundo de painel, e o escolhido numa faixa pêssego.
169
203
  if (e.menu && e.menu.itens.length) {
170
204
  const cabem = Math.max(1, Math.min(e.menu.itens.length, 8));
171
205
  // A janela acompanha a seleção: descer na lista rola, em vez de o
172
206
  // item escolhido sumir embaixo.
173
207
  let topo = Math.max(0, Math.min(e.menu.sel - Math.floor(cabem / 2),
174
208
  e.menu.itens.length - cabem));
209
+ const nomeW = 16;
175
210
  for (let i = topo; i < topo + cabem; i++) {
176
211
  const [nome, desc] = e.menu.itens[i];
177
- const escolhido = i === e.menu.sel;
178
- const nomeW = 16;
179
- const corpo = (escolhido ? c.brand('❯ ') : ' ')
180
- + (escolhido ? c.bold(c.white(encher(nome, nomeW))) : c.muted(encher(nome, nomeW)))
181
- + c.dim(desc || '');
182
- out.push(' ' + truncate(corpo, largura - 2));
212
+ if (i === e.menu.sel) {
213
+ const corpo = ' ' + encher(nome, nomeW) + (desc || '');
214
+ out.push(`${COR.selFundo}${COR.selTexto}${encher(truncate(corpo, largura), largura)}${COR.reset}`);
215
+ } else {
216
+ out.push(painel(' ' + c.white(encher(nome, nomeW)) + c.dim(desc || ''), largura));
217
+ }
183
218
  }
184
219
  if (e.menu.itens.length > cabem) {
185
- out.push(' ' + c.dim(`… e mais ${e.menu.itens.length - cabem}`));
220
+ out.push(painel(' ' + c.dim(`… e mais ${e.menu.itens.length - cabem}`), largura));
186
221
  }
187
222
  }
188
223
 
189
- /* A linha que explica, só no modo bash. Ela ocupa o lugar do rodapé
190
- normal de propósito: no instante em que a pessoa vai rodar um comando,
191
- o que importa não é lembrar os atalhos, é saber onde ele vai rodar. */
192
- if (bash) {
193
- out.push(' ' + c.warn('roda no shell') + c.dim(`, em ${e.pasta || 'a pasta atual'}`)
194
- + c.dim(' · apague o ! para voltar ao normal'));
195
- }
196
- for (const linha of (e.status || [])) out.push(' ' + linha);
197
-
224
+ const margem = ' '.repeat(recuo);
198
225
  return {
199
- linhas: out,
200
- cursorLinha: 1 + cursorLinha, // +1 pela régua de cima
226
+ linhas: recuo ? out.map((l) => margem + l) : out,
227
+ cursorLinha: (e.spinner ? 1 : 0) + 1 + cursorLinha, // +1 pelo respiro de cima
201
228
  /* No modo bash o "!" não é desenhado, então tudo depois dele anda uma
202
229
  coluna para a esquerda — e o cursor tem de andar junto, ou ele
203
230
  aparece um caractere à frente de onde o texto realmente está. */
204
- cursorColuna: 1 + 2 + cursorColuna - (bash && cursorLinha === 0 ? 1 : 0),
231
+ cursorColuna: recuo + PREFIXO + cursorColuna - (bash && cursorLinha === 0 ? 1 : 0),
205
232
  };
206
233
  }
207
234
 
235
+ /** O pedido que foi mandado, como fica no histórico da tela: um painel. */
236
+ function pedidoNaTela(texto, colunas) {
237
+ const largura = Math.max(24, Math.min(colunas || 80, 200)) - 1;
238
+ const barra = `${COR.barra}┃${COR.reset}`;
239
+ const linhas = quebrar(texto, Math.max(8, largura - PREFIXO))
240
+ .map((l) => painel(barra + ' ' + pintarEtiquetas(c.white(l.texto)), largura));
241
+ return [painel(barra, largura), ...linhas, painel(barra, largura)].join('\n');
242
+ }
243
+
208
244
  // ── A caixa viva ─────────────────────────────────────────────────────────
209
245
 
210
246
  class Entrada extends EventEmitter {
@@ -416,11 +452,16 @@ class Entrada extends EventEmitter {
416
452
  menu: this.menu,
417
453
  ocupado: this.ocupado,
418
454
  spinner: this.spinnerTexto || '',
455
+ centro: !this.mandou,
419
456
  });
420
457
  }
421
458
 
422
459
  _desenhar() {
423
460
  if (!this.tty || this.suspenso || this.fechado) return;
461
+ /* Já desenhada (um `prompt()` de quem não sabe que ela está no ar):
462
+ apaga antes. Sem isso o quadro novo começava na linha do cursor —
463
+ a do texto — e o respiro de cima do painel ficava dobrado. */
464
+ if (this.desenhado && !this._meiaLinha) this._apagar();
424
465
  const q = this._quadro();
425
466
  this._pintando = true;
426
467
  /* Cursor no meio de uma linha (fluxo que ainda não fechou): desce uma
@@ -525,6 +566,9 @@ class Entrada extends EventEmitter {
525
566
 
526
567
  case 'tab': {
527
568
  if (this.menu) return this._escolherDoMenu();
569
+ /* Tab com a caixa vazia troca o modo — o "tab agents" do
570
+ OpenCode. Com texto, continua completando o comando. */
571
+ if (!this.line && !this.ocupado) { this.emit('trocar-modo'); return; }
528
572
  const f = this._fantasma();
529
573
  if (f) return this.write(f);
530
574
  if (this.line.startsWith('/')) { this._filtrarMenu(); this._redesenhar(); }
@@ -605,6 +649,12 @@ class Entrada extends EventEmitter {
605
649
  }
606
650
  case 'l': this._apagar(); this.escreverCru('\x1b[2J\x1b[H'); return this._desenhar();
607
651
  case 'j': return this._novaLinha();
652
+ // Ctrl+P abre os comandos, como no OpenCode: o mesmo menu do "/".
653
+ case 'p':
654
+ if (this.line && !this.line.startsWith('/')) return;
655
+ if (!this.line) { this.line = '/'; this.cursor = 1; }
656
+ this._filtrarMenu();
657
+ return this._redesenhar();
608
658
  default: return;
609
659
  }
610
660
  }
@@ -660,10 +710,12 @@ class Entrada extends EventEmitter {
660
710
  senão o pedido some no instante em que a resposta começa, e quem
661
711
  rola para cima não acha o que perguntou. */
662
712
  this._apagar();
713
+ if (texto.trim()) this.mandou = true; // a caixa sai do centro e ocupa a largura
663
714
  if (texto.trim() && !oculto) {
664
- const marca = c.brand('❯ ');
665
- // Na tela fica a etiqueta (o que se viu ao mandar); quem vai ao agente é o texto inteiro.
666
- this.escreverCru(visto.split('\n').map((l, i) => (i ? ' ' : marca) + pintarEtiquetas(c.white(l))).join('\n') + '\n');
715
+ // Na tela fica a etiqueta (o que se viu ao mandar); quem vai ao agente
716
+ // é o texto inteiro. O desenho é o do pedido no OpenCode: o mesmo
717
+ // painel da caixa, com a barra, e um respiro em cima e embaixo.
718
+ this.escreverCru(pedidoNaTela(visto, process.stdout.columns || 80) + '\n');
667
719
  if (this.historico[this.historico.length - 1] !== texto) this.historico.push(texto);
668
720
  }
669
721
  this._desenhar();
@@ -673,4 +725,4 @@ class Entrada extends EventEmitter {
673
725
 
674
726
  function criar(op) { return new Entrada(op).iniciar(); }
675
727
 
676
- module.exports = { criar, Entrada, montar, quebrar, encher, pintarEtiquetas };
728
+ module.exports = { criar, Entrada, montar, quebrar, encher, pintarEtiquetas, painel, pedidoNaTela, PREFIXO, COR };
@@ -320,14 +320,28 @@ function creditos(arquivos = null, pasta = PASTA()) {
320
320
  * Qual ferramenta desta máquina sabe recortar. A decisão fica separada do
321
321
  * processo para poder ser testada sem recortar nada.
322
322
  */
323
- function comoTirarFundo({ temRembg, temBackgroundRemover, temPython, temFfmpeg } = {}) {
323
+ function comoTirarFundo({ temRembg, temBackgroundRemover, temPython, temUv, temFfmpeg } = {}) {
324
324
  if (temRembg) return 'rembg';
325
325
  if (temBackgroundRemover) return 'backgroundremover';
326
326
  if (temPython) return 'python-rembg';
327
+ /* Com o uv (o mesmo do recorte de pessoa), o rembg roda sem instalar nada:
328
+ o uv baixa o pacote num ambiente à parte. Antes, sem `pip install rembg`,
329
+ a foto caía no corte por cor e saía com o fundo inteiro (10/10). */
330
+ if (temUv) return 'uv-rembg';
327
331
  if (temFfmpeg) return 'ffmpeg-chroma'; // só resolve fundo de cor chapada
328
332
  return null;
329
333
  }
330
334
 
335
+ let _uv;
336
+ function acharUv() {
337
+ if (_uv !== undefined) return _uv;
338
+ const os = require('os');
339
+ for (const c of ['uv', path.join(os.homedir(), '.local', 'bin', 'uv'), '/opt/homebrew/bin/uv', '/usr/local/bin/uv']) {
340
+ try { if (spawnSync(c, ['--version'], { stdio: 'ignore', timeout: 5000 }).status === 0) return (_uv = c); } catch { /* próximo */ }
341
+ }
342
+ return (_uv = null);
343
+ }
344
+
331
345
  function existe(cmd) {
332
346
  try { return spawnSync(cmd, ['--help'], { stdio: 'ignore', timeout: 8000 }).status != null; }
333
347
  catch { return false; }
@@ -354,6 +368,7 @@ async function tirarFundo(arquivo, { destino, cor = '0x00FF00', tolerancia = 0.3
354
368
  try { return spawnSync('python3', ['-c', 'import rembg'], { stdio: 'ignore', timeout: 20000 }).status === 0; }
355
369
  catch { return false; }
356
370
  })(),
371
+ temUv: !!acharUv(),
357
372
  temFfmpeg: existe('ffmpeg'),
358
373
  });
359
374
  if (!metodo) {
@@ -381,6 +396,11 @@ async function tirarFundo(arquivo, { destino, cor = '0x00FF00', tolerancia = 0.3
381
396
  deu = await rodar('python3', ['-c',
382
397
  'import sys;from rembg import remove;open(sys.argv[2],"wb").write(remove(open(sys.argv[1],"rb").read()))',
383
398
  arquivo, saida]);
399
+ } else if (metodo === 'uv-rembg') {
400
+ // A primeira vez baixa o modelo (~170 MB, fica em ~/.u2net); depois é segundos por imagem.
401
+ deu = await rodar(acharUv(), ['run', '--quiet', '--python', '3.12', '--with', 'rembg[cpu]', '--with', 'pillow', 'python', '-c',
402
+ 'import sys;from rembg import remove, new_session;open(sys.argv[2],"wb").write(remove(open(sys.argv[1],"rb").read(), session=new_session("isnet-general-use")))',
403
+ arquivo, saida]);
384
404
  } else {
385
405
  deu = await rodar('ffmpeg', ['-y', '-i', arquivo, '-vf',
386
406
  `colorkey=${cor}:${tolerancia}:0.1`, '-c:a', 'copy', saida]);