primocode 11.2.0 → 11.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/lib/cli/sessao.js CHANGED
@@ -13,6 +13,7 @@ const os = require('os');
13
13
 
14
14
  const pkg = require('../../package.json');
15
15
  const ui = require('../tui/ui');
16
+ const tela = require('../tui/tela.js');
16
17
  const entrada = require('../tui/entrada');
17
18
  const { c, welcomeBox, Spinner } = ui;
18
19
  const { escolherModo } = require('../modo.js');
@@ -444,9 +445,10 @@ function barraDeStatus() {
444
445
  direita.push(...SL.partes({ modelo: SL.modelo(), ctxPct: uso.pct, tokensHoje: SL.tokensDeHoje(), c }));
445
446
  const aprovar = require('../cli-nova.js').praAprovar();
446
447
  if (aprovar) direita.push(c.warn(`${aprovar} pra aprovar`));
447
- direita.push(state.trabalhando && rl && rl.ocupado
448
+ const atalhos = state.trabalhando && rl && rl.ocupado
448
449
  ? c.warn('trabalhando') + c.dim(' · ') + c.white('esc esc') + c.dim(' para')
449
- : c.white('tab') + c.dim(' modos ') + c.white('ctrl+p') + c.dim(' comandos'));
450
+ : c.white('tab') + c.dim(' modos ') + c.white('ctrl+p') + c.dim(' comandos');
451
+ direita.push(atalhos);
450
452
 
451
453
  /* O modo DENTRO do painel. No auto, depois do primeiro pedido, ele diz
452
454
  qual modo acabou respondendo — senão você nunca sabe quem respondeu. */
@@ -455,9 +457,12 @@ function barraDeStatus() {
455
457
  if (state.mode === 'auto' && state.modoEfetivo) {
456
458
  modo += c.dim(' → ') + c.muted(NOME_MODO[state.modoEfetivo] || state.modoEfetivo);
457
459
  }
458
- modo += c.dim(' · ') + c.white(effort.normalizar(state.config.effort)) + ' ' + c.dim('PrimoCode');
460
+ /* "Build · Primo Max · esforço alto": o modo, o plano da conta ("Primo Max") e o esforço. */
461
+ let planoNome = '';
462
+ try { const eu = conta.atual(); if (eu && eu.plano && eu.plano !== 'free') planoNome = ' ' + String(eu.plano).replace(/^./, (x) => x.toUpperCase()); } catch { /* sem conta */ }
463
+ modo += c.dim(' · ') + c.white('Primo' + planoNome) + c.dim(' · esforço ') + c.white(effort.normalizar(state.config.effort));
459
464
 
460
- return { modo, cor, esquerda: partes.join(c.dim(' · ')), direita: direita.join(c.dim(' ')) };
465
+ return { modo, cor, esquerda: partes.join(c.dim(' · ')), direita: direita.join(c.dim(' ')), atalhos };
461
466
  }
462
467
 
463
468
  /* A barra da direita da tela cheia, como a do OpenCode: o contexto (quantos
@@ -468,20 +473,61 @@ function barraLateral() {
468
473
  const dir = state.workDir || process.cwd();
469
474
  const ramo = ramoDoGit(dir);
470
475
  const m = state.mode === 'auto' && state.modoEfetivo ? state.modoEfetivo : state.mode;
471
- const secoes = [
472
- { titulo: 'Contexto', linhas: [
473
- c.muted(`${uso.tokens.toLocaleString('pt-BR')} tokens`),
474
- (uso.pct >= 100 ? c.err : uso.pct >= 70 ? c.warn : c.muted)(`${uso.pct}% usado`),
475
- ] },
476
- { titulo: 'Modo', linhas: [
477
- `${COR_MODO[m] || COR_MODO.auto}${NOME_MODO[m] || m}${c.reset}` + c.muted(` · esforço ${effort.normalizar(state.config.effort)}`),
478
- ] },
479
- ];
476
+ const larg = 22;
477
+ const secoes = [];
478
+
479
+ // Contexto: quanto da conversa já foi gasto, com a barra.
480
+ secoes.push({ titulo: 'Contexto', linhas: [
481
+ c.muted(`${uso.tokens.toLocaleString('pt-BR')} tokens · ${uso.pct}%`),
482
+ tela.barraDe(uso.pct, larg, uso.pct >= 100 ? c.err : uso.pct >= 70 ? c.warn : c.brand),
483
+ ] });
484
+
485
+ /* Plano: o plano da CONTA (o que o login confirmou) e o gasto de hoje contra o limite que a
486
+ pessoa ensinou (/usage limite). O servidor não informa o limite da semana, então ele não é inventado. */
487
+ try {
488
+ const eu = conta.atual();
489
+ const L = require('../config/limite.js');
490
+ const hoje = L.usoDeHoje().tokens;
491
+ const lim = L.limiteConhecido();
492
+ const linhas = [];
493
+ if (eu && eu.plano) linhas.push(c.muted(`Plano ${String(eu.plano).replace(/^./, (x) => x.toUpperCase())}`));
494
+ if (lim) {
495
+ const pct = Math.round((hoje / lim) * 100);
496
+ linhas.push(c.muted(`${pct}% do limite de hoje`), tela.barraDe(pct, larg, pct >= 100 ? c.err : pct >= 70 ? c.warn : c.brand));
497
+ } else linhas.push(c.dim(`${hoje.toLocaleString('pt-BR')} tokens hoje`));
498
+ secoes.push({ titulo: 'Plano', linhas });
499
+ } catch { /* o plano nunca derruba a tela */ }
500
+
501
+ secoes.push({ titulo: 'Modo', linhas: [
502
+ `${COR_MODO[m] || COR_MODO.auto}${NOME_MODO[m] || m}${c.reset}` + c.muted(` · esforço ${effort.normalizar(state.config.effort)}`),
503
+ ] });
504
+
505
+ // Tarefas: o write_todos do agente.
506
+ const todos = ui.estadoSessao.todos || [];
507
+ if (todos.length) {
508
+ const marca = { completed: c.ok('✓'), in_progress: c.brand('●'), pending: c.dim('○') };
509
+ secoes.push({ titulo: 'Tarefas', linhas: todos.slice(0, 8).map((t) => `${marca[t.status] || c.dim('○')} ` + (t.status === 'completed' ? c.dim(t.content) : t.status === 'in_progress' ? c.white(t.content) : c.muted(t.content))) });
510
+ }
511
+
512
+ // Arquivos alterados, com +N −M.
513
+ if (ui.estadoSessao.arquivos.size) {
514
+ const arqs = [...ui.estadoSessao.arquivos.entries()].slice(-8);
515
+ secoes.push({ titulo: 'Arquivos alterados', linhas: arqs.map(([f, k]) =>
516
+ c.muted(path.basename(f)) + ' ' + (k.mais ? c.ok('+' + k.mais) : '') + (k.menos ? ' ' + c.err('−' + k.menos) : '')) });
517
+ }
518
+
519
+ // MCP: os servidores configurados (o padrão, o do usuário, o do projeto).
520
+ try {
521
+ const nomes = Object.keys(require('../mcp/mcp.js').servidores(dir));
522
+ if (nomes.length) secoes.push({ titulo: 'MCP', linhas: nomes.slice(0, 5).map((n) => c.ok('●') + ' ' + c.muted(n)) });
523
+ } catch { /* sem MCP, sem seção */ }
524
+
480
525
  if (state.attachedFiles.length) {
481
526
  secoes.push({ titulo: 'Anexos', linhas: state.attachedFiles.map((f) => c.muted(path.basename(f.path))) });
482
527
  }
483
528
  if (state.controle && state.controle.liberado) secoes.push({ titulo: 'Controle do PC', linhas: [c.warn('liberado até fechar')] });
484
529
  return {
530
+ subtitulo: state.currentProject ? state.currentProject : undefined,
485
531
  secoes,
486
532
  rodape: [
487
533
  c.muted(shortPath(dir)) + (ramo ? c.white(':' + ramo) : ''),
@@ -490,6 +536,83 @@ function barraLateral() {
490
536
  };
491
537
  }
492
538
 
539
+ /* ── A PALETA (ctrl+p) E OS DIÁLOGOS ─────────────────────────────────────
540
+ * O desenho é do OpenCode: uma lista com busca no meio da tela. O conteúdo é
541
+ * o que já existe aqui: os comandos de barra, os modos, os agentes da pessoa,
542
+ * as conversas salvas e os temas. Escolher um item roda o comando de sempre. */
543
+ const ATALHOS_DO_LIDER = { '/clear': 'ctrl+x n', '/projects': 'ctrl+x p', '/estudio': 'ctrl+x s' };
544
+
545
+ function paletaDeComandos() {
546
+ const itens = [
547
+ { secao: 'Sugeridos', nome: 'Nova conversa', cmd: '/clear', tecla: 'ctrl+x n' },
548
+ { secao: 'Sugeridos', nome: 'Trocar o modo e o esforço', desc: 'auto · primo code · chat', tecla: 'ctrl+x m', acao: (r) => r.abrirDialogo(dialogoModelo()) },
549
+ { secao: 'Sugeridos', nome: 'Agentes', desc: 'ligar e desligar', tecla: 'ctrl+x a', acao: (r) => r.abrirDialogo(dialogoAgentes()) },
550
+ { secao: 'Sugeridos', nome: 'Conversas salvas', tecla: 'ctrl+x l', acao: (r) => dialogoSessoes().then((d) => d && r.abrirDialogo(d)) },
551
+ { secao: 'Sugeridos', nome: 'Tema', desc: 'cor de destaque', tecla: 'ctrl+x t', acao: (r) => r.abrirDialogo(dialogoTema()) },
552
+ { secao: 'Sugeridos', nome: 'Mostrar ou esconder a barra da direita', tecla: 'ctrl+x b', acao: (r) => { r.lateralOculta = !r.lateralOculta; r._quadroAnterior = null; r._pintar(); } },
553
+ ];
554
+ for (const [nome, desc] of comandosDoMenu()) {
555
+ if (/^(\/uninstall|!comando|\/voice )/.test(nome) || nome.includes(' ') || nome === '/clear') continue;
556
+ itens.push({ secao: 'Comandos', nome, desc, cmd: nome, tecla: ATALHOS_DO_LIDER[nome] || '', preencher: /\s<|<.*>/.test(desc) && /^\/(dir|worktree|rule|lock|attach|read|run|open|history|howto)$/.test(nome) });
557
+ }
558
+ return itens;
559
+ }
560
+
561
+ function dialogoModelo() {
562
+ const atual = state.mode;
563
+ const esforco = effort.normalizar(state.config.effort);
564
+ const itens = [
565
+ ['auto', 'Auto', 'o PrimoCode escolhe entre fazer e conversar'],
566
+ ['primocode', 'Primo Code', 'o agente que cria e edita arquivos'],
567
+ ['chat', 'Chat', 'só conversa, sem mexer em nada'],
568
+ ].map(([id, nome, desc]) => ({ secao: 'Modo', nome: (atual === id ? '● ' : ' ') + nome, desc, id }));
569
+ for (const e of ['low', 'medium', 'high', 'ultracode']) itens.push({ secao: 'Esforço', nome: (esforco === effort.normalizar(e) ? '● ' : ' ') + effort.normalizar(e), desc: e === 'ultracode' ? 'o máximo' : '', esforco: e });
570
+ return {
571
+ titulo: 'Modo e esforço', itens,
572
+ aoEscolher: (it, r) => {
573
+ if (it.id) { state.mode = it.id; state.modoEfetivo = null; cfg.saveConfig({ mode: it.id }); r.toast(`Modo: ${NOME_MODO[it.id]}`, 'ok'); }
574
+ else { cfg.saveConfig({ effort: it.esforco }); state.config.effort = it.esforco; r.toast(`Esforço: ${effort.normalizar(it.esforco)}`, 'ok'); }
575
+ refreshPrompt();
576
+ },
577
+ };
578
+ }
579
+
580
+ function dialogoAgentes() {
581
+ let lista = [];
582
+ try { lista = require('../agentes-pessoais').listar({ dir: state.workDir }); } catch { lista = []; }
583
+ return {
584
+ titulo: 'Agentes',
585
+ vazio: 'Nenhum agente. /agentes criar <nome> | <o que faz> | <ofício>',
586
+ itens: lista.map((a) => ({ nome: (a.ligado ? '● ' : '○ ') + '@' + a.nome, desc: a.descricao || a.oficio || '', agente: a })),
587
+ aoEscolher: (it, r) => {
588
+ try {
589
+ const a = require('../agentes-pessoais').alternar(it.agente.nome, !it.agente.ligado, { dir: state.workDir });
590
+ r.toast(`@${a.nome} ${a.ligado ? 'ligado' : 'desligado'}`, 'ok');
591
+ } catch (e) { r.toast(e.message, 'erro'); }
592
+ },
593
+ };
594
+ }
595
+
596
+ async function dialogoSessoes() {
597
+ if (!conta.atual()) { if (rl) rl.toast('Entre na conta (/login) para ver as conversas salvas', 'aviso'); return null; }
598
+ const l = await nuvem.listarConversas({ limite: 30 });
599
+ if (!l.ok || !l.conversas.length) { if (rl) rl.toast(l.ok ? 'Nenhuma conversa salva ainda' : l.error, 'aviso'); return null; }
600
+ return {
601
+ titulo: 'Conversas',
602
+ itens: l.conversas.map((x, i) => ({ nome: x.titulo.slice(0, 44), desc: `${x.mensagens} msg`, tecla: x.atualizada ? new Date(x.atualizada).toLocaleDateString('pt-BR') : '', n: i + 1 })),
603
+ aoEscolher: (it, r) => { r.line = '/resume ' + it.n; r.cursor = r.line.length; r._enviar(); },
604
+ };
605
+ }
606
+
607
+ function dialogoTema() {
608
+ const atual = ui.temaDoMomento();
609
+ return {
610
+ titulo: 'Tema',
611
+ itens: Object.entries(ui.TEMAS).map(([id, t]) => ({ nome: (atual === id ? '● ' : ' ') + t.nome, id })),
612
+ aoEscolher: (it, r) => { ui.definirTema(it.id); cfg.saveConfig({ tema: it.id }); r._quadroAnterior = null; r._pintar(); r.toast(`Tema: ${ui.TEMAS[it.id].nome}`, 'ok'); },
613
+ };
614
+ }
615
+
493
616
  /* O Tab com a caixa vazia gira o modo, como o "tab agents" do OpenCode:
494
617
  auto → primo code → chat → auto. A escolha fica salva, como a do /mode. */
495
618
  function trocarModo() {
@@ -507,6 +630,7 @@ function refreshPrompt() { if (rl) rl.prompt(); }
507
630
  // Enquanto uma pergunta está no ar, a próxima linha digitada é a RESPOSTA dela
508
631
  // — não um novo comando. Sem isso o "s" de confirmação virava prompt para a IA.
509
632
  let pendingAsk = null;
633
+ let permissaoPendente = null; // resolve a pergunta de permissão quando a pessoa aperta Esc
510
634
 
511
635
  /* ── O QUE A PESSOA ADIANTA ──────────────────────────────────────────────
512
636
  * Enquanto a abertura corre — e ela demora, porque confere o plano no
@@ -922,6 +1046,30 @@ async function prepararPasta(userText) {
922
1046
  * entregar o mouse e o teclado.
923
1047
  */
924
1048
  async function autorizarControle(oQueVaiFazer, { primeira, tipo } = {}) {
1049
+ /* NA TELA CHEIA a pergunta é um PAINEL AMARELO no meio da conversa (o de permissão do OpenCode):
1050
+ "enter sim · a sempre · esc não". Só o /perms perguntar tem Enter = sim, porque a pessoa pediu para
1051
+ ser perguntada naquele projeto. O primeiro pedido de controle do computador e o que "não tem volta"
1052
+ mantêm o Enter = NÃO (o padrão de quem apertou Enter sem ler não entrega o mouse). */
1053
+ if (rl && rl.cheia) {
1054
+ const enterSim = !!tipo && tipo !== 'perigo';
1055
+ const titulo = tipo === 'perigo' ? 'Isto não tem volta' : primeira ? 'O Primo quer usar o seu computador' : 'Permissão';
1056
+ const w = Math.max(30, (process.stdout.columns || 80) - 2);
1057
+ const teclas = enterSim
1058
+ ? c.white('enter') + c.dim(' sim ') + c.white('a') + c.dim(' sempre ') + c.white('esc') + c.dim(' não')
1059
+ : c.white('1') + c.dim(' sim, agora ') + c.white('a') + c.dim(' sempre ') + c.white('enter') + c.dim('/') + c.white('esc') + c.dim(' não');
1060
+ console.log('');
1061
+ console.log(tela.painelCor([c.warn(titulo) + ' ' + c.white(oQueVaiFazer), teclas], 'warn', w).join('\n'));
1062
+ const dito = String(await Promise.race([
1063
+ ask(''),
1064
+ new Promise((res) => { permissaoPendente = () => res('esc'); }),
1065
+ ])).trim().toLowerCase().replace(/[.!?,;:…]+/g, ' ').replace(/\s+/g, ' ').trim();
1066
+ permissaoPendente = null;
1067
+ if (dito === 'esc') { pendingAsk = null; console.log(' ' + c.muted('Não autorizado.') + c.dim(' Ele vai resolver por outro caminho.')); return 'nao'; }
1068
+ if (dito === 'a' || /sess|sempre|tudo|todas|^2$/.test(dito)) { console.log(' ' + c.ok('✓ autorizado') + c.dim(' — esta sessão inteira. Fechar o PrimoCode esquece.')); return 'sessao'; }
1069
+ if (dito === '1' || /^(s|sim|ok|pode|agora|y|yes|claro|vai)\b/.test(dito) || (dito === '' && enterSim)) { console.log(' ' + c.ok('✓ autorizado') + c.dim(' — só esta ação')); return 'agora'; }
1070
+ console.log(' ' + c.muted('Não autorizado.') + c.dim(' Ele vai resolver por outro caminho.'));
1071
+ return 'nao';
1072
+ }
925
1073
  /* O mesmo portão serve ao /permissoes (lib/permission/perigo.js): a pergunta muda de
926
1074
  cabeçalho, as três respostas são as mesmas. */
927
1075
  if (tipo) {
@@ -3009,7 +3157,10 @@ async function handleSlash(text) {
3009
3157
  }
3010
3158
 
3011
3159
  case 'clear': state.history = []; state.memoria = memoria.nova();
3012
- state.gravador.zerar(); console.log(c.ok('Conversa limpa.')); break;
3160
+ state.gravador.zerar();
3161
+ ui.estadoSessao.todos = []; ui.estadoSessao.arquivos.clear();
3162
+ if (rl && rl.cheia) { rl.novaConversa(); rl.toast('Conversa nova', 'ok'); } else console.log(c.ok('Conversa limpa.'));
3163
+ break;
3013
3164
  case 'cls': process.stdout.write('\x1Bc'); break;
3014
3165
 
3015
3166
  /* O /status olha só o servidor. O /doctor olha tudo o que costuma
@@ -3925,6 +4076,7 @@ async function main() {
3925
4076
  contexto, e sem conversa a home põe o logo e a caixa no meio da tela.
3926
4077
  PRIMOCODE_TELA=linha (ou "tela": "linha" no config) volta ao jeito
3927
4078
  antigo, que escreve em linha pelo terminal. */
4079
+ if (state.config.tema) ui.definirTema(state.config.tema);
3928
4080
  const telaCheia = process.stdout.isTTY && process.stdout.rows >= 16
3929
4081
  && (process.env.PRIMOCODE_TELA || state.config.tela || 'cheia') !== 'linha';
3930
4082
  if (!telaCheia) {
@@ -3946,7 +4098,11 @@ async function main() {
3946
4098
  logoAnimado: (quadro, trabalhando) => ui.logoAnimado(state.config.mascot, quadro, trabalhando),
3947
4099
  agora: () => (ui.agora.texto && Date.now() - ui.agora.desde < 10 * 60 * 1000 ? ui.agora.texto : ''),
3948
4100
  versao: VERSION,
3949
- logo: ui.logoComMascote(state.config.mascot),
4101
+ logo: [...ui.mascot(state.config.mascot), '', ...ui.logoBloco()],
4102
+ logoSimples: ui.logoBloco(),
4103
+ plano: () => { try { const eu = conta.atual(); return eu && eu.plano ? 'Plano ' + String(eu.plano).replace(/^./, (x) => x.toUpperCase()) : ''; } catch { return ''; } },
4104
+ paleta: paletaDeComandos,
4105
+ dialogos: { modelo: dialogoModelo, agente: dialogoAgentes, sessoes: dialogoSessoes, tema: dialogoTema },
3950
4106
  pasta: () => {
3951
4107
  const dir = state.workDir || process.cwd();
3952
4108
  const ramo = ramoDoGit(dir);
@@ -3954,7 +4110,8 @@ async function main() {
3954
4110
  },
3955
4111
  });
3956
4112
  if (telaCheia && state.workDirOrigem === 'padrao') {
3957
- console.log(c.warn(` pasta padrão: ${shortPath(state.workDir)}`) + c.dim(' · /dir <pasta> para trabalhar numa sua'));
4113
+ // Um aviso no canto, não uma linha na conversa: a home continua limpa.
4114
+ rl.toast(`pasta padrão: ${shortPath(state.workDir)} · /dir <pasta> para trabalhar numa sua`, 'aviso', 7000);
3958
4115
  }
3959
4116
  /* A última conversa desta pasta, em uma linha (lib/passagem.js). Uma vez por abertura, só aqui (o -p já saiu
3960
4117
  antes) e só se a pessoa não abriu já retomando (-c / --resume). */
@@ -3987,6 +4144,7 @@ async function main() {
3987
4144
  * ela é desenhada pela própria caixa (ver lib/tui/entrada.js). */
3988
4145
  let ultimoEsc = 0;
3989
4146
  rl.on('escape', () => {
4147
+ if (permissaoPendente) { permissaoPendente(); return; } // Esc na pergunta de permissão = não
3990
4148
  if (!busy) { ultimoEsc = 0; return; }
3991
4149
  const agora = Date.now();
3992
4150
  if (agora - ultimoEsc < 2000) {
@@ -343,6 +343,8 @@ function loadConfig() {
343
343
  // Piloto automático e agentes em grupo (docs/estrutura.md, seção 9). Desligados por padrão.
344
344
  piloto: file.piloto === true,
345
345
  grupo: file.grupo === true,
346
+ // Preferências da tela Configurações (legenda, silêncios, efeitos, voz, formato, idioma, testes).
347
+ preferencias: preferenciasValidas(file.preferencias),
346
348
  // A tela de primeira execução do app (docs/estrutura.md, 16.7): some depois de feita ou pulada.
347
349
  inicioFeito: file.inicioFeito === true,
348
350
  rootDir: ROOT_DIR,
@@ -350,12 +352,25 @@ function loadConfig() {
350
352
  };
351
353
  }
352
354
 
355
+ /* Preferências da tela Configurações: só estas chaves, cada uma com o seu tipo. O resto é descartado. */
356
+ const PREFERENCIAS = { legenda: 'b', silencio: 'b', sfx: 'b', testes: 'b', voz: 's', formato: 's', idioma: 's' };
357
+ function preferenciasValidas(o) {
358
+ const out = {};
359
+ if (!o || typeof o !== 'object') return out;
360
+ for (const [k, t] of Object.entries(PREFERENCIAS)) {
361
+ if (t === 'b' && typeof o[k] === 'boolean') out[k] = o[k];
362
+ else if (t === 's' && typeof o[k] === 'string' && o[k].length <= 40) out[k] = o[k];
363
+ }
364
+ return out;
365
+ }
366
+
353
367
  function saveConfig(partial) {
354
368
  ensureDir(CONFIG_DIR);
355
369
  const cur = (() => { try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; } })();
356
370
  // Só persiste o que faz sentido guardar — nada de campos derivados.
357
371
  const { server, model, effort, token, autoApply, currentProject, userName, mascot, reacoes, theme, mode, permissoes, team, piloto, grupo, inicioFeito } = { ...cur, ...partial };
358
- const next = { server, model, effort, token, autoApply, currentProject, userName, mascot, reacoes, theme, mode, permissoes, team, piloto, grupo, inicioFeito };
372
+ const preferencias = preferenciasValidas({ ...(cur.preferencias || {}), ...(partial.preferencias || {}) });
373
+ const next = { server, model, effort, token, autoApply, currentProject, userName, mascot, reacoes, theme, mode, permissoes, team, piloto, grupo, inicioFeito, preferencias };
359
374
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), 'utf8');
360
375
  return next;
361
376
  }
package/lib/diff.js CHANGED
@@ -134,6 +134,8 @@ function desenhar(antes, depois, { prefixo = '', max = MAX_MOSTRADAS } = {}) {
134
134
  const out = [];
135
135
  let desenhadas = 0;
136
136
  let cortou = false;
137
+ let larguraFaixa = 20;
138
+ for (const bloco of blocos) for (const o of bloco) larguraFaixa = Math.max(larguraFaixa, 4 + 1 + 2 + Math.min(o.texto.length, 120) + 1);
137
139
 
138
140
  for (const bloco of blocos) {
139
141
  if (desenhadas >= max) { cortou = true; break; }
@@ -143,8 +145,16 @@ function desenhar(antes, depois, { prefixo = '', max = MAX_MOSTRADAS } = {}) {
143
145
  // removidas, o do antes. Sem número, "+ const x = 1" não diz onde.
144
146
  const n = String(o.b || o.a || '').padStart(4);
145
147
  const texto = o.texto.length > 120 ? o.texto.slice(0, 119) + '…' : o.texto;
146
- if (o.tipo === '+') out.push(prefixo + c.dim(n) + ' ' + c.ok('+ ' + texto));
147
- else if (o.tipo === '-') out.push(prefixo + c.dim(n) + ' ' + c.err('- ' + texto));
148
+ /* As linhas que entraram e saíram levam um FUNDO verde e vermelho até o fim
149
+ (o diff do OpenCode); as que ficaram, só o texto apagado. O fundo vale
150
+ a largura da linha mais comprida do bloco, para a faixa ficar reta. */
151
+ const faixa = (cor, fundo, sinal) => {
152
+ const corpo = `${n} ${sinal} ${texto}`;
153
+ return prefixo + fundo + c.dim(n) + fundo + ' ' + cor + sinal + ' ' + texto
154
+ + ' '.repeat(Math.max(0, larguraFaixa - corpo.length)) + '\x1b[0m';
155
+ };
156
+ if (o.tipo === '+') out.push(faixa('\x1b[38;5;78m', '\x1b[48;5;22m', '+'));
157
+ else if (o.tipo === '-') out.push(faixa('\x1b[38;5;204m', '\x1b[48;5;52m', '-'));
148
158
  else out.push(prefixo + c.dim(n) + ' ' + c.dim(' ' + texto));
149
159
  desenhadas++;
150
160
  }
@@ -0,0 +1,57 @@
1
+ // Transcrição da fala do app (bug 3, 02/11): o renderer grava com MediaRecorder e manda o WAV; aqui ele vira texto
2
+ // com o Whisper local que o produto já tem (voz/primo_voz/transcrever.py, no voz-venv). Sem provedor pago, sem rede.
3
+ // Nunca toca som: só lê um arquivo.
4
+ 'use strict';
5
+
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { spawn, execFileSync } = require('child_process');
10
+
11
+ const PASTA_VOZ = path.join(__dirname, '..', '..', 'voz');
12
+ const TETO_BYTES = 11 * 1024 * 1024; // ~5 min de WAV 16 kHz mono (em base64 cabe no teto de 16 MB do WebSocket)
13
+ const TEMPO_MS = 120000;
14
+
15
+ /** O que falta para transcrever aqui, ou null se está tudo pronto. `py` vem de lib/plugin/voz.js (o venv do Primo ganha). */
16
+ function faltaAlgo(py) {
17
+ if (!py) return { error: 'o modo voz precisa do Python 3, e não achei nenhum nesta máquina.', comoResolver: 'instale o Python 3 e rode `primocode doutor`' };
18
+ try { execFileSync(py, ['-c', 'import faster_whisper'], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 20000 }); return null; } catch {
19
+ return { error: 'falta o Whisper local (faster-whisper), que transforma a sua fala em texto.', comoResolver: 'rode `primocode doutor` (ou abra o /voice no terminal uma vez, que ele instala)' };
20
+ }
21
+ }
22
+
23
+ /** `transcrever(wav, { python })` → { ok, texto } | { ok:false, error, comoResolver? }. Nunca rejeita. */
24
+ function transcrever(wav, { python: py, idioma = 'pt', tempoMs = TEMPO_MS } = {}) {
25
+ py = py || require('../plugin/voz').python();
26
+ const falta = faltaAlgo(py);
27
+ if (falta) return Promise.resolve({ ok: false, ...falta });
28
+ return new Promise((resolve) => {
29
+ const p = spawn(py, ['-m', 'primo_voz.transcrever', 'arquivo', wav, idioma], { cwd: PASTA_VOZ, env: { ...process.env, PYTHONPATH: PASTA_VOZ } });
30
+ let fora = '', erro = '';
31
+ const t = setTimeout(() => { try { p.kill('SIGKILL'); } catch { /* já saiu */ } resolve({ ok: false, error: 'a transcrição demorou demais' }); }, tempoMs);
32
+ p.stdout.on('data', (d) => { fora += d; });
33
+ p.stderr.on('data', (d) => { erro += d; });
34
+ p.on('error', (e) => { clearTimeout(t); resolve({ ok: false, error: e.message }); });
35
+ p.on('close', () => {
36
+ clearTimeout(t);
37
+ try { const r = JSON.parse(fora.trim().split('\n').pop()); resolve(r.ok ? { ok: true, texto: String(r.texto || '') } : { ok: false, error: r.error || 'não consegui transcrever' }); }
38
+ catch { resolve({ ok: false, error: erro.trim().split('\n').pop() || 'o transcritor não respondeu' }); }
39
+ });
40
+ });
41
+ }
42
+
43
+ /** Grava o áudio (base64, WAV) num arquivo temporário, transcreve e apaga. `transcritor` troca o Whisper (teste). */
44
+ async function transcreverBase64(audio, { pasta, transcritor = transcrever } = {}) {
45
+ if (typeof audio !== 'string' || !audio) return { ok: false, error: 'falta o áudio' };
46
+ const bytes = Buffer.from(audio, 'base64');
47
+ if (!bytes.length) return { ok: false, error: 'áudio vazio' };
48
+ if (bytes.length > TETO_BYTES) return { ok: false, error: 'áudio grande demais (máximo ~5 minutos)' };
49
+ if (bytes.subarray(0, 4).toString('latin1') !== 'RIFF') return { ok: false, error: 'o áudio precisa ser WAV' };
50
+ const dir = pasta || path.join(os.tmpdir(), 'primocode-voz');
51
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
52
+ const arq = path.join(dir, `fala-${Date.now()}-${process.pid}.wav`);
53
+ fs.writeFileSync(arq, bytes, { mode: 0o600 });
54
+ try { return await transcritor(arq); } finally { try { fs.rmSync(arq, { force: true }); } catch { /* sem arquivo */ } }
55
+ }
56
+
57
+ module.exports = { transcrever, transcreverBase64, faltaAlgo, TETO_BYTES };
@@ -103,7 +103,7 @@ function acharNavegador(env = process.env, existe = require('fs').existsSync) {
103
103
  function htmlParaPdf(htmlPath, pdfPath, { navegador = acharNavegador() } = {}) {
104
104
  if (!navegador) return { ok: false, erro: 'Chrome/Chromium não encontrado (defina PRIMO_CHROME)' };
105
105
  const { spawnSync } = require('child_process');
106
- const r = spawnSync(navegador, ['--headless=new', '--disable-gpu', '--no-pdf-header-footer',
106
+ const r = spawnSync(navegador, ['--headless=new', '--use-mock-keychain', '--password-store=basic', '--disable-gpu', '--no-pdf-header-footer',
107
107
  `--print-to-pdf=${pdfPath}`, 'file://' + htmlPath], { encoding: 'utf8', timeout: 90000 });
108
108
  return require('fs').existsSync(pdfPath) ? { ok: true } : { ok: false, erro: (r.stderr || '').slice(-300) || 'PDF não saiu' };
109
109
  }
@@ -180,7 +180,7 @@ function htmlParaPngs(htmlPath, baseSemExt, n, w, h, { escala = 1, navegador = a
180
180
  const tmp = `${baseSemExt}.tmp-${i}.html`;
181
181
  const alvo = `${baseSemExt}-${String(i).padStart(2, '0')}.png`;
182
182
  fs.writeFileSync(tmp, html.replace('</head>', `<style>section:not(:nth-of-type(${i})){display:none!important}html,body{height:${h}px;overflow:hidden}</style></head>`));
183
- spawnSync(navegador, ['--headless=new', '--disable-gpu', '--hide-scrollbars', `--force-device-scale-factor=${escala}`,
183
+ spawnSync(navegador, ['--headless=new', '--use-mock-keychain', '--password-store=basic', '--disable-gpu', '--hide-scrollbars', `--force-device-scale-factor=${escala}`,
184
184
  `--window-size=${w},${h}`, `--screenshot=${alvo}`, 'file://' + tmp], { encoding: 'utf8', timeout: 90000 });
185
185
  try { fs.unlinkSync(tmp); } catch {}
186
186
  if (fs.existsSync(alvo)) arquivos.push(alvo); else erro = `PNG ${i} não saiu`;
@@ -519,6 +519,9 @@ function abrirTela({ home, abrir, timeoutMs = 10 * 60 * 1000, porta = 0, inicio
519
519
  const token = crypto.randomBytes(16).toString('hex');
520
520
  let html;
521
521
  try { html = fs.readFileSync(path.join(__dirname, 'gosto', 'tela.html'), 'utf8'); } catch { return resolve({ ok: false, motivo: 'sem_tela' }); }
522
+ // No app do PrimoCode a tela abre num quadro (iframe) e o "Finalizar" (window.close) não faz nada ali: avisa a janela de fora
523
+ // para fechar o quadro. Entra na hora de servir, porque o gosto/tela.html é guardado sem mudança (test/kits-tela.test.js confere o sha1).
524
+ html = html.replace('</body>', "<script>document.addEventListener('click',function(e){var b=e.target.closest&&e.target.closest('#finalizar');if(b&&window.parent!==window)window.parent.postMessage('primocode:tela-fim','*');},true);</script></body>");
522
525
  // os vídeos que já estão no gosto: quem abre o /referencias de novo vê o que já trouxe (só o nome)
523
526
  const jaTem = ((ler(home) || {}).referencias || []).map((r) => String(r.nome || 'vídeo').slice(0, 80));
524
527
  const dados = JSON.stringify({ ESTILOS, GOSTEI, NAO_QUERO, INICIO: inicio === 'referencias' ? 'referencias' : 'carrossel', JA_TEM: jaTem }).replace(/</g, '\\u003c');
@@ -380,9 +380,11 @@ function mandar(rota, corpo) {
380
380
  }
381
381
  function erro(msg) { const e = $('#erro'); e.hidden = !msg; e.textContent = msg || ''; }
382
382
 
383
+ /* No app do PrimoCode a tela abre dentro de um quadro (iframe): avisa a janela de fora que acabou, pra ela fechar o quadro. */
384
+ function avisarApp() { try { if (window.parent !== window) setTimeout(() => window.parent.postMessage('primocode:tela-fim', '*'), 1200); } catch { /* aberta solta no navegador */ } }
383
385
  function mostrar(n) {
384
386
  [1, 4].forEach((i) => { $('#passo' + i).hidden = i !== n; });
385
- if (n === 4) { $('#titulo').textContent = 'Pronto. O PrimoCode já sabe o estilo.'; $('#sub').hidden = true; }
387
+ if (n === 4) { $('#titulo').textContent = 'Pronto. O PrimoCode já sabe o estilo.'; $('#sub').hidden = true; avisarApp(); }
386
388
  window.scrollTo({ top: 0, behavior: parar ? 'auto' : 'smooth' });
387
389
  }
388
390
 
@@ -6,7 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
 
9
- const ICONES = ['folder', 'film', 'palette', 'workflow', 'pen', 'home', 'shield', 'bell', 'sliders'];
9
+ const ICONES = ['folder', 'film', 'palette', 'workflow', 'pen', 'home', 'shield', 'bell', 'sliders', 'code', 'rocket', 'cake', 'store', 'clapperboard', 'megaphone', 'graduation-cap', 'heart', 'camera', 'music'];
10
10
  const COR_PADRAO = '#22d3ee';
11
11
  const hexOk = (h) => /^#[0-9a-f]{6}$/i.test(String(h || ''));
12
12
  const cfg = () => require('./config/config');
@@ -36,6 +36,21 @@ function ramoGit(dir) {
36
36
  } catch { return { git: false, ramo: '' }; }
37
37
  }
38
38
 
39
+ /* O "dono/repo" do remoto origin, lido do .git/config (sem rodar o git). Vazio se não há. */
40
+ function remotoGit(dir) {
41
+ try {
42
+ const cfg = fs.readFileSync(path.join(dir, '.git', 'config'), 'utf8');
43
+ const m = /\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/.exec(cfg);
44
+ const r = m && /[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(m[1]);
45
+ return r ? r[1] : '';
46
+ } catch { return ''; }
47
+ }
48
+
49
+ /* Quantos itens (arquivos e pastas, sem os ocultos) o projeto tem na raiz, e quando mexeram nele por último. */
50
+ function conteudo(dir) {
51
+ try { return { itens: fs.readdirSync(dir).filter((n) => !n.startsWith('.')).length, atualizado: fs.statSync(dir).mtimeMs }; } catch { return { itens: 0, atualizado: null }; }
52
+ }
53
+
39
54
  function resumo(id) {
40
55
  const dir = path.join(raiz(), id);
41
56
  const m = lerMeta(dir);
@@ -43,12 +58,14 @@ function resumo(id) {
43
58
  id, nome: m.nome || id, descricao: m.descricao || '',
44
59
  icone: ICONES.includes(m.icone) ? m.icone : 'folder', cor: hexOk(m.cor) ? m.cor : COR_PADRAO,
45
60
  caminho: dir, arquivado: !!m.arquivado, criado: m.criado || null,
46
- ...ramoGit(dir),
61
+ ...ramoGit(dir), remoto: remotoGit(dir), ...conteudo(dir),
47
62
  };
48
63
  }
49
64
 
50
65
  function listar({ arquivados = false } = {}) {
51
- return cfg().listProjects().map(resumo).filter((p) => arquivados || !p.arquivado);
66
+ /* O mais recente primeiro (como a lista da barra lateral); empate por nome. */
67
+ return cfg().listProjects().map(resumo).filter((p) => arquivados || !p.arquivado)
68
+ .sort((a, b) => String(b.criado || '').localeCompare(String(a.criado || '')) || a.nome.localeCompare(b.nome));
52
69
  }
53
70
 
54
71
  function limpar(c, { criando = false } = {}) {
@@ -246,6 +246,17 @@ function ident(profundidade) {
246
246
  * duas versões do texto, que o `limparParaOModelo` apaga antes de a conversa
247
247
  * seguir. Sem essa limpeza, cada edição mandaria o arquivo inteiro de volta
248
248
  * ao modelo — que acabou de escrevê-lo e não aprende nada relendo. */
249
+ /* +N −M de uma escrita ou edição: o diff de verdade (linhas que entraram e saíram). Arquivo
250
+ novo conta todas as linhas como entradas. Sem as duas versões nem a contagem, null. */
251
+ function contarMudanca(result) {
252
+ if (!result) return null;
253
+ if (typeof result._antes === 'string' && typeof result._depois === 'string') {
254
+ try { return diff.contar(diff.operacoes(result._antes, result._depois)); } catch { return null; }
255
+ }
256
+ if (result.created && Number.isFinite(result.linhas)) return { mais: result.linhas, menos: 0 };
257
+ return null;
258
+ }
259
+
249
260
  function mostrarDiff(result, profundidade) {
250
261
  if (!result || typeof result._antes !== 'string' || typeof result._depois !== 'string') return;
251
262
  const bloco = diff.desenhar(result._antes, result._depois, {
@@ -287,17 +298,24 @@ function logResult(kind, result, profundidade) {
287
298
  }
288
299
 
289
300
  switch (kind) {
290
- case 'write_file':
291
- console.log(p + c.muted(`${result.created ? 'criado' : 'atualizado'} · ${tools.fmtBytes(result.bytes)}`));
301
+ case 'write_file': {
302
+ const k = contarMudanca(result);
303
+ if (k) require('../../tui/ui').registrarArquivo(result.path, k.mais, k.menos);
304
+ console.log(p + c.muted(`${result.created ? 'criado' : 'atualizado'} · ${tools.fmtBytes(result.bytes)}`)
305
+ + (k ? c.muted(' · ') + c.ok(`+${k.mais}`) + ' ' + c.err(`−${k.menos}`) : ''));
292
306
  // Reescrever arquivo que já existia apaga o que estava lá. O
293
307
  // número de bytes não conta isso; o bloco embaixo conta.
294
308
  mostrarDiff(result, profundidade);
295
309
  return;
310
+ }
296
311
  case 'edit_file':
297
312
  case 'multi_edit': {
298
313
  const delta = result.linhasDepois - result.linhasAntes;
299
314
  const sinal = delta > 0 ? c.ok(`+${delta}`) : delta < 0 ? c.err(`${delta}`) : c.muted('±0');
300
- console.log(p + c.muted(`${result.substituicoes} troca${result.substituicoes > 1 ? 's' : ''} · `) + sinal + c.muted(' linhas'));
315
+ const k = contarMudanca(result);
316
+ if (k) require('../../tui/ui').registrarArquivo(result.path, k.mais, k.menos);
317
+ console.log(p + c.muted(`${result.substituicoes} troca${result.substituicoes > 1 ? 's' : ''} · `) + sinal + c.muted(' linhas')
318
+ + (k ? c.muted(' · ') + c.ok(`+${k.mais}`) + ' ' + c.err(`−${k.menos}`) : ''));
301
319
  mostrarDiff(result, profundidade);
302
320
  return;
303
321
  }
@@ -342,6 +360,7 @@ function logResult(kind, result, profundidade) {
342
360
  return;
343
361
  }
344
362
  case 'write_todos': {
363
+ require('../../tui/ui').estadoSessao.todos = result.todos || [];
345
364
  const marca = { completed: c.ok('✔'), in_progress: c.brand('▶'), pending: c.dim('○') };
346
365
  for (const t of result.todos || []) {
347
366
  const texto = t.status === 'completed' ? c.dim(t.content) : c.white(t.content);
@@ -2287,7 +2306,7 @@ async function runActInterno(userPrompt, opts) {
2287
2306
  // arquivo de verdade — a memória era descartada nesse caso, sempre.
2288
2307
  // A raiz de trabalho não é projeto: um memory.md solto em ~/primocode
2289
2308
  // era o que um pedido de vídeo (que trabalha na raiz) deixava para trás.
2290
- if (!subagent && temPasta && require('path').resolve(pastaAtual) !== require('path').resolve(cfg.ROOT_DIR)) {
2309
+ if (!subagent && temPasta && pastaAtual && require('path').resolve(pastaAtual) !== require('path').resolve(cfg.ROOT_DIR)) {
2291
2310
  cfg.appendProjectMemory(pastaAtual, passagemDaParada || `**Pedido:** ${userPrompt.slice(0, 300)}\n**Resultado:** ${finalText.slice(0, 500)}`);
2292
2311
  }
2293
2312
 
package/lib/tool/tools.js CHANGED
@@ -688,6 +688,7 @@ async function despachar(name, args, ctx) {
688
688
  ok: true, created,
689
689
  path: path.relative(projectDir, filePath).replace(/\\/g, '/'),
690
690
  bytes: content.length,
691
+ linhas: content.split('\n').length,
691
692
  ...(created ? {} : { _antes: antes, _depois: content }),
692
693
  };
693
694
  }