primocode 10.4.0 → 10.4.1

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.4.0
1
+ # PrimoCode v10.4.1
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v10.4.0
12
+ ▐▛███▜▌ Primo Code v10.4.1
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -19,6 +19,14 @@ veja `/mascot` e `/name`.
19
19
 
20
20
  ## O que mudou na 10.4
21
21
 
22
+ - **A tela do OpenCode.** O PrimoCode abre em tela cheia: a conversa rola em
23
+ cima, a caixa de digitar fica presa no pé, e a barra da direita mostra a
24
+ sessão, o contexto (tokens e % usado), o modo, a pasta e a versão. Sem
25
+ conversa, a home põe o logo e a caixa no meio. `Tab` troca o modo, `Ctrl+P`
26
+ abre os comandos (o menu abre em cima da caixa), a roda do mouse e o
27
+ `PgUp`/`PgDn` rolam a conversa. `PRIMOCODE_TELA=linha` volta ao jeito antigo.
28
+ - **Motion sem encostar na borda.** Margem de 6% nas laterais e 7% em cima e
29
+ embaixo, e 4% entre elementos — garantido em código antes do render.
22
30
  - **Vídeo gravado em 3D (`estilo: personagem`).** Personagens modelados — gente
23
31
  ou robô — conversando num set (podcast, estúdio, escritório). A boca segue a
24
32
  voz letra por letra, eles piscam, gesticulam e viram a cabeça para quem fala.
package/bin/primocode.js CHANGED
@@ -482,6 +482,36 @@ function barraDeStatus() {
482
482
  return { modo, cor, esquerda: partes.join(c.dim(' · ')), direita: direita.join(c.dim(' ')) };
483
483
  }
484
484
 
485
+ /* A barra da direita da tela cheia, como a do OpenCode: o contexto (quantos
486
+ tokens a conversa já ocupa e quanto isso é do teto), o modo e o esforço, e
487
+ no pé a pasta com o ramo e a versão. */
488
+ function barraLateral() {
489
+ const uso = memoria.usoDoContexto(state.memoria, state.history);
490
+ const dir = state.workDir || process.cwd();
491
+ const ramo = ramoDoGit(dir);
492
+ const m = state.mode === 'auto' && state.modoEfetivo ? state.modoEfetivo : state.mode;
493
+ const secoes = [
494
+ { titulo: 'Contexto', linhas: [
495
+ c.muted(`${uso.tokens.toLocaleString('pt-BR')} tokens`),
496
+ (uso.pct >= 100 ? c.err : uso.pct >= 70 ? c.warn : c.muted)(`${uso.pct}% usado`),
497
+ ] },
498
+ { titulo: 'Modo', linhas: [
499
+ `${COR_MODO[m] || COR_MODO.auto}${NOME_MODO[m] || m}${c.reset}` + c.muted(` · esforço ${effort.normalizar(state.config.effort)}`),
500
+ ] },
501
+ ];
502
+ if (state.attachedFiles.length) {
503
+ secoes.push({ titulo: 'Anexos', linhas: state.attachedFiles.map((f) => c.muted(path.basename(f.path))) });
504
+ }
505
+ if (state.controle && state.controle.liberado) secoes.push({ titulo: 'Controle do PC', linhas: [c.warn('liberado até fechar')] });
506
+ return {
507
+ secoes,
508
+ rodape: [
509
+ c.muted(shortPath(dir)) + (ramo ? c.white(':' + ramo) : ''),
510
+ c.brand('●') + ' ' + c.bold(c.white('Primo')) + c.white('Code') + ' ' + c.dim(VERSION),
511
+ ],
512
+ };
513
+ }
514
+
485
515
  /* O Tab com a caixa vazia gira o modo, como o "tab agents" do OpenCode:
486
516
  auto → primo code → chat → auto. A escolha fica salva, como a do /mode. */
487
517
  function trocarModo() {
@@ -2707,23 +2737,39 @@ async function main() {
2707
2737
  process.exit(0);
2708
2738
  }
2709
2739
 
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)));
2740
+ /* A TELA CHEIA do OpenCode (lib/tela.js) é o padrão: a conversa rola em
2741
+ cima, a caixa fica presa no pé, a barra da direita mostra a sessão e o
2742
+ contexto, e sem conversa a home põe o logo e a caixa no meio da tela.
2743
+ PRIMOCODE_TELA=linha (ou "tela": "linha" no config) volta ao jeito
2744
+ antigo, que escreve em linha pelo terminal. */
2745
+ const telaCheia = process.stdout.isTTY && process.stdout.rows >= 16
2746
+ && (process.env.PRIMOCODE_TELA || state.config.tela || 'cheia') !== 'linha';
2747
+ if (!telaCheia) {
2748
+ if (process.stdout.isTTY && process.stdout.rows >= 20) {
2749
+ const rows = process.stdout.rows;
2750
+ const alto = 8 + 6; // o logo com a versão + a caixa
2751
+ process.stdout.write('\n'.repeat(rows) + '\x1b[H' + '\n'.repeat(Math.max(0, Math.floor((rows - alto) / 2) - 1)));
2752
+ }
2753
+ console.log(welcomeBox(VERSION, state.workDir ? shortPath(state.workDir) : null, {
2754
+ pastaPadrao: state.workDirOrigem === 'padrao',
2755
+ }));
2718
2756
  }
