primocode 10.5.0 → 10.7.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.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/bin/primocode.js +117 -24
  3. package/lib/act.js +141 -9
  4. package/lib/agents-md.js +140 -0
  5. package/lib/auditoria.js +122 -0
  6. package/lib/browser.js +48 -1
  7. package/lib/catalogo.js +29 -6
  8. package/lib/comandos-extra.js +2 -0
  9. package/lib/config.js +32 -4
  10. package/lib/entrada.js +33 -11
  11. package/lib/estudio/cenarios.js +91 -0
  12. package/lib/estudio/colecao.js +35 -1
  13. package/lib/estudio/diretor.js +1 -0
  14. package/lib/estudio/falantes.js +30 -6
  15. package/lib/estudio/hibrido.js +144 -8
  16. package/lib/estudio/index.js +196 -63
  17. package/lib/estudio/kit/mundo.jsx +1060 -0
  18. package/lib/estudio/kit/voxel.jsx +315 -70
  19. package/lib/estudio/kit.js +1 -1
  20. package/lib/estudio/kokoro.js +18 -4
  21. package/lib/estudio/margem.js +1 -1
  22. package/lib/estudio/movimentos.js +212 -0
  23. package/lib/estudio/mundo.js +328 -0
  24. package/lib/estudio/personagem.js +164 -11
  25. package/lib/estudio/plano.js +6 -0
  26. package/lib/estudio/producao.js +107 -17
  27. package/lib/estudio/projeto.js +77 -22
  28. package/lib/leitura.js +176 -0
  29. package/lib/local/cerebro.json +180 -3
  30. package/lib/local/entrada.js +61 -4
  31. package/lib/mcp.js +296 -12
  32. package/lib/memoria-conversa.js +32 -3
  33. package/lib/mencoes.js +5 -1
  34. package/lib/nome-curto.js +126 -0
  35. package/lib/nome-projeto.js +5 -2
  36. package/lib/outra-pasta.js +161 -0
  37. package/lib/perigo.js +34 -2
  38. package/lib/referencia.js +40 -11
  39. package/lib/repeticao.js +20 -1
  40. package/lib/tela.js +1 -1
  41. package/lib/tools.js +76 -6
  42. package/lib/travado.js +141 -0
  43. package/lib/ui.js +1 -0
  44. package/package.json +2 -2