2719
- console.log(welcomeBox(VERSION, state.workDir ? shortPath(state.workDir) : null, {
2720
- pastaPadrao: state.workDirOrigem === 'padrao',
2721
- }));
2722
2757
  rl = entrada.criar({
2723
2758
  status: barraDeStatus,
2724
2759
  comandos: comandosDoMenu,
2725
2760
  vazio: ui.convite(),
2761
+ telaCheia,
2762
+ lateral: barraLateral,
2763
+ versao: VERSION,
2764
+ pasta: () => {
2765
+ const dir = state.workDir || process.cwd();
2766
+ const ramo = ramoDoGit(dir);
2767
+ return shortPath(dir) + (ramo ? ':' + ramo : '');
2768
+ },
2726
2769
  });
2770
+ if (telaCheia && state.workDirOrigem === 'padrao') {
2771
+ console.log(c.warn(` pasta padrão: ${shortPath(state.workDir)}`) + c.dim(' · /dir <pasta> para trabalhar numa sua'));
2772
+ }
2727
2773
  // O spinner desenha dentro da caixa (lib/entrada.js mostrarSpinner).
2728
2774
  if (rl.tty) Spinner.caixa = rl;
2729
2775
  ui.titulo('PrimoCode');
package/lib/entrada.js CHANGED
@@ -40,6 +40,7 @@
40
40
  const readline = require('readline');
41
41
  const { EventEmitter } = require('events');
42
42
  const { c, visibleWidth, truncate } = require('./ui.js');
43
+ const tela = require('./tela.js');
43
44
 
44
45
  // ── O desenho ────────────────────────────────────────────────────────────
45
46
 