@@ -0,0 +1,140 @@
1
+ /**
2
+ * agents-md.js — a memória da conversa mora NA PASTA, num AGENTS.md.
3
+ *
4
+ * "Eu preciso de um AGENTS.md com a memória da conversa em cada pasta."
5
+ * (11/10/2026)
6
+ *
7
+ * O resumo da conversa (lib/memoria-conversa.js) já existia, mas vivia só na
8
+ * memória do processo: fechar o terminal apagava o "de onde parei". Agora,
9
+ * a cada resposta, ele é escrito no AGENTS.md da pasta em que se está — e a
10
+ * próxima sessão aberta ali lê e continua.
11
+ *
12
+ * ── POR QUE AGENTS.md, E POR QUE SÓ UM BLOCO DELE ────────────────────────
13
+ * AGENTS.md é o arquivo que Codex, Cursor e Claude Code já leem; a memória
14
+ * escrita aqui serve a qualquer agente que abrir a pasta. Mas o repo da
15
+ * pessoa pode JÁ TER um AGENTS.md, escrito por ela. Então o PrimoCode só
16
+ * escreve entre as duas marcas abaixo e nunca toca no resto: se o arquivo não
17
+ * existe, nasce com o bloco; se existe sem bloco, o bloco vai no fim; se tem
18
+ * bloco, só o miolo dele muda.
19
+ *
20
+ * ── CURTO DE PROPÓSITO ───────────────────────────────────────────────────
21
+ * O bloco entra no contexto de todo pedido naquela pasta. Teto de 2.400
22
+ * letras: o resumo já é podado por quem resume (250 palavras), e o teto
23
+ * aqui é a trava para o dia em que não for.
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const fs = require('fs');
29
+ const os = require('os');
30
+ const path = require('path');
31
+
32
+ const ARQUIVO = 'AGENTS.md';
33
+ const INICIO = '<!-- primocode:memoria -->';
34
+ const FIM = '<!-- /primocode:memoria -->';
35
+ const TETO = 2400;
36
+
37
+ function escaparRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
38
+ const RE_BLOCO = new RegExp(`${escaparRe(INICIO)}[\\s\\S]*?${escaparRe(FIM)}`);
39
+
40
+ /** O miolo do bloco (sem cabeçalho nem marcas), ou ''. */
41
+ function extrair(texto) {
42
+ const m = String(texto || '').match(RE_BLOCO);
43
+ if (!m) return '';
44
+ return m[0].slice(INICIO.length, -FIM.length)
45
+ .replace(/^\s*## Memória do PrimoCode\s*\n/, '')
46
+ .replace(/^\s*>.*\n/, '')
47
+ .trim();
48
+ }
49
+
50
+ /** O texto da PESSOA: o arquivo sem o bloco da memória. */
51
+ function semBloco(texto) {
52
+ const resto = String(texto || '').replace(RE_BLOCO, '').replace(/\n{3,}/g, '\n\n').trim();
53
+ // Só o título que o próprio PrimoCode pôs ao criar o arquivo: não é
54
+ // instrução de ninguém, e não pode tapar o CLAUDE.md do repo.
55
+ return /^#[^\n]*$/.test(resto) ? '' : resto;
56
+ }
57
+
58
+ /**
59
+ * Corta no fim de uma linha, nunca no meio: o resumo vem em tópicos, e meio
60
+ * tópico é pior que tópico nenhum.
61
+ */
62
+ function encurtar(corpo, teto = TETO) {
63
+ const t = String(corpo || '').trim();
64
+ if (t.length <= teto) return t;
65
+ const linhas = t.split('\n');
66
+ let saida = '';
67
+ for (const l of linhas) {
68
+ if ((saida + l).length + 1 > teto - 12) break;
69
+ saida += (saida ? '\n' : '') + l;
70
+ }
71
+ return (saida || t.slice(0, teto - 12)) + '\n(… cortado)';
72
+ }
73
+
74
+ function quandoLocal(d = new Date()) {
75
+ const p = (n) => String(n).padStart(2, '0');
76
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
77
+ }
78
+
79
+ function bloco(corpo, quando) {
80
+ return [
81
+ INICIO,
82
+ '## Memória do PrimoCode',
83
+ `> Escrita pelo PrimoCode a cada resposta (${quando}). Ele lê isto ao abrir esta pasta e continua daqui. Fora deste bloco ele nunca mexe.`,
84
+ '',
85
+ encurtar(corpo),
86
+ FIM,
87
+ ].join('\n');
88
+ }
89
+
90
+ /**
91
+ * O arquivo novo, a partir do que existe. Puro: é aqui que mora a promessa
92
+ * de não estragar o que a pessoa escreveu, e o teste mede sem disco.
93
+ */
94
+ function aplicar(existente, corpo, { titulo = 'Projeto', quando = quandoLocal() } = {}) {
95
+ const novo = bloco(corpo, quando);
96
+ if (existente == null || !String(existente).trim()) return `# ${titulo}\n\n${novo}\n`;
97
+ const texto = String(existente);
98
+ // Função no replace: um "$&" dentro do resumo não pode virar padrão.
99
+ if (RE_BLOCO.test(texto)) return texto.replace(RE_BLOCO, () => novo);
100
+ return `${texto.replace(/\s+$/, '')}\n\n${novo}\n`;
101
+ }
102
+
103
+ /**
104
+ * Pasta onde vale escrever. A home e a raiz do disco não: um AGENTS.md
105
+ * solto em ~ seria lido por todo agente de todo projeto. A raiz de trabalho
106
+ * (~/primocode) também não — é o corredor, não um projeto, e o dono já
107
+ * reclamou dos arquivos soltos ali.
108
+ */
109
+ function podeGravar(dir, { raiz } = {}) {
110
+ if (!dir) return false;
111
+ const alvo = path.resolve(dir);
112
+ if (alvo === path.resolve(os.homedir())) return false;
113
+ if (alvo === path.parse(alvo).root) return false;
114
+ if (raiz && alvo === path.resolve(raiz)) return false;
115
+ try { return fs.statSync(alvo).isDirectory(); } catch { return false; }
116
+ }
117
+
118
+ /** A memória gravada nesta pasta, ou ''. */
119
+ function ler(dir) {
120
+ if (!dir) return '';
121
+ try { return extrair(fs.readFileSync(path.join(dir, ARQUIVO), 'utf-8')); } catch { return ''; }
122
+ }
123
+
124
+ /**
125
+ * Grava (ou atualiza) o bloco. Devolve true quando escreveu. Não reescreve
126
+ * se o miolo não mudou: o arquivo é do repo da pessoa, e mexer na data à toa
127
+ * suja o `git status` dela a cada resposta.
128
+ */
129
+ function gravar(dir, corpo, opts = {}) {
130
+ const limpo = String(corpo || '').trim();
131
+ if (!limpo || !podeGravar(dir, opts)) return false;
132
+ const arq = path.join(dir, ARQUIVO);
133
+ let existente = null;
134
+ try { existente = fs.readFileSync(arq, 'utf-8'); } catch { existente = null; }
135
+ if (existente != null && extrair(existente) === encurtar(limpo)) return false;
136
+ const novo = aplicar(existente, limpo, { titulo: opts.titulo || path.basename(path.resolve(dir)), quando: opts.quando });
137
+ try { fs.writeFileSync(arq, novo, 'utf-8'); return true; } catch { return false; }
138
+ }
139
+
140
+ module.exports = { ARQUIVO, INICIO, FIM, TETO, extrair, semBloco, encurtar, aplicar, podeGravar, ler, gravar };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * auditoria.js — anota ANTES de agir (o "decide, registra, age" do OpenBot).
3
+ *
4
+ * O registro.js faz o balanço do pedido na tela, e some quando a tela fecha.
5
+ * Este é o livro que fica: ~/.primocode/registro/acoes-AAAA-MM-DD.jsonl, uma
6
+ * linha por decisão, escrita ANTES de a ferramenta rodar — então o que foi
7
+ * recusado (perigo, permissão, freio) e o que travou no meio também aparece.
8
+ * Depois vem uma segunda linha com o resultado, ligada pelo `id`.
9
+ *
10
+ * Nunca guarda conteúdo de arquivo nem ambiente: argumento longo vira
11
+ * "<N caracteres>", chave com cara de segredo vira "***". Um arquivo por dia,
12
+ * com teto de tamanho, e os de mais de RETER_DIAS dias são apagados.
13
+ * Falhar aqui nunca derruba o agente — mas também nunca some calado: passou
14
+ * do teto, abre o arquivo seguinte do dia (acoes-AAAA-MM-DD.2.jsonl); não deu
15
+ * para gravar, `problema()` devolve o porquê, uma vez, e o act.js mostra na
16
+ * tela. Antes, o livro cheio parava de gravar em silêncio, e quem enchesse o
17
+ * dia de propósito cegava o resto dele (review do #95, item 3).
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const path = require('path');
25
+
26
+ const MAX_BYTES = 5 * 1024 * 1024;
27
+ const RETER_DIAS = 14;
28
+ const LONGOS = /^(content|new_string|old_string|edits|texto|text|body|html|script)$/i;
29
+ const SEGREDO_CHAVE = /(token|senha|password|secret|api[_-]?key|auth|cookie|credencial)/i;
30
+
31
+ let seq = 0;
32
+ let limpou = false;
33
+ let problemaNovo = null;
34
+ const problemasMostrados = new Set();
35
+
36
+ const pasta = () => path.join(os.homedir(), '.primocode', 'registro');
37
+ function arquivoDoDia(d = new Date(), parte = 1) {
38
+ const p = (n) => String(n).padStart(2, '0');
39
+ const sufixo = parte > 1 ? `.${parte}` : '';
40
+ return path.join(pasta(), `acoes-${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}${sufixo}.jsonl`);
41
+ }
42
+
43
+ /** O arquivo do dia que ainda cabe: o primeiro abaixo do teto. */
44
+ function arquivoComEspaco(d = new Date()) {
45
+ for (let parte = 1; ; parte++) {
46
+ const arq = arquivoDoDia(d, parte);
47
+ let tamanho = 0;
48
+ try { tamanho = fs.statSync(arq).size; } catch { return arq; }
49
+ if (tamanho <= MAX_BYTES) return arq;
50
+ }
51
+ }
52
+
53
+ function semSegredo(texto) {
54
+ return String(texto)
55
+ .replace(/\b(sk|ghp|gho|ghs|xox[abp]|pk|rk)[-_][A-Za-z0-9_-]{8,}/g, '***')
56
+ .replace(/((?:token|senha|password|secret|api[_-]?key)\s*[=:]\s*)\S+/gi, '$1***')
57
+ .replace(/(authorization:\s*bearer\s+)\S+/gi, '$1***');
58
+ }
59
+
60
+ /** O resumo dos argumentos: curto, sem conteúdo e sem segredo. */
61
+ function resumirArgs(args) {
62
+ const out = {};
63
+ for (const [k, v] of Object.entries(args || {})) {
64
+ if (SEGREDO_CHAVE.test(k)) out[k] = '***';
65
+ else if (LONGOS.test(k)) out[k] = `<${JSON.stringify(v == null ? '' : v).length} caracteres>`;
66
+ else if (typeof v === 'string') out[k] = semSegredo(v.length > 160 ? v.slice(0, 160) + '…' : v);
67
+ else if (v && typeof v === 'object') out[k] = semSegredo(JSON.stringify(v).slice(0, 160));
68
+ else out[k] = v;
69
+ }
70
+ return out;
71
+ }
72
+
73
+ function limparVelhos() {
74
+ if (limpou) return;
75
+ limpou = true;
76
+ const limite = Date.now() - RETER_DIAS * 86400000;
77
+ for (const nome of fs.readdirSync(pasta())) {
78
+ const m = /^acoes-(\d{4}-\d{2}-\d{2})(?:\.\d+)?\.jsonl$/.exec(nome);
79
+ if (m && Date.parse(m[1]) < limite) fs.unlinkSync(path.join(pasta(), nome));
80
+ }
81
+ }
82
+
83
+ function escrever(linha) {
84
+ try {
85
+ fs.mkdirSync(pasta(), { recursive: true });
86
+ limparVelhos();
87
+ fs.appendFileSync(arquivoComEspaco(), JSON.stringify(linha) + '\n');
88
+ return true;
89
+ } catch (e) {
90
+ // O livro nunca derruba o agente, mas a falha aparece (problema()).
91
+ const motivo = `o livro de ações não está gravando (${(e && e.code) || (e && e.message) || e})`;
92
+ if (!problemasMostrados.has(motivo)) problemaNovo = motivo;
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /** A falha de gravação ainda não mostrada, uma vez; depois, null. */
98
+ function problema() {
99
+ const p = problemaNovo;
100
+ problemaNovo = null;
101
+ if (p) problemasMostrados.add(p);
102
+ return p;
103
+ }
104
+
105
+ /** A linha de ANTES. Devolve o id para ligar o resultado. */
106
+ function antes({ agente, ferramenta, args, decisao, motivo }) {
107
+ const id = `${process.pid}-${Date.now().toString(36)}-${++seq}`;
108
+ const linha = { id, quando: new Date().toISOString(), agente, ferramenta, args: resumirArgs(args), decisao };
109
+ if (motivo) linha.motivo = semSegredo(String(motivo).slice(0, 200));
110
+ escrever(linha);
111
+ return id;
112
+ }
113
+
114
+ /** A linha de DEPOIS: deu certo ou não, e o erro curto. */
115
+ function depois(id, result) {
116
+ const ok = !(result && result.ok === false);
117
+ const linha = { id, quando: new Date().toISOString(), resultado: ok ? 'ok' : 'falhou' };
118
+ if (!ok && result.error) linha.erro = semSegredo(String(result.error).slice(0, 200));
119
+ escrever(linha);
120
+ }
121
+
122
+ module.exports = { antes, depois, problema, resumirArgs, arquivoDoDia, arquivoComEspaco, MAX_BYTES, RETER_DIAS };
package/lib/browser.js CHANGED
@@ -1164,6 +1164,12 @@ async function abaEscondida(url, op = {}) {
1164
1164
  await s.send('Page.enable');
1165
1165
  await s.send('Runtime.enable');
1166
1166
  await s.send('Log.enable');
1167
+ // A leitura em cascata pede isto: o Chrome segue redirect e o
1168
+ // JavaScript da página pede a rede sozinho. A mesma trava da busca
1169
+ // simples (hostBloqueado) vale para cada pedido, não só para a URL
1170
+ // inicial. Sem isto, uma página pública abre e o passo 2 alcança a
1171
+ // rede local (review do #96).
1172
+ if (op.bloquearPrivado) await travarRedeLocal(s);
1167
1173
  // A aba nova nasce ATRÁS do about:blank inicial, e aba de fundo é
1168
1174
  // onde o Chrome economiza: para o vídeo, estrangula os timers. Trazer
1169
1175
  // para a frente é o que faz a página se comportar como na tela da pessoa.
@@ -1232,6 +1238,47 @@ async function abrirEscondido(url) {
1232
1238
  return { fechar: aba.fechar, erros: aba.erros };
1233
1239
  }
1234
1240
 
1241
+ /* Rede local, link-local e arquivo: a mesma lista da busca simples. Exigir
1242
+ aqui, e não só em referencia.js, porque o Chrome é quem abre a URL. */
1243
+ function destinoDeRedeLocal(url) {
1244
+ let u;
1245
+ try { u = new URL(url); } catch { return true; }
1246
+ if (u.protocol === 'file:') return true;
1247
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
1248
+ return require('./referencia').hostBloqueado(u.hostname);
1249
+ }
1250
+
1251
+ async function travarRedeLocal(s) {
1252
+ const anterior = s.onEvent;
1253
+ s.onEvent = (metodo, p) => {
1254
+ if (metodo === 'Fetch.requestPaused') {
1255
+ const bloquear = destinoDeRedeLocal(p.request && p.request.url);
1256
+ const chamada = bloquear ? 'Fetch.failRequest' : 'Fetch.continueRequest';
1257
+ const params = bloquear
1258
+ ? { requestId: p.requestId, errorReason: 'BlockedByClient' }
1259
+ : { requestId: p.requestId };
1260
+ s.send(chamada, params).catch(() => {});
1261
+ }
1262
+ if (anterior) anterior(metodo, p);
1263
+ };
1264
+ await s.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
1265
+ }
1266
+
1267
+ /* O HTML que a página tem DEPOIS de rodar o JavaScript: o passo 2 da leitura
1268
+ em cascata (lib/leitura.js), pra página que a busca simples devolve vazia. */
1269
+ async function htmlEscondido(url, op = {}) {
1270
+ if (destinoDeRedeLocal(url)) return null;
1271
+ const aba = await abaEscondida(url, { prazoLoad: op.prazo || 12000, assentar: op.assentar || 1500, bloquearPrivado: true });
1272
+ try {
1273
+ const r = await aba.sessao.send('Runtime.evaluate', {
1274
+ expression: 'document.documentElement.outerHTML', returnByValue: true,
1275
+ }).catch(() => null);
1276
+ return (r && r.result && r.result.value) || null;
1277
+ } finally {
1278
+ await aba.fechar();
1279
+ }
1280
+ }
1281
+
1235
1282
  /* O revisor é ferramenta de uma tarefa, não um processo que fica. Deixá-lo de
1236
1283
  pé come memória até a pessoa desligar o computador, e ele não é o navegador
1237
1284
  dela — ninguém sentiria falta nem saberia que existe para fechar. */
@@ -1296,5 +1343,5 @@ module.exports = {
1296
1343
  scriptDaSeta, verSeta, findChrome,
1297
1344
  open, click, clickText, clickSelector, type, typeInSelector,
1298
1345
  key, screenshot, evaluate, waitFor, wait, scroll, close,
1299
- inspecionarEscondido, rodarEscondido, abrirEscondido, abaEscondida, fecharRevisor, subirRevisor, aquecerRevisor,
1346
+ inspecionarEscondido, rodarEscondido, abrirEscondido, abaEscondida, htmlEscondido, fecharRevisor, subirRevisor, aquecerRevisor,
1300
1347
  };
package/lib/catalogo.js CHANGED
@@ -31,9 +31,9 @@ const GRUPOS = [
31
31
  { tool: 'list_dir', escreve: false, faz: 'mostra a árvore de pastas',
32
32
  args: 'path (opcional, padrão "."). depth (opcional, número, padrão 2).' },
33
33
  { tool: 'glob', escreve: false, faz: 'acha arquivo pelo nome (**/*.js)',
34
- args: 'pattern: padrão de nome, ex "**/*.js" ou "src/**/*.{ts,tsx}".' },
34
+ args: 'pattern: padrão de nome, ex "**/*.js" ou "src/**/*.{ts,tsx}". Numa pasta que o usuário citou: padrão absoluto ("/caminho/**/*.js"), sem trocar a de trabalho.' },
35
35
  { tool: 'grep', escreve: false, faz: 'acha texto dentro dos arquivos',
36
- args: 'pattern: expressão regular. glob (opcional): restringe aos arquivos que casam. ignoreCase (opcional, bool). contexto (opcional, número): linhas ao redor do achado.' },
36
+ args: 'pattern: expressão regular. glob (opcional): restringe aos arquivos que casam. ignoreCase (opcional, bool). contexto (opcional, número): linhas ao redor do achado. Numa pasta que o usuário citou: glob absoluto ("/caminho/**"), sem trocar a de trabalho.' },
37
37
  ],
38
38
  },
39
39
  {
@@ -93,8 +93,8 @@ const GRUPOS = [
93
93
  + 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link} '
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
- + '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: o quadro fixo do reel (cantos, o assunto no alto, "01 · seção", o tempo) — SÓ quando a pessoa pedir; vazio = sem moldura (ela entrega que o vídeo foi gerado); heroi em cada parte: o desenho da batida além do texto, tirado do CONTEÚDO — um ícone 3D RENDERIZADO da biblioteca ("fluent3d:hot beverage", "fluent3d:money bag"; procure com estudio_midia acao:icones3d — é o que mais parece 3D de verdade), uma animação pronta ("noto:1f525"; acao:animacoes), um ícone do Iconify do objeto que a fala cita ("mdi:bread-slice", "mdi:qrcode"; 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 | personagem (a ANIMAÇÃO 3D de verdade, o vídeo GRAVADO em 3D, no visual de blocos/voxel de render de cinema: bonecos de blocos e o mascote laranja (quem é IA) num set de ladrilhos com uma parede de telas, luz roxa de recorte, o fundo desfocado de lente e a legenda branca no meio — cada parte é UMA fala {falante, texto, emocao?, destaque?}, sem tipo; a boca segue a voz, gestos, e a câmera corta entre aberto, ombro e close de baixo; é a escolha para animação 3D, personagem, boneco, avatar, mascote, podcast, entrevista ou alguém FALANDO em 3D — um narrador só também serve (sem falante). As telas do fundo mostram o ASSUNTO: paineis:["título curto", …] (até 12) — vazio, saem dos destaques e das palavras fortes. 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 telas (o roxo da referência, padrão)|neon|oceano|terra; cor do mascote: personagens:{"claude":{cor:"#d97757"}}; visual:"liso" só se pedirem o boneco liso antigo). '
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 — a COISA que a frase nomeia, 2 a 4 palavras, não a frase inteira; rota:"tipografia" em número/citação, "produto" quando a frase fala do produto; ancora:"palavra" — o clipe troca 0,2 s depois dela) | 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: o quadro fixo do reel (cantos, o assunto no alto, "01 · seção", o tempo) — SÓ quando a pessoa pedir; vazio = sem moldura (ela entrega que o vídeo foi gerado); heroi em cada parte: o desenho da batida além do texto, tirado do CONTEÚDO — um ícone 3D RENDERIZADO da biblioteca ("fluent3d:hot beverage", "fluent3d:money bag"; procure com estudio_midia acao:icones3d — é o que mais parece 3D de verdade), uma animação pronta ("noto:1f525"; acao:animacoes), um ícone do Iconify do objeto que a fala cita ("mdi:bread-slice", "mdi:qrcode"; 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 | personagem (a ANIMAÇÃO 3D de verdade, o vídeo GRAVADO em 3D no visual de blocos/voxel de render de cinema — é a escolha para animação 3D, personagem, bicho, boneco, avatar, mascote, podcast, entrevista ou alguém FALANDO em 3D; cada parte é UMA fala {falante, texto, emocao?, destaque?, acao?, cenario?}, sem tipo). NADA É FIXO, monte o que a pessoa pediu: cena:"texto livre do lugar" (praia, cidade, floresta, parque, fazenda, neve, deserto, espaço, fundo do mar, escritório, sala, cozinha, escola, palco… + hora: "à noite", "ao pôr do sol") ou cena:{lugar, hora, elementos:["palmeira","barraca","violão",…]} — elemento fora do catálogo vira ícone 3D em blocos; parte.cenario muda o lugar a partir daquela fala. Até 4 falantes; personagens:{"nome":{tipo:pessoa|robo|animal|coisa, especie (animal: cachorro|gato|cavalo|vaca|porco|ovelha|raposa|lobo|urso|panda|coelho|leao|tigre|elefante|girafa|tartaruga|galinha|pato|passaro|papagaio|coruja|pinguim|peixe|tubarao|golfinho|dinossauro|sapo|macaco), icone (coisa: "pizza", "café", "polvo" — qualquer objeto/bicho que fala), pessoa: pele, cabelo:curto|cacheado|longo|topete|coque|careca, corCabelo, roupa, oculos, barba; robo: cor}} — sem tipo, o nome decide (nome de bicho vira bicho, de IA vira o mascote laranja, de objeto vira coisa); figurantes:["cachorro","pessoa"] (quem está na cena sem falar); BICHO NÃO FALA: a fala de um bicho sai da narração e ele fica na cena — só fala se a pessoa pedir ("cachorro falante") ou personagens.x.fala:true; a voz de cada falante sai do GÊNERO do nome (personagens.x.genero:f|m manda), uma voz por personagem do começo ao fim; bigode:true e cabeca:"cubo" (o rosto de pixel antigo) em personagens; sem lugar no pedido, o set é a cobertura à noite (janela da cidade, cartões de interface na frente). acao em cada parte: acenar|apontar|pular|dancar|comemorar|pensar|andar|correr. emocao feliz|serio|surpreso|pensativo|animado|curioso; destaque:"texto curto"; paineis:["título",…] só no set de telas (o padrão quando o pedido não diz lugar); visual:"liso" só se pedirem o boneco liso antigo). '
98
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. '
@@ -109,7 +109,10 @@ const GRUPOS = [
109
109
  + 'o render empurra para dentro, encolhe o texto largo e afasta o colado; enfeite de fundo marque decorativo: true.' },
110
110
 
111
111
  { tool: 'estudio_midia', escreve: true, faz: 'procura e baixa ícone, imagem, música e efeito sonoro da internet, e lista as fontes',
112
- args: 'acao: biblioteca | imagem | icones | icones3d | animacoes | som | trilha | efeito | fontes | print | elementos | elemento | recortar. termo. id (para baixar). '
112
+ args: 'acao: biblioteca | movimentos | cenarios | salvar_cenario | imagem | icones | icones3d | animacoes | som | trilha | efeito | fontes | print | elementos | elemento | recortar. termo. id (para baixar). '
113
+ + 'movimentos termo:"quique" categoria:entrada|saida|enfase|loop: os 100+ movimentos prontos com nome; no elemento, movimento:"<nome>" (ou {nome, em, duracao}, ou uma lista) vira quadros-chave sozinho. Varie: não use o mesmo em todo elemento. Pode inventar o seu em anima. '
114
+ + 'cenarios: os lugares prontos do vídeo 3D e os que a pessoa já salvou. Pode CRIAR um cenário novo na cena (lugar base + ceu, chao, parede, luz, acento, hora, elementos); '
115
+ + 'no fim do vídeo pergunte "posso salvar este cenário pra usar na próxima vez?" e, SÓ com o sim, salvar_cenario com nome, cenario {…} e autorizado:true. Depois, lugar:"<nome>" traz ele de volta. '
113
116
  + 'biblioteca: o cardápio INTEIRO do que existe (ícone 2D, ícone 3D, animação pronta, efeito sonoro, música, fonte, personagem 3D) — olhe antes de escolher. '
114
117
  + 'icones3d termo:"coffee": os 1.500+ ícones 3D renderizados do Fluent Emoji (MIT); com item:"fluent3d:<nome>" e o id do projeto, baixa e devolve o elemento {tipo:"imagem"} pronto. '
115
118
  + 'animacoes termo:"fire": as 881 animações prontas do Noto (Lottie, vetor; CC BY 4.0, ponha o crédito); com item:"noto:<código>", baixa e devolve o elemento {tipo:"kit", peca:"animacao", src, loop} pronto. '
@@ -119,7 +122,7 @@ const GRUPOS = [
119
122
  + '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. '
120
123
  + '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. '
121
124
  + '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; '
122
- + '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). '
125
+ + '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). No vídeo 3D (estilo personagem), a peça ENTRA NA CENA em blocos: passe roteiro.objetos:[{nome:"logo", src:<o src devolvido>}] (até 3). '
123
126
  + 'recortar: arquivo — tira o fundo de qualquer foto com IA (rembg), PNG transparente. '
124
127
  + '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. '
125
128
  + 'guardar-efeito {arquivo, nome, descricao}: guarda um componente que você escreveu e ficou bom, para os próximos vídeos '
@@ -171,6 +174,20 @@ const GRUPOS = [
171
174
  + 'A pauta é para LER e compor com as primitivas — não copie os campos dela para dentro do plano.' },
172
175
  ],
173
176
  },
177
+ {
178
+ /* MCP primeiro, mouse por último: o serviço pela API dele (Notion,
179
+ GitHub, Slack…) é uma chamada; o mesmo pela tela são vinte cliques. */
180
+ nome: 'Conectores',
181
+ resumo: 'MCP: age num serviço ou app pela API dele, sem mouse — procura, conecta e usa',
182
+ itens: [
183
+ { tool: 'mcp_procurar', escreve: false, faz: 'acha o servidor MCP de um serviço (lista curada + registro oficial)',
184
+ args: 'busca: o serviço ou app, ex "notion", "github", "planilha".' },
185
+ { tool: 'mcp_conectar', escreve: false, faz: 'instala, sobe e valida um servidor MCP — pede autorização antes',
186
+ args: 'nome: curto, ex "notion" (curado: só o nome basta). comando (opcional) e args (opcional, lista): do "instalar" do mcp_procurar. env (opcional, objeto): as chaves que o servidor pede — sem elas, pergunte ao usuário.' },
187
+ { tool: 'mcp_usar', escreve: false, faz: 'chama uma ferramenta de um servidor MCP conectado',
188
+ args: 'servidor: nome do servidor. ferramenta: nome da tool dele. argumentos (opcional, objeto): os args da tool.' },
189
+ ],
190
+ },
174
191
  {
175
192
  nome: 'Computador',
176
193
  resumo: 'janelas, botões, mouse e teclado — age em qualquer programa e na área de trabalho',
@@ -214,6 +231,12 @@ const GRUPOS = [
214
231
  // ser para os outros casos). Esta tool é a única porta pra lá.
215
232
  { tool: 'remember_general', escreve: false, faz: 'grava algo sobre o usuário que vale para todo projeto (memory-geral.md)',
216
233
  args: 'fato: o que lembrar, em uma frase.' },
234
+ // A troca de pasta pede o SIM do usuário, e quem pergunta é o bin:
235
+ // por isso o loop (lib/act.js) atende, não o tools.execute.
236
+ { tool: 'mudar_pasta', escreve: false, noLoop: true,
237
+ faz: 'troca a pasta de trabalho para outra que o usuário citou — só depois do sim dele',
238
+ args: 'caminho: a pasta (absoluto, ~/…, ou o nome de um projeto). motivo: uma frase do porquê, aparece na pergunta ao usuário. '
239
+ + 'Para só LER lá não precisa trocar: read_file/list_dir com o caminho absoluto, glob/grep com padrão absoluto.' },
217
240
  // Estas duas o loop do agente atende (lib/act.js), não o tools.execute.
218
241
  { tool: 'spawn_agent', escreve: false, faz: 'chama um subagente especialista para uma parte da tarefa', noLoop: true,
219
242
  args: 'description: 3 a 5 palavras, aparece na tela do usuário. prompt: instrução completa e autossuficiente — o subagente não vê esta conversa. especialista (opcional): frontend, backend, testes, design, dados, revisor ou ambiente — cada um chega com o briefing da profissão (lib/especialistas.js); sem ele, a tarefa sugere.' },
@@ -466,6 +466,8 @@ const ATALHOS = [
466
466
  ['Ctrl+C', 'limpa o que foi digitado; com a caixa vazia, sai'],
467
467
  ['Ctrl+D', 'sai (com a caixa vazia)'],
468
468
  ['Ctrl+L', 'limpa a tela'],
469
+ ['PgUp · PgDn', 'rola a conversa'],
470
+ ['arrastar o mouse', 'seleciona texto; Cmd+C (Mac) ou Ctrl+Shift+C copia'],
469
471
  ['Ctrl+A · Ctrl+E', 'vai para o começo · o fim da linha'],
470
472
  ['Ctrl+U · Ctrl+K', 'apaga antes · depois do cursor'],
471
473
  ['Ctrl+W', 'apaga a palavra antes do cursor'],
package/lib/config.js CHANGED
@@ -13,6 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const os = require('os');
16
+ const { nomeCurto } = require('./nome-curto.js');
16
17
 
17
18
  const HOME = os.homedir();
18
19
  const ROOT_DIR = path.join(HOME, 'primocode');
@@ -106,12 +107,27 @@ function slugify(name) {
106
107
  .replace(/-+$/, ''); // o corte pode deixar hífen no fim
107
108
  }
108
109
 
110
+ /* O nome da pasta de um projeto em ~/primocode.
111
+ *
112
+ * Curto e legível (lib/nome-curto.js): "landing da padaria do zé com
113
+ * cardápio" vira `landing-padaria-ze-cardapio`, não a frase inteira. Mas uma
114
+ * pasta que JÁ existe com o nome comprido de antes continua sendo ela —
115
+ * renomear sozinho o projeto de alguém é pior que um nome feio. */
116
+ function nomeDaPasta(projectName) {
117
+ const antigo = slugify(projectName);
118
+ if (antigo && fs.existsSync(path.join(ROOT_DIR, antigo))) return antigo;
119
+ // Já veio como nome de pasta (do modelo, do /novo, com o -2 da colisão):
120
+ // passar de novo pela régua tiraria o "-2" e cairia no projeto do vizinho.
121
+ if (/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(String(projectName || '')) && antigo.length <= 40) return antigo;
122
+ return nomeCurto(projectName) || antigo;
123
+ }
124
+
109
125
  function createProjectDir(projectName) {
110
126
  ensureStructure();
111
127
  /* Nome sem letra nem número ("!!!", só emoji, só japonês) dava slug
112
128
  VAZIO — e path.join(ROOT_DIR, '') é a própria raiz: o "projeto" virava
113
129
  ~/primocode, com memory.md gravado nela. */
114
- const slug = slugify(projectName) || `projeto-${today()}`;
130
+ const slug = nomeDaPasta(projectName) || `projeto-${today()}`;
115
131
  const projectDir = path.join(ROOT_DIR, slug);
116
132
  ensureDir(projectDir);
117
133
  const memoryPath = path.join(projectDir, 'memory.md');
@@ -123,7 +139,7 @@ function createProjectDir(projectName) {
123
139
 
124
140
  function getProjectDir(projectName) {
125
141
  if (!projectName) return ROOT_DIR;
126
- return path.join(ROOT_DIR, slugify(projectName));
142
+ return path.join(ROOT_DIR, nomeDaPasta(projectName) || slugify(projectName));
127
143
  }
128
144
 
129
145
  function listProjects() {
@@ -348,13 +364,25 @@ function appendHistory(entry) {
348
364
  * ativo fica guardado aqui, num arquivo só dele (a config tem lista fechada de
349
365
  * campos), e a próxima sessão continua nele. Vale por uma semana: depois
350
366
  * disso, abrir o PrimoCode e cair num projeto esquecido seria a surpresa. */
367
+ /* As RECENTES vão junto, no mesmo arquivo: são as pastas em que a pessoa
368
+ trabalhou, e é por elas que "olha lá no repo do site" acha o site sem a
369
+ pessoa digitar o caminho (lib/outra-pasta.js). Dez bastam. */
351
370
  function lembrarProjeto(dir, origem = 'projeto') {
352
371
  try {
353
372
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
354
- fs.writeFileSync(PROJETO_ATIVO_FILE, JSON.stringify({ dir, origem, quando: Date.now() }, null, 2), 'utf-8');
373
+ const recentes = [dir, ...pastasRecentes().filter((d) => path.resolve(d) !== path.resolve(dir))].slice(0, 10);
374
+ fs.writeFileSync(PROJETO_ATIVO_FILE, JSON.stringify({ dir, origem, quando: Date.now(), recentes }, null, 2), 'utf-8');
355
375
  } catch { /* lembrar é conveniência: falhou, a sessão segue igual */ }
356
376
  }
357
377
 
378
+ function pastasRecentes() {
379
+ try {
380
+ const j = JSON.parse(fs.readFileSync(PROJETO_ATIVO_FILE, 'utf-8'));
381
+ const lista = Array.isArray(j.recentes) ? j.recentes : (j.dir ? [j.dir] : []);
382
+ return lista.filter((d) => typeof d === 'string' && d);
383
+ } catch { return []; }
384
+ }
385
+
358
386
  function projetoLembrado(maxDias = 7) {
359
387
  try {
360
388
  const j = JSON.parse(fs.readFileSync(PROJETO_ATIVO_FILE, 'utf-8'));
@@ -370,7 +398,7 @@ function esquecerProjeto() {
370
398
 
371
399
  module.exports = {
372
400
  tokensDoAssunto, GENERICAS,
373
- lembrarProjeto, projetoLembrado, esquecerProjeto, PROJETO_ATIVO_FILE,
401
+ lembrarProjeto, projetoLembrado, esquecerProjeto, PROJETO_ATIVO_FILE, pastasRecentes, nomeDaPasta,
374
402
  loadConfig, saveConfig, appendHistory, servidor, SERVIDOR,
375
403
  CONFIG_DIR, CONFIG_FILE, HISTORY_FILE, ROOT_DIR, GENERAL_MEMORY,
376
404
  ensureStructure, slugify, stripAccents,
package/lib/entrada.js CHANGED
@@ -311,7 +311,7 @@ class Entrada extends EventEmitter {
311
311
  this._aoTeclar = (ch, key) => this._tecla(ch, key);
312
312
  process.stdin.on('keypress', this._aoTeclar);
313
313
 
314
- this._aoRedimensionar = () => { if (this.cheia) this._pintar(); else if (this.desenhado) this._redesenhar(); };
314
+ this._aoRedimensionar = () => { if (this.cheia) { this._quadroAnterior = null; this._pintar(); } else if (this.desenhado) this._redesenhar(); };
315
315
  process.stdout.on('resize', this._aoRedimensionar);
316
316
 
317
317
  /* Todo `console.log` do programa passa a apagar a caixa antes de
@@ -399,7 +399,7 @@ class Entrada extends EventEmitter {
399
399
  suspender() { this.suspenso = true; if (this.cheia) this._agendar(); else this._apagar(); }
400
400
  retomar() {
401
401
  this.suspenso = false;
402
- if (this.cheia && this.ativo && !this.fechado) { this.painelTexto = null; return this._pintar(); }
402
+ if (this.cheia && this.ativo && !this.fechado) { this.painelTexto = null; this._quadroAnterior = null; return this._pintar(); }
403
403
  if (this.ativo && !this.fechado) this._desenhar();
404
404
  }
405
405
 
@@ -412,8 +412,13 @@ class Entrada extends EventEmitter {
412
412
  // ── a tela cheia ─────────────────────────────────────────────────────
413
413
 
414
414
  /* A tela alternativa (a do vim): o que estava no terminal fica guardado e
415
- volta inteiro na saída. Com ela, a roda do mouse — senão o terminal
416
- transformaria a roda em seta para cima e a seta é o histórico. */
415
+ volta inteiro na saída.
416
+
417
+ SEM capturar o mouse (o modo 1000 do xterm). Capturar dava a roda para rolar a
418
+ conversa, mas tirava do terminal o clique-e-arrasta: ninguém conseguia
419
+ selecionar um trecho da resposta para copiar ("na CLI não consigo
420
+ selecionar texto", 11/10). Selecionar vale mais que a roda: rolar fica
421
+ no PgUp/PgDn, e o /copiar copia a resposta inteira. */
417
422
  _entrarNaTela() {
418
423
  if (this._naTela) return;
419
424
  this._naTela = true;
@@ -430,10 +435,11 @@ class Entrada extends EventEmitter {
430
435
  set(v) { eu._colunasReais = v; }, // o Node escreve aqui quando a janela muda
431
436
  });
432
437
  }
433
- this.escreverCru('\x1b[?1049h\x1b[?1000h\x1b[?1006h\x1b[2J');
438
+ this.escreverCru('\x1b[?1049h\x1b[2J');
439
+ this._quadroAnterior = null; // a tela foi limpa: o próximo quadro vai inteiro
434
440
  if (!this._aoSairDoProcesso) {
435
441
  // Um erro que derruba o programa não pode deixar o terminal na
436
- // tela alternativa, sem cursor e capturando o mouse.
442
+ // tela alternativa e sem cursor.
437
443
  this._aoSairDoProcesso = () => this._sairDaTela();
438
444
  process.on('exit', this._aoSairDoProcesso);
439
445
  }
@@ -442,7 +448,9 @@ class Entrada extends EventEmitter {
442
448
  _sairDaTela() {
443
449
  if (!this._naTela) return;
444
450
  this._naTela = false;
445
- this.escreverCru('\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l');
451
+ /* Desliga o mouse mesmo sem ter ligado: uma versão antiga (ou um
452
+ crash dela) pode ter deixado o terminal capturando o clique. */
453
+ this.escreverCru('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?25h\x1b[?1049l');
446
454
  }
447
455
 
448
456
  /** Junta as mudanças de um mesmo instante num quadro só. */
@@ -494,9 +502,21 @@ class Entrada extends EventEmitter {
494
502
  if (!this.cheia || this.fechado || !this._naTela) return;
495
503
  const q = this._quadroCheio();
496
504
  this.rolagem = Math.min(this.rolagem, q.rolagemMax);
497
- let out = '\x1b[?25l';
498
- q.linhas.forEach((l, i) => { out += `\x1b[${i + 1};1H${l}\x1b[0m\x1b[K`; });
499
- out += `\x1b[${q.cursor.linha + 1};${q.cursor.coluna + 1}H\x1b[?25h`;
505
+ /* Só reescreve a linha que MUDOU. Repintar a tela inteira a cada
506
+ quadro (140 ms enquanto trabalha) apagava a seleção do mouse no
507
+ meio do arrasto: o terminal desfaz a seleção quando a linha
508
+ selecionada é reescrita. A conversa parada fica intocada. */
509
+ const antes = this._quadroAnterior;
510
+ const mesmoTamanho = antes && antes.linhas.length === q.linhas.length;
511
+ let corpo = '';
512
+ q.linhas.forEach((l, i) => {
513
+ if (mesmoTamanho && antes.linhas[i] === l) return;
514
+ corpo += `\x1b[${i + 1};1H${l}\x1b[0m\x1b[K`;
515
+ });
516
+ const mesmoCursor = antes && antes.cursor.linha === q.cursor.linha && antes.cursor.coluna === q.cursor.coluna;
517
+ this._quadroAnterior = { linhas: q.linhas, cursor: q.cursor };
518
+ if (!corpo && mesmoCursor) { this.desenhado = { linhas: q.linhas, cursorLinha: q.cursor.linha }; return; }
519
+ const out = (corpo ? '\x1b[?25l' + corpo : '') + `\x1b[${q.cursor.linha + 1};${q.cursor.coluna + 1}H` + (corpo ? '\x1b[?25h' : '');
500
520
  this._pintando = true;
501
521
  this.escreverCru(out);
502
522
  this._pintando = false;
@@ -692,7 +712,9 @@ class Entrada extends EventEmitter {
692
712
  }
693
713
 
694
714
  /* A roda do mouse chega como "\x1b[<64;x;yM", e o readline a entrega
695
- picada, tecla por tecla. Daqui até o M (ou m), tudo é do mouse. */
715
+ picada, tecla por tecla. Daqui até o M (ou m), tudo é do mouse.
716
+ Não ligamos mais a captura, mas um terminal que ficou com ela
717
+ ligada (de outro programa) não pode digitar lixo na caixa. */
696
718
  if (key && key.code === '[<') { this._mouse = ''; return; }
697
719
  if (this._mouse != null) {
698
720
  if (/^[mM]$/.test(seq)) {