@@ -200,8 +201,13 @@ function montar(e) {
200
201
  // campo de digitar para baixo a cada tecla, e o texto que a pessoa
201
202
  // escreve saltaria de lugar enquanto ela escreve. O desenho é o do
202
203
  // OpenCode: fundo de painel, e o escolhido numa faixa pêssego.
204
+ /* NA TELA CHEIA (`menuAcima`) ele abre EM CIMA do painel, como no
205
+ OpenCode: lá a caixa é presa no pé da tela e nada salta — o menu só
206
+ cobre a conversa. */
207
+ const menu = [];
203
208
  if (e.menu && e.menu.itens.length) {
204
- const cabem = Math.max(1, Math.min(e.menu.itens.length, 8));
209
+ const out = menu;
210
+ const cabem = Math.max(1, Math.min(e.menu.itens.length, e.menuAcima ? 10 : 8));
205
211
  // A janela acompanha a seleção: descer na lista rola, em vez de o
206
212
  // item escolhido sumir embaixo.
207
213
  let topo = Math.max(0, Math.min(e.menu.sel - Math.floor(cabem / 2),
@@ -221,10 +227,14 @@ function montar(e) {
221
227
  }
222
228
  }
223
229
 
230
+ let acima = 0;
231
+ if (menu.length && e.menuAcima) { out.splice(e.spinner ? 1 : 0, 0, ...menu); acima = menu.length; }
232
+ else out.push(...menu);
233
+
224
234
  const margem = ' '.repeat(recuo);
225
235
  return {
226
236
  linhas: recuo ? out.map((l) => margem + l) : out,
227
- cursorLinha: (e.spinner ? 1 : 0) + 1 + cursorLinha, // +1 pelo respiro de cima
237
+ cursorLinha: acima + (e.spinner ? 1 : 0) + 1 + cursorLinha, // +1 pelo respiro de cima
228
238
  /* No modo bash o "!" não é desenhado, então tudo depois dele anda uma
229
239
  coluna para a esquerda — e o cursor tem de andar junto, ou ele
230
240
  aparece um caractere à frente de onde o texto realmente está. */
@@ -268,6 +278,12 @@ class Entrada extends EventEmitter {
268
278
  this.oculto = false; // a próxima resposta é senha: ver `ocultarProxima`
269
279
  this.tty = !!(process.stdin.isTTY && process.stdout.isTTY);
270
280
  this.escreverCru = process.stdout.write.bind(process.stdout);
281
+ /* A TELA CHEIA (lib/tela.js): a conversa vive aqui, em linhas, e a
282
+ tela inteira é redesenhada a cada mudança — em vez de o texto subir
283
+ pelo terminal com a caixa atrás. */
284
+ this.cheia = !!(op.telaCheia && this.tty);
285
+ this.conversa = [];
286
+ this.rolagem = 0;
271
287
  }
272
288
 
273
289
  // ── ciclo de vida ────────────────────────────────────────────────────
@@ -290,7 +306,7 @@ class Entrada extends EventEmitter {
290
306
  this._aoTeclar = (ch, key) => this._tecla(ch, key);
291
307
  process.stdin.on('keypress', this._aoTeclar);
292
308
 
293
- this._aoRedimensionar = () => { if (this.desenhado) this._redesenhar(); };
309
+ this._aoRedimensionar = () => { if (this.cheia) this._pintar(); else if (this.desenhado) this._redesenhar(); };
294
310
  process.stdout.on('resize', this._aoRedimensionar);
295
311
 
296
312
  /* Todo `console.log` do programa passa a apagar a caixa antes de
@@ -300,8 +316,20 @@ class Entrada extends EventEmitter {
300
316
  por cima da caixa. */
301
317
  this._stdoutOriginal = process.stdout.write;
302
318
  const eu = this;
319
+ if (this.cheia) this._entrarNaTela();
303
320
  process.stdout.write = function (texto, ...resto) {
304
321
  if (eu._pintando) return eu._stdoutOriginal.call(this, texto, ...resto);
322
+ /* Na tela cheia, o que o programa escreve vira linha da conversa,
323
+ e quem põe na tela é o próximo quadro. Suspensa (o painel do
324
+ /effort desenhando por cima), passa direto. */
325
+ if (eu.cheia) {
326
+ if (eu.suspenso) return eu._stdoutOriginal.call(this, texto, ...resto);
327
+ tela.acrescentar(eu.conversa, texto);
328
+ eu._agendar();
329
+ const fim = resto.find((r) => typeof r === 'function');
330
+ if (fim) fim();
331
+ return true;
332
+ }
305
333
  eu._apagar();
306
334
  const r = eu._stdoutOriginal.call(this, texto, ...resto);
307
335
  /* ── O TEXTO QUE CHEGA EM PEDAÇOS ────────────────────────────
@@ -346,6 +374,7 @@ class Entrada extends EventEmitter {
346
374
 
347
375
  parar() {
348
376
  if (!this.tty) { if (this._rlPipe) this._rlPipe.close(); return; }
377
+ if (this.cheia) this._sairDaTela();
349
378
  this._apagar();
350
379
  this.escreverCru('\x1b[?2004l');
351
380
  if (this._stdoutOriginal) process.stdout.write = this._stdoutOriginal;
@@ -359,8 +388,88 @@ class Entrada extends EventEmitter {
359
388
  /* Enquanto outro desenho é dono da tela — o painel do /effort, uma tela
360
389
  do marketplace — a caixa sai da frente e larga o teclado. Sem isso, os
361
390
  dois desenhariam no mesmo lugar e as setas moveriam os dois. */
362
- suspender() { this.suspenso = true; this._apagar(); }
363
- retomar() { this.suspenso = false; if (this.ativo && !this.fechado) this._desenhar(); }
391
+ suspender() { this.suspenso = true; if (this.cheia) this._sairDaTela(); else this._apagar(); }
392
+ retomar() {
393
+ this.suspenso = false;
394
+ if (this.cheia && this.ativo && !this.fechado) { this._entrarNaTela(); return this._pintar(); }
395
+ if (this.ativo && !this.fechado) this._desenhar();
396
+ }
397
+
398
+ // ── a tela cheia ─────────────────────────────────────────────────────
399
+
400
+ /* A tela alternativa (a do vim): o que estava no terminal fica guardado e
401
+ volta inteiro na saída. Com ela, a roda do mouse — senão o terminal
402
+ transformaria a roda em seta para cima e a seta é o histórico. */
403
+ _entrarNaTela() {
404
+ if (this._naTela) return;
405
+ this._naTela = true;
406
+ /* O resto do programa (o markdown, a fala) quebra linha pela largura
407
+ do terminal. Na tela cheia a conversa é só a coluna da esquerda —
408
+ sem a barra da direita — então, enquanto ela está no ar, é ESSA a
409
+ largura que o programa vê. A real fica guardada para o desenho. */
410
+ if (!this._colunasReais) {
411
+ const eu = this;
412
+ this._colunasReais = process.stdout.columns || 80;
413
+ Object.defineProperty(process.stdout, 'columns', {
414
+ configurable: true,
415
+ get() { return eu._naTela && !eu.suspenso ? tela.larguraDaConversa(eu._colunasReais) : eu._colunasReais; },
416
+ set(v) { eu._colunasReais = v; }, // o Node escreve aqui quando a janela muda
417
+ });
418
+ }
419
+ this.escreverCru('\x1b[?1049h\x1b[?1000h\x1b[?1006h\x1b[2J');
420
+ if (!this._aoSairDoProcesso) {
421
+ // Um erro que derruba o programa não pode deixar o terminal na
422
+ // tela alternativa, sem cursor e capturando o mouse.
423
+ this._aoSairDoProcesso = () => this._sairDaTela();
424
+ process.on('exit', this._aoSairDoProcesso);
425
+ }
426
+ }
427
+
428
+ _sairDaTela() {
429
+ if (!this._naTela) return;
430
+ this._naTela = false;
431
+ this.escreverCru('\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[?1049l');
432
+ }
433
+
434
+ /** Junta as mudanças de um mesmo instante num quadro só. */
435
+ _agendar() {
436
+ if (this._agendado) return;
437
+ this._agendado = setTimeout(() => { this._agendado = null; this._pintar(); }, 16);
438
+ }
439
+
440
+ /** O estado do quadro, para a tela cheia (função pura em lib/tela.js). */
441
+ _quadroCheio() {
442
+ const conversa = this.conversa.some((l) => /\S/.test(l));
443
+ return tela.quadro({
444
+ colunas: this._colunasReais || 80,
445
+ linhas: process.stdout.rows || 24,
446
+ conversa: this.conversa,
447
+ rolagem: this.rolagem,
448
+ home: !this.mandou && !conversa,
449
+ caixa: (colunas, centro) => montar({ ...this._estado(), colunas, centro, menuAcima: true }),
450
+ lateral: { titulo: this.titulo, ...(this.op.lateral ? this.op.lateral() : {}) },
451
+ versao: this.op.versao || '',
452
+ pasta: this.op.pasta ? this.op.pasta() : '',
453
+ });
454
+ }
455
+
456
+ _pintar() {
457
+ if (!this.cheia || this.suspenso || this.fechado || !this._naTela) return;
458
+ const q = this._quadroCheio();
459
+ this.rolagem = Math.min(this.rolagem, q.rolagemMax);
460
+ let out = '\x1b[?25l';
461
+ q.linhas.forEach((l, i) => { out += `\x1b[${i + 1};1H${l}\x1b[0m\x1b[K`; });
462
+ out += `\x1b[${q.cursor.linha + 1};${q.cursor.coluna + 1}H\x1b[?25h`;
463
+ this._pintando = true;
464
+ this.escreverCru(out);
465
+ this._pintando = false;
466
+ this.desenhado = { linhas: q.linhas, cursorLinha: q.cursor.linha };
467
+ }
468
+
469
+ _rolar(passo) {
470
+ this.rolagem = Math.max(0, this.rolagem + passo);
471
+ this._pintar();
472
+ }
364
473
 
365
474
  /** O agente está trabalhando: a caixa esmaece, e o Enter manda um RECADO
366
475
  * que entra na tarefa em curso (a caixa de entrada de lib/act.js). */
@@ -378,11 +487,13 @@ class Entrada extends EventEmitter {
378
487
  const novo = texto || '';
379
488
  if (novo === (this.spinnerTexto || '')) return;
380
489
  this.spinnerTexto = novo;
490
+ if (this.cheia) return this._agendar();
381
491
  if (this.desenhado && !this._meiaLinha) this._redesenhar();
382
492
  }
383
493
 
384
494
  /** Escreve texto ACIMA da caixa — é por onde todo o resto do programa fala. */
385
495
  escreverAcima(texto) {
496
+ if (this.cheia) { tela.acrescentar(this.conversa, texto); return this._agendar(); }
386
497
  if (!this.desenhado) { this.escreverCru(texto); return; }
387
498
  this._apagar();
388
499
  this.escreverCru(texto);
@@ -441,8 +552,10 @@ class Entrada extends EventEmitter {
441
552
 
442
553
  // ── desenho ──────────────────────────────────────────────────────────
443
554
 
444
- _quadro() {
445
- return montar({
555
+ _quadro() { return montar(this._estado()); }
556
+
557
+ _estado() {
558
+ return ({
446
559
  texto: this.oculto ? '•'.repeat(this.line.length) : this.line,
447
560
  cursor: this.cursor,
448
561
  colunas: process.stdout.columns || 80,
@@ -457,6 +570,7 @@ class Entrada extends EventEmitter {
457
570
  }
458
571
 
459
572
  _desenhar() {
573
+ if (this.cheia) return this._pintar();
460
574
  if (!this.tty || this.suspenso || this.fechado) return;
461
575
  /* Já desenhada (um `prompt()` de quem não sabe que ela está no ar):
462
576
  apaga antes. Sem isso o quadro novo começava na linha do cursor —
@@ -482,6 +596,7 @@ class Entrada extends EventEmitter {
482
596
  }
483
597
 
484
598
  _apagar() {
599
+ if (this.cheia) return;
485
600
  if (!this.desenhado || !this.tty) return;
486
601
  this._pintando = true;
487
602
  // Sobe até a primeira linha do quadro e limpa daí para baixo.
@@ -538,6 +653,19 @@ class Entrada extends EventEmitter {
538
653
  return;
539
654
  }
540
655
 
656
+ /* A roda do mouse chega como "\x1b[<64;x;yM", e o readline a entrega
657
+ picada, tecla por tecla. Daqui até o M (ou m), tudo é do mouse. */
658
+ if (key && key.code === '[<') { this._mouse = ''; return; }
659
+ if (this._mouse != null) {
660
+ if (/^[mM]$/.test(seq)) {
661
+ const botao = parseInt(this._mouse, 10);
662
+ this._mouse = null;
663
+ if (botao === 64) this._rolar(3);
664
+ else if (botao === 65) this._rolar(-3);
665
+ } else this._mouse += seq;
666
+ return;
667
+ }
668
+
541
669
  if (!key) { if (ch) this.write(ch); return; }
542
670
 
543
671
  // Esc Esc e o resto das teclas de fora: quem escuta decide. A caixa
@@ -607,6 +735,8 @@ class Entrada extends EventEmitter {
607
735
  if (this.cursor < this.line.length) { this.cursor++; this._redesenhar(); }
608
736
  return;
609
737
  }
738
+ case 'pageup': return this._rolar(Math.max(3, (process.stdout.rows || 24) - 8));
739
+ case 'pagedown': return this._rolar(-Math.max(3, (process.stdout.rows || 24) - 8));
610
740
  case 'home': this.cursor = 0; this._redesenhar(); return;
611
741
  case 'end': this.cursor = this.line.length; this._redesenhar(); return;
612
742
 
@@ -647,7 +777,9 @@ class Entrada extends EventEmitter {
647
777
  this.cursor = antes.length;
648
778
  return this._mudou();
649
779
  }
650
- case 'l': this._apagar(); this.escreverCru('\x1b[2J\x1b[H'); return this._desenhar();
780
+ case 'l':
781
+ if (this.cheia) { this.conversa = []; this.rolagem = 0; return this._pintar(); }
782
+ this._apagar(); this.escreverCru('\x1b[2J\x1b[H'); return this._desenhar();
651
783
  case 'j': return this._novaLinha();
652
784
  // Ctrl+P abre os comandos, como no OpenCode: o mesmo menu do "/".
653
785
  case 'p':
@@ -711,11 +843,21 @@ class Entrada extends EventEmitter {
711
843
  rola para cima não acha o que perguntou. */
712
844
  this._apagar();
713
845
  if (texto.trim()) this.mandou = true; // a caixa sai do centro e ocupa a largura
846
+ // O título da sessão, na barra da direita: o primeiro pedido.
847
+ if (texto.trim() && !oculto && !this.titulo && !texto.trim().startsWith('/')) {
848
+ const t = visto.replace(/\s+/g, ' ').trim();
849
+ this.titulo = t.length > 70 ? t.slice(0, 69).replace(/\s+\S*$/, '') + '…' : t;
850
+ }
714
851
  if (texto.trim() && !oculto) {
715
852
  // Na tela fica a etiqueta (o que se viu ao mandar); quem vai ao agente
716
853
  // é o texto inteiro. O desenho é o do pedido no OpenCode: o mesmo
717
854
  // painel da caixa, com a barra, e um respiro em cima e embaixo.
718
- this.escreverCru(pedidoNaTela(visto, process.stdout.columns || 80) + '\n');
855
+ if (this.cheia) {
856
+ const W = this._colunasReais || 80;
857
+ const mainW = W >= tela.LATERAL_MIN ? W - tela.LATERAL_W : W;
858
+ this.rolagem = 0;
859
+ tela.acrescentar(this.conversa, (this.conversa.some((l) => /\S/.test(l)) ? '\n' : '') + pedidoNaTela(visto, mainW - 1) + '\n');
860
+ } else this.escreverCru(pedidoNaTela(visto, process.stdout.columns || 80) + '\n');
719
861
  if (this.historico[this.historico.length - 1] !== texto) this.historico.push(texto);
720
862
  }
721
863
  this._desenhar();
package/lib/tela.js ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * tela.js — a TELA CHEIA do PrimoCode, no desenho do OpenCode.
3
+ *
4
+ * O jeito antigo escrevia em linha, como qualquer programa de terminal: o
5
+ * texto subia e a caixa ia junto, no pé do que já tinha saído. O OpenCode,
6
+ * que é a referência pedida (com os prints), é outra coisa: ocupa a tela
7
+ * inteira (a "tela alternativa" do terminal, a mesma do vim), e cada parte
8
+ * tem lugar fixo:
9
+ *
10
+ * ┌──────────────────────────────────────────┬──────────────────┐
11
+ * │ a conversa, rolando │ título da sessão │
12
+ * │ │ Contexto │
13
+ * │ │ 36.216 tokens │
14
+ * │ [menu dos comandos, quando aberto] │ 4% usado │
15
+ * │ ┃ caixa de digitar, presa no pé │ │
16
+ * │ ┃ Auto · low PrimoCode │ ~/pasta:main │
17
+ * │ ~/pasta 36k (4%) ctrl+p… │ ● PrimoCode 10.4 │
18
+ * └──────────────────────────────────────────┴──────────────────┘
19
+ *
20
+ * Sem conversa ainda, a HOME: o logo e a caixa no meio da tela, a pasta no
21
+ * canto de baixo à esquerda e a versão à direita.
22
+ *
23
+ * Tudo aqui é FUNÇÃO PURA: recebe o estado e devolve as linhas da tela. Quem
24
+ * escreve no terminal é lib/entrada.js. Assim o teste confere o quadro
25
+ * inteiro — com barra lateral, sem ela, home, menu — sem terminal nenhum.
26
+ */
27
+
28
+ 'use strict';
29
+
30
+ const { c, visibleWidth, truncate, logo } = require('./ui.js');
31
+
32
+ const RESET = '\x1b[0m';
33
+ const FUNDO_LATERAL = '\x1b[48;5;234m';
34
+ const LATERAL_W = 42; // a largura da barra da direita
35
+ const LATERAL_MIN = 110; // abaixo disso a barra some: a conversa precisa do espaço
36
+
37
+ /* ── A conversa como texto ───────────────────────────────────────────────
38
+ O que o programa escreve (console.log, o fluxo da resposta) chega aqui
39
+ como texto cru, com cor. A tela guarda LINHAS lógicas; quebrar na largura
40
+ é coisa do desenho, porque a largura muda quando a janela muda. */
41
+
42
+ /** Junta um pedaço escrito às linhas que já existem. */
43
+ function acrescentar(linhas, texto) {
44
+ // Tirar o que não é cor: mover cursor, limpar linha, título. Na tela
45
+ // cheia quem decide onde cada coisa vai é o desenho, não quem escreve.
46
+ const t = String(texto == null ? '' : texto)
47
+ .replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, '')
48
+ .replace(/\x1b\[[0-9;?]*[A-Za-ln-z]/g, '');
49
+ if (!linhas.length) linhas.push('');
50
+ const partes = t.split('\n');
51
+ partes.forEach((p, i) => {
52
+ if (i > 0) linhas.push('');
53
+ // "\r" volta ao começo da linha: o que vem depois toma o lugar dela
54
+ // (é como o spinner antigo e as barras de progresso se redesenhavam).
55
+ const k = p.lastIndexOf('\r');
56
+ if (k >= 0) linhas[linhas.length - 1] = p.slice(k + 1);
57
+ else linhas[linhas.length - 1] += p;
58
+ });
59
+ // Guarda as últimas 5.000: uma sessão longa não pode crescer sem fim.
60
+ if (linhas.length > 5000) linhas.splice(0, linhas.length - 5000);
61
+ return linhas;
62
+ }
63
+
64
+ /**
65
+ * Quebra uma linha com cor em pedaços de no máximo `w` colunas: no ESPAÇO
66
+ * quando dá (palavra cortada ao meio é o que faz texto de terminal parecer
67
+ * quebrado), no caractere só quando a palavra sozinha é maior que a linha.
68
+ * Não corta código de cor ao meio e religa a cor ativa em cada pedaço.
69
+ */
70
+ function quebrarComCor(linha, w) {
71
+ const out = [];
72
+ let atual = '', larg = 0, ativo = '';
73
+ // Códigos de cor não têm espaço, então partir no espaço não os corta.
74
+ const palavras = String(linha).split(/(?<= )/);
75
+ const corDe = (t) => {
76
+ for (const m of t.match(/\x1b\[[0-9;]*m/g) || []) ativo = (m === RESET || m === '\x1b[m') ? '' : ativo + m;
77
+ };
78
+ const novaLinha = () => { out.push(atual + (ativo ? RESET : '')); atual = ativo; larg = 0; };
79
+ for (const p of palavras) {
80
+ const pw = visibleWidth(p);
81
+ const semEspaco = visibleWidth(p.replace(/ +$/, ''));
82
+ if (larg + semEspaco <= w) { atual += p; larg += pw; corDe(p); continue; }
83
+ if (semEspaco <= w && larg > 0) { novaLinha(); atual += p; larg = pw; corDe(p); continue; }
84
+ // A palavra não cabe nem sozinha: vai caractere a caractere.
85
+ const re = /\x1b\[[0-9;]*m/y;
86
+ for (let i = 0; i < p.length;) {
87
+ re.lastIndex = i;
88
+ const m = re.exec(p);
89
+ if (m) { atual += m[0]; corDe(m[0]); i += m[0].length; continue; }
90
+ const ch = String.fromCodePoint(p.codePointAt(i));
91
+ const cw = visibleWidth(ch);
92
+ if (larg + cw > w && larg > 0) novaLinha();
93
+ atual += ch; larg += cw; i += ch.length;
94
+ }
95
+ }
96
+ out.push(atual + (ativo ? RESET : ''));
97
+ return out;
98
+ }
99
+
100
+ /** Preenche até `w` colunas (ou corta), com o fundo opcional até o fim. */
101
+ function caber(s, w, fundo) {
102
+ const v = visibleWidth(s);
103
+ if (v > w) s = truncate(s, w);
104
+ const falta = Math.max(0, w - visibleWidth(s));
105
+ if (!fundo) return s + ' '.repeat(falta);
106
+ return fundo + String(s).split(RESET).join(RESET + fundo) + ' '.repeat(falta) + RESET;
107
+ }
108
+
109
+ /* ── A barra da direita ──────────────────────────────────────────────── */
110
+
111
+ /**
112
+ * @param {object} lat { titulo, secoes: [{titulo, linhas}], rodape: [] }
113
+ * @param {number} alto quantas linhas a tela tem
114
+ */
115
+ function lateral(lat, alto) {
116
+ const w = LATERAL_W - 4; // 2 de margem de cada lado
117
+ const corpo = [''];
118
+ for (const l of quebrarComCor(c.bold(c.white(lat.titulo || 'Nova sessão')), w)) corpo.push(l);
119
+ for (const s of (lat.secoes || [])) {
120
+ corpo.push('');
121
+ corpo.push(c.bold(c.white(s.titulo)));
122
+ for (const l of s.linhas) corpo.push(...quebrarComCor(l, w));
123
+ }
124
+ const rodape = (lat.rodape || []).slice();
125
+ rodape.push('');
126
+ const linhas = [];
127
+ for (let i = 0; i < alto; i++) {
128
+ const deBaixo = alto - i - 1;
129
+ let l = '';
130
+ if (deBaixo < rodape.length) l = rodape[rodape.length - 1 - deBaixo];
131
+ else if (i < corpo.length) l = corpo[i];
132
+ linhas.push(caber(' ' + caber(l, w) + ' ', LATERAL_W, FUNDO_LATERAL));
133
+ }
134
+ return linhas;
135
+ }
136
+
137
+ /* ── O quadro inteiro ─────────────────────────────────────────────────── */
138
+
139
+ /**
140
+ * @param {object} e
141
+ * @param {number} e.colunas, e.linhas o tamanho do terminal
142
+ * @param {string[]} e.conversa as linhas lógicas escritas até aqui
143
+ * @param {number} e.rolagem quantas linhas acima do fim (0 = colado embaixo)
144
+ * @param {boolean} e.home ainda não houve pedido: logo no meio
145
+ * @param {(colunas:number, centro:boolean)=>{linhas:string[], cursorLinha:number, cursorColuna:number}} e.caixa
146
+ * @param {object} e.lateral o conteúdo da barra da direita
147
+ * @param {string} e.versao, e.pasta o rodapé da home
148
+ * @returns {{linhas: string[], cursor: {linha:number, coluna:number}, rolagemMax: number}}
149
+ */
150
+ function quadro(e) {
151
+ const W = Math.max(30, e.colunas || 80);
152
+ const H = Math.max(10, e.linhas || 24);
153
+
154
+ if (e.home) {
155
+ const caixa = e.caixa(W, true);
156
+ const lg = W >= 50 ? logo() : [c.bold(c.white('primocode'))];
157
+ /* O painel fica PARADO no lugar dele: o menu, quando abre, cobre o
158
+ que está em cima (o logo), em vez de empurrar tudo para cima. */
159
+ const menu = Math.max(0, caixa.linhas.findIndex((l) => l.includes('┃')) - (/┃/.test(caixa.linhas[0]) ? 0 : 0));
160
+ const bloco = lg.length + 2 + caixa.linhas.length - menu;
161
+ const topo = Math.max(0, Math.floor((H - bloco) / 2) - 1);
162
+ const linhas = [];
163
+ for (let i = 0; i < topo; i++) linhas.push('');
164
+ for (const l of lg) linhas.push(' '.repeat(Math.max(0, Math.floor((W - visibleWidth(l)) / 2))) + l);
165
+ linhas.push('', '');
166
+ const inicioCaixa = Math.max(0, linhas.length - menu);
167
+ linhas.length = inicioCaixa;
168
+ linhas.push(...caixa.linhas);
169
+ while (linhas.length < H - 1) linhas.push('');
170
+ linhas.length = H - 1;
171
+ const esq = ' ' + c.dim(e.pasta || '');
172
+ const dir = c.dim(e.versao || '') + ' ';
173
+ linhas.push(esq + ' '.repeat(Math.max(1, W - visibleWidth(esq) - visibleWidth(dir))) + dir);
174
+ return {
175
+ linhas: linhas.map((l) => caber(l, W)),
176
+ cursor: { linha: inicioCaixa + caixa.cursorLinha, coluna: caixa.cursorColuna },
177
+ rolagemMax: 0,
178
+ };
179
+ }
180
+
181
+ const comLateral = W >= LATERAL_MIN;
182
+ const mainW = comLateral ? W - LATERAL_W : W;
183
+ /* Uma coluna de margem dos dois lados, para a conversa e para a caixa: o
184
+ pedido mandado (um painel) e a caixa de digitar ficam na MESMA borda. */
185
+ const caixa0 = e.caixa(mainW - 1, false);
186
+ const caixa = { ...caixa0, linhas: caixa0.linhas.map((l) => ' ' + l), cursorColuna: caixa0.cursorColuna + 1 };
187
+ const altoConversa = Math.max(1, H - caixa.linhas.length);
188
+
189
+ // A conversa, quebrada na largura da coluna, com 2 colunas de margem.
190
+ const visiveis = [];
191
+ for (const l of (e.conversa || [])) visiveis.push(...quebrarComCor(l, mainW - 2));
192
+ while (visiveis.length && !temTexto(visiveis[visiveis.length - 1])) visiveis.pop();
193
+ const rolagemMax = Math.max(0, visiveis.length - altoConversa);
194
+ const rolagem = Math.max(0, Math.min(e.rolagem | 0, rolagemMax));
195
+ const fim = visiveis.length - rolagem;
196
+ const janela = visiveis.slice(Math.max(0, fim - altoConversa), fim);
197
+ // A conversa encosta EMBAIXO, logo acima da caixa — como num chat.
198
+ const conversa = [];
199
+ for (let i = janela.length; i < altoConversa; i++) conversa.push('');
200
+ for (const l of janela) conversa.push(' ' + l);
201
+ if (rolagem > 0) {
202
+ conversa[0] = ' ' + c.dim(`↑ ${rolagem} linha${rolagem > 1 ? 's' : ''} abaixo · PgDn ou a roda do mouse para voltar`);
203
+ }
204
+
205
+ const esquerda = [...conversa, ...caixa.linhas].map((l) => caber(l, mainW));
206
+ const direita = comLateral ? lateral(e.lateral || {}, H) : null;
207
+ const linhas = esquerda.map((l, i) => (direita ? l + direita[i] : l));
208
+ return {
209
+ linhas,
210
+ cursor: { linha: altoConversa + caixa.cursorLinha, coluna: caixa.cursorColuna },
211
+ rolagemMax,
212
+ };
213
+ }
214
+
215
+ function temTexto(l) { return visibleWidth(l) > 0 && /\S/.test(l.replace(/\x1b\[[0-9;]*m/g, '')); }
216
+
217
+ /** A largura que o texto da conversa tem, numa tela de `colunas`. */
218
+ function larguraDaConversa(colunas) {
219
+ const W = Math.max(30, colunas || 80);
220
+ return (W >= LATERAL_MIN ? W - LATERAL_W : W) - 2;
221
+ }
222
+
223
+ module.exports = { larguraDaConversa, quadro, acrescentar, quebrarComCor, lateral, caber, LATERAL_W, LATERAL_MIN };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.4.0",
3
+ "version": "10.4.1",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/gerativo-identidade.test.js && node test/gerativo-moldura.test.js && node test/gerativo-heroi.test.js && node test/gerativo-ambiente.test.js && node test/hibrido.test.js && node test/batida.test.js && node test/estudio-acervo.test.js && node test/estudio-diretor.test.js && node test/estudio-marca.test.js && node test/estudio-novo-projeto.test.js && node test/estudio-plano.test.js && node test/estudio-slides.test.js && node test/estudio-video.test.js && node test/revisor.test.js && node test/assinatura.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js && node test/zoom.test.js && node test/personagem.test.js && node test/margem.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/gerativo-identidade.test.js && node test/gerativo-moldura.test.js && node test/gerativo-heroi.test.js && node test/gerativo-ambiente.test.js && node test/hibrido.test.js && node test/batida.test.js && node test/estudio-acervo.test.js && node test/estudio-diretor.test.js && node test/estudio-marca.test.js && node test/estudio-novo-projeto.test.js && node test/estudio-plano.test.js && node test/estudio-slides.test.js && node test/estudio-video.test.js && node test/revisor.test.js && node test/assinatura.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js && node test/zoom.test.js && node test/personagem.test.js && node test/tela.test.js && node test/margem.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"