primocode 10.3.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/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
 
@@ -47,7 +48,26 @@ const { c, visibleWidth, truncate } = require('./ui.js');
47
48
  const RE_ETIQUETA = /\[Colado #\d+ · [^\]]+\]/g;
48
49
  function pintarEtiquetas(s) { return String(s).replace(RE_ETIQUETA, (m) => c.brand(m) + '\x1b[38;5;254m'); }
49
50
 
50
- const BORDA = { ne: '╭', no: '╮', se: '╰', so: '╯', h: '─', v: '│' };
51
+ /* As cores do painel, no cubo de 256 cores. O fundo é um degrau acima do
52
+ preto do terminal (235); a barra é o azul do OpenCode; o escolhido do menu,
53
+ o pêssego dele com texto escuro. */
54
+ const COR = {
55
+ fundo: '\x1b[48;5;235m',
56
+ barra: '\x1b[38;5;75m',
57
+ warn: '\x1b[38;5;214m',
58
+ selFundo: '\x1b[48;5;216m',
59
+ selTexto: '\x1b[38;5;234m',
60
+ reset: '\x1b[0m',
61
+ };
62
+ /* Onde o texto começa: a barra, o espaço, e as duas colunas da marca ("$ "
63
+ no modo bash, vazias no normal). O cursor conta a partir daqui. */
64
+ const PREFIXO = 4;
65
+
66
+ /** Uma linha do painel: fundo até `w` colunas, religado depois de cada reset. */
67
+ function painel(s, w) {
68
+ const corpo = String(s).split(COR.reset).join(COR.reset + COR.fundo);
69
+ return COR.fundo + corpo + ' '.repeat(Math.max(0, w - visibleWidth(s))) + COR.reset;
70
+ }
51
71
 
52
72
  /** Preenche até `w` colunas VISÍVEIS (códigos de cor não ocupam coluna). */
53
73
  function encher(s, w) {
@@ -95,9 +115,13 @@ function quebrar(texto, w) {
95
115
  */
96
116
  function montar(e) {
97
117
  const colunas = Math.max(24, Math.min(e.colunas || 80, 200));
98
- const largura = colunas - 1; // a última coluna fica livre: escrever nela
99
- // faz o terminal quebrar a linha sozinho
100
- const w = Math.max(8, largura - 3); // espaço + '❯ ' — e mais nada à direita
118
+ /* Na ABERTURA (nada mandado ainda) a caixa é estreita e centrada embaixo
119
+ do logo, como a home do OpenCode; depois do primeiro pedido ela ocupa a
120
+ largura toda. `recuo` é a margem da esquerda, que entra em toda linha. */
121
+ const largura = e.centro ? Math.min(colunas - 1, 80) : colunas - 1; // a última coluna
122
+ // fica livre: escrever nela faz o terminal quebrar a linha
123
+ const recuo = e.centro ? Math.max(0, Math.floor((colunas - largura) / 2)) : 0;
124
+ const w = Math.max(8, largura - PREFIXO);
101
125
 
102
126
  const cursor = Math.max(0, Math.min(e.cursor | 0, String(e.texto).length));
103
127
  const linhas = quebrar(e.texto, w);
@@ -112,41 +136,39 @@ function montar(e) {
112
136
  if (cursor >= l.de && cursor <= fim) { cursorLinha = i; cursorColuna = cursor - l.de; }
113
137
  }
114
138
 
115
- /* DUAS RÉGUAS, NÃO UMA CAIXA.
116
- Era uma caixa fechada — cantos e barras laterais. O Claude Code, que é a
117
- referência pedida, usa só a linha de cima e a de baixo, e a diferença
118
- não é gosto: a barra lateral direita obriga a reservar duas colunas em
119
- toda linha e a preencher o resto com espaço, e é ela que faz o texto
120
- colado parecer desalinhado quando o terminal é estreito. Sem as laterais
121
- o campo ganha as colunas de volta e a emenda some. */
122
- /* MODO BASH: a linha que começa com "!" roda no shell, e a caixa INTEIRA
123
- muda de cor para avisar.
124
-
125
- O aviso é o ponto. Um comando de shell faz coisa irreversível, e a
139
+ /* O DESENHO DO OPENCODE, que é a referência pedida: um PAINEL — fundo um
140
+ tom mais claro que o terminal, uma barra grossa na esquerda na cor do
141
+ modo — e não réguas nem caixa de cantos. Dentro dele, o texto; uma
142
+ linha em branco; e embaixo, ainda dentro, o modo e o esforço ("Auto ·
143
+ alto"), que é o que muda o que acontece no Enter. Fora do painel, uma
144
+ linha só: a pasta à esquerda, os atalhos à direita.
145
+
146
+ O fundo vai até a última coluna útil em toda linha do painel. É ele que
147
+ desenha a caixa — sem borda nenhuma — e por isso cada `reset` de cor no
148
+ meio do texto tem de religar o fundo (ver `painel`). */
149
+ /* MODO BASH: a linha que começa com "!" roda no shell, e o painel muda de
150
+ cor para avisar. Um comando de shell faz coisa irreversível, e a
126
151
  diferença entre "pedir ao agente" e "rodar direto" não pode depender de
127
- a pessoa lembrar do que digitou três segundos atrás: ela tem de VER, no
128
- momento em que digita, que aquele Enter vai ao shell. Daí o âmbar — a
129
- mesma cor que o resto do programa usa para "atenção, não é o caminho
130
- comum". */
152
+ a pessoa lembrar do que digitou: ela tem de VER, enquanto digita, que
153
+ aquele Enter vai ao shell. Âmbar é o "atenção" do resto do programa. */
131
154
  const bash = /^\s*!/.test(String(e.texto));
155
+ const st = Array.isArray(e.status) ? { linhas: e.status } : (e.status || {});
156
+ const corBarra = bash ? COR.warn : (st.cor || COR.barra);
157
+ const barra = `${corBarra}┃${COR.reset}`;
158
+ const linhaDoPainel = (conteudo) => painel(barra + conteudo, largura);
132
159
 
133
160
  const out = [];
134
- const regua = (bash ? c.warn : c.dim)(BORDA.h.repeat(largura));
135
- /* O SPINNER MORA AQUI, na linha logo acima da caixa. Ele escrevia direto
136
- no terminal com "\r" a cada 80 ms, sem quebra de linha — e a caixa,
137
- que espera a linha fechar para se redesenhar, ficava apagada enquanto
138
- ele girasse: o que se digitava não aparecia, e a barra de status
139
- sumia ("não consigo digitar enquanto ele trabalha"). */
161
+ /* O SPINNER MORA AQUI, na linha logo acima do painel. Ele escrevia direto
162
+ no terminal com "\r" a cada 80 ms — e a caixa, que espera a linha fechar
163
+ para se redesenhar, ficava apagada enquanto ele girasse. */
140
164
  if (e.spinner) out.push(' ' + truncate(e.spinner, largura - 2));
141
- out.push(regua);
165
+
166
+ out.push(linhaDoPainel('')); // o respiro de cima
142
167
 
143
168
  const vazio = !String(e.texto).length;
144
169
  linhas.forEach((l, i) => {
145
- const marca = i === 0
146
- ? (e.ocupado ? c.dim('❯ ') : bash ? c.warn('$ ') : c.brand('❯ '))
147
- : c.dim('┆ ');
148
- // O "!" some do texto desenhado: quem vê "$ " já sabe que é shell, e
149
- // mostrar os dois é mostrar a mesma informação duas vezes.
170
+ // O "!" some do texto desenhado: quem vê "$ " já sabe que é shell.
171
+ const marca = i === 0 && bash ? c.warn('$ ') : ' ';
150
172
  let conteudo = pintarEtiquetas(c.white(i === 0 && bash ? l.texto.replace(/^\s*!/, '') : l.texto));
151
173
  // O fantasma só existe na última linha e só com o cursor no fim dela:
152
174
  // desenhado no meio do texto, ele empurraria o que vem depois.
@@ -155,56 +177,80 @@ function montar(e) {
155
177
  }
156
178
  if (i === 0 && vazio && e.ocupado) conteudo = c.dim('mande um recado — ele entra na tarefa depois do passo atual');
157
179
  else if (i === 0 && vazio && e.vazio) conteudo = c.dim(e.vazio);
158
- // Sem `encher`: sem barra à direita não há nada para alinhar, e o
159
- // espaço sobrando só apagaria o que estivesse embaixo.
160
- out.push(' ' + marca + conteudo);
180
+ out.push(linhaDoPainel(' ' + marca + truncate(conteudo, w)));
161
181
  });
162
182
 
163
- out.push(regua);
183
+ out.push(linhaDoPainel(''));
184
+ // O modo e o esforço, dentro do painel — "Build · Nemotron" no OpenCode.
185
+ out.push(linhaDoPainel(' ' + truncate(bash
186
+ ? c.warn('roda no shell') + c.dim(`, em ${e.pasta || 'a pasta atual'} · apague o ! para voltar ao normal`)
187
+ : (st.modo || c.dim('PrimoCode')), largura - 4)));
188
+
189
+ // ── a linha de baixo: pasta à esquerda, atalhos à direita ────────────
190
+ if (st.linhas) {
191
+ for (const linha of st.linhas) out.push(' ' + truncate(linha, largura - 2));
192
+ } else {
193
+ const esq = st.esquerda || '';
194
+ const dir = st.direita || '';
195
+ const sobra = largura - 2 - visibleWidth(esq) - visibleWidth(dir);
196
+ out.push(' ' + (sobra >= 2 ? esq + ' '.repeat(sobra) + dir : truncate(esq, largura - 2)));
197
+ }
164
198
 
165
199
  // ── o menu dos comandos ──────────────────────────────────────────────
166
- // Fica ABAIXO da caixa, não dentro: dentro, ele empurraria o campo de
167
- // digitar para cima a cada tecla, e o texto que a pessoa escreve saltaria
168
- // de lugar enquanto ela escreve.
200
+ // Fica ABAIXO de tudo, não em cima do painel: em cima, ele empurraria o
201
+ // campo de digitar para baixo a cada tecla, e o texto que a pessoa
202
+ // escreve saltaria de lugar enquanto ela escreve. O desenho é o do
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 = [];
169
208
  if (e.menu && e.menu.itens.length) {
170
- 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));
171
211
  // A janela acompanha a seleção: descer na lista rola, em vez de o
172
212
  // item escolhido sumir embaixo.
173
213
  let topo = Math.max(0, Math.min(e.menu.sel - Math.floor(cabem / 2),
174
214
  e.menu.itens.length - cabem));
215
+ const nomeW = 16;
175
216
  for (let i = topo; i < topo + cabem; i++) {
176
217
  const [nome, desc] = e.menu.itens[i];
177
- const escolhido = i === e.menu.sel;
178
- const nomeW = 16;
179
- const corpo = (escolhido ? c.brand('❯ ') : ' ')
180
- + (escolhido ? c.bold(c.white(encher(nome, nomeW))) : c.muted(encher(nome, nomeW)))
181
- + c.dim(desc || '');
182
- out.push(' ' + truncate(corpo, largura - 2));
218
+ if (i === e.menu.sel) {
219
+ const corpo = ' ' + encher(nome, nomeW) + (desc || '');
220
+ out.push(`${COR.selFundo}${COR.selTexto}${encher(truncate(corpo, largura), largura)}${COR.reset}`);
221
+ } else {
222
+ out.push(painel(' ' + c.white(encher(nome, nomeW)) + c.dim(desc || ''), largura));
223
+ }
183
224
  }
184
225
  if (e.menu.itens.length > cabem) {
185
- out.push(' ' + c.dim(`… e mais ${e.menu.itens.length - cabem}`));
226
+ out.push(painel(' ' + c.dim(`… e mais ${e.menu.itens.length - cabem}`), largura));
186
227
  }
187
228
  }
188
229
 
189
- /* A linha que explica, só no modo bash. Ela ocupa o lugar do rodapé
190
- normal de propósito: no instante em que a pessoa vai rodar um comando,
191
- o que importa não é lembrar os atalhos, é saber onde ele vai rodar. */
192
- if (bash) {
193
- out.push(' ' + c.warn('roda no shell') + c.dim(`, em ${e.pasta || 'a pasta atual'}`)
194
- + c.dim(' · apague o ! para voltar ao normal'));
195
- }
196
- for (const linha of (e.status || [])) out.push(' ' + linha);
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);
197
233
 
234
+ const margem = ' '.repeat(recuo);
198
235
  return {
199
- linhas: out,
200
- cursorLinha: 1 + cursorLinha, // +1 pela régua de cima
236
+ linhas: recuo ? out.map((l) => margem + l) : out,
237
+ cursorLinha: acima + (e.spinner ? 1 : 0) + 1 + cursorLinha, // +1 pelo respiro de cima
201
238
  /* No modo bash o "!" não é desenhado, então tudo depois dele anda uma
202
239
  coluna para a esquerda — e o cursor tem de andar junto, ou ele
203
240
  aparece um caractere à frente de onde o texto realmente está. */
204
- cursorColuna: 1 + 2 + cursorColuna - (bash && cursorLinha === 0 ? 1 : 0),
241
+ cursorColuna: recuo + PREFIXO + cursorColuna - (bash && cursorLinha === 0 ? 1 : 0),
205
242
  };
206
243
  }
207
244
 
245
+ /** O pedido que foi mandado, como fica no histórico da tela: um painel. */
246
+ function pedidoNaTela(texto, colunas) {
247
+ const largura = Math.max(24, Math.min(colunas || 80, 200)) - 1;
248
+ const barra = `${COR.barra}┃${COR.reset}`;
249
+ const linhas = quebrar(texto, Math.max(8, largura - PREFIXO))
250
+ .map((l) => painel(barra + ' ' + pintarEtiquetas(c.white(l.texto)), largura));
251
+ return [painel(barra, largura), ...linhas, painel(barra, largura)].join('\n');
252
+ }
253
+
208
254
  // ── A caixa viva ─────────────────────────────────────────────────────────
209
255
 
210
256
  class Entrada extends EventEmitter {
@@ -232,6 +278,12 @@ class Entrada extends EventEmitter {
232
278
  this.oculto = false; // a próxima resposta é senha: ver `ocultarProxima`
233
279
  this.tty = !!(process.stdin.isTTY && process.stdout.isTTY);
234
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;
235
287
  }
236
288
 
237
289
  // ── ciclo de vida ────────────────────────────────────────────────────
@@ -254,7 +306,7 @@ class Entrada extends EventEmitter {
254
306
  this._aoTeclar = (ch, key) => this._tecla(ch, key);
255
307
  process.stdin.on('keypress', this._aoTeclar);
256
308
 
257
- this._aoRedimensionar = () => { if (this.desenhado) this._redesenhar(); };
309
+ this._aoRedimensionar = () => { if (this.cheia) this._pintar(); else if (this.desenhado) this._redesenhar(); };
258
310
  process.stdout.on('resize', this._aoRedimensionar);
259
311
 
260
312
  /* Todo `console.log` do programa passa a apagar a caixa antes de
@@ -264,8 +316,20 @@ class Entrada extends EventEmitter {
264
316
  por cima da caixa. */
265
317
  this._stdoutOriginal = process.stdout.write;
266
318
  const eu = this;
319
+ if (this.cheia) this._entrarNaTela();
267
320
  process.stdout.write = function (texto, ...resto) {
268
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
+ }
269
333
  eu._apagar();
270
334
  const r = eu._stdoutOriginal.call(this, texto, ...resto);
271
335
  /* ── O TEXTO QUE CHEGA EM PEDAÇOS ────────────────────────────
@@ -310,6 +374,7 @@ class Entrada extends EventEmitter {
310
374
 
311
375
  parar() {
312
376
  if (!this.tty) { if (this._rlPipe) this._rlPipe.close(); return; }
377
+ if (this.cheia) this._sairDaTela();
313
378
  this._apagar();
314
379
  this.escreverCru('\x1b[?2004l');
315
380
  if (this._stdoutOriginal) process.stdout.write = this._stdoutOriginal;
@@ -323,8 +388,88 @@ class Entrada extends EventEmitter {
323
388
  /* Enquanto outro desenho é dono da tela — o painel do /effort, uma tela
324
389
  do marketplace — a caixa sai da frente e larga o teclado. Sem isso, os
325
390
  dois desenhariam no mesmo lugar e as setas moveriam os dois. */
326
- suspender() { this.suspenso = true; this._apagar(); }
327
- 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
+ }
328
473
 
329
474
  /** O agente está trabalhando: a caixa esmaece, e o Enter manda um RECADO
330
475
  * que entra na tarefa em curso (a caixa de entrada de lib/act.js). */
@@ -342,11 +487,13 @@ class Entrada extends EventEmitter {
342
487
  const novo = texto || '';
343
488
  if (novo === (this.spinnerTexto || '')) return;
344
489
  this.spinnerTexto = novo;
490
+ if (this.cheia) return this._agendar();
345
491
  if (this.desenhado && !this._meiaLinha) this._redesenhar();
346
492
  }
347
493
 
348
494
  /** Escreve texto ACIMA da caixa — é por onde todo o resto do programa fala. */
349
495
  escreverAcima(texto) {
496
+ if (this.cheia) { tela.acrescentar(this.conversa, texto); return this._agendar(); }
350
497
  if (!this.desenhado) { this.escreverCru(texto); return; }
351
498
  this._apagar();
352
499
  this.escreverCru(texto);
@@ -405,8 +552,10 @@ class Entrada extends EventEmitter {
405
552
 
406
553
  // ── desenho ──────────────────────────────────────────────────────────
407
554
 
408
- _quadro() {
409
- return montar({
555
+ _quadro() { return montar(this._estado()); }
556
+
557
+ _estado() {
558
+ return ({
410
559
  texto: this.oculto ? '•'.repeat(this.line.length) : this.line,
411
560
  cursor: this.cursor,
412
561
  colunas: process.stdout.columns || 80,
@@ -416,11 +565,17 @@ class Entrada extends EventEmitter {
416
565
  menu: this.menu,
417
566
  ocupado: this.ocupado,
418
567
  spinner: this.spinnerTexto || '',
568
+ centro: !this.mandou,
419
569
  });
420
570
  }
421
571
 
422
572
  _desenhar() {
573
+ if (this.cheia) return this._pintar();
423
574
  if (!this.tty || this.suspenso || this.fechado) return;
575
+ /* Já desenhada (um `prompt()` de quem não sabe que ela está no ar):
576
+ apaga antes. Sem isso o quadro novo começava na linha do cursor —
577
+ a do texto — e o respiro de cima do painel ficava dobrado. */
578
+ if (this.desenhado && !this._meiaLinha) this._apagar();
424
579
  const q = this._quadro();
425
580
  this._pintando = true;
426
581
  /* Cursor no meio de uma linha (fluxo que ainda não fechou): desce uma
@@ -441,6 +596,7 @@ class Entrada extends EventEmitter {
441
596
  }
442
597
 
443
598
  _apagar() {
599
+ if (this.cheia) return;
444
600
  if (!this.desenhado || !this.tty) return;
445
601
  this._pintando = true;
446
602
  // Sobe até a primeira linha do quadro e limpa daí para baixo.
@@ -497,6 +653,19 @@ class Entrada extends EventEmitter {
497
653
  return;
498
654
  }
499
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
+
500
669
  if (!key) { if (ch) this.write(ch); return; }
501
670
 
502
671
  // Esc Esc e o resto das teclas de fora: quem escuta decide. A caixa
@@ -525,6 +694,9 @@ class Entrada extends EventEmitter {
525
694
 
526
695
  case 'tab': {
527
696
  if (this.menu) return this._escolherDoMenu();
697
+ /* Tab com a caixa vazia troca o modo — o "tab agents" do
698
+ OpenCode. Com texto, continua completando o comando. */
699
+ if (!this.line && !this.ocupado) { this.emit('trocar-modo'); return; }
528
700
  const f = this._fantasma();
529
701
  if (f) return this.write(f);
530
702
  if (this.line.startsWith('/')) { this._filtrarMenu(); this._redesenhar(); }
@@ -563,6 +735,8 @@ class Entrada extends EventEmitter {
563
735
  if (this.cursor < this.line.length) { this.cursor++; this._redesenhar(); }
564
736
  return;
565
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));
566
740
  case 'home': this.cursor = 0; this._redesenhar(); return;
567
741
  case 'end': this.cursor = this.line.length; this._redesenhar(); return;
568
742
 
@@ -603,8 +777,16 @@ class Entrada extends EventEmitter {
603
777
  this.cursor = antes.length;
604
778
  return this._mudou();
605
779
  }
606
- 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();
607
783
  case 'j': return this._novaLinha();
784
+ // Ctrl+P abre os comandos, como no OpenCode: o mesmo menu do "/".
785
+ case 'p':
786
+ if (this.line && !this.line.startsWith('/')) return;
787
+ if (!this.line) { this.line = '/'; this.cursor = 1; }
788
+ this._filtrarMenu();
789
+ return this._redesenhar();
608
790
  default: return;
609
791
  }
610
792
  }
@@ -660,10 +842,22 @@ class Entrada extends EventEmitter {
660
842
  senão o pedido some no instante em que a resposta começa, e quem
661
843
  rola para cima não acha o que perguntou. */
662
844
  this._apagar();
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
+ }
663
851
  if (texto.trim() && !oculto) {
664
- const marca = c.brand('❯ ');
665
- // Na tela fica a etiqueta (o que se viu ao mandar); quem vai ao agente é o texto inteiro.
666
- this.escreverCru(visto.split('\n').map((l, i) => (i ? ' ' : marca) + pintarEtiquetas(c.white(l))).join('\n') + '\n');
852
+ // Na tela fica a etiqueta (o que se viu ao mandar); quem vai ao agente
853
+ // é o texto inteiro. O desenho é o do pedido no OpenCode: o mesmo
854
+ // painel da caixa, com a barra, e um respiro em cima e embaixo.
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');
667
861
  if (this.historico[this.historico.length - 1] !== texto) this.historico.push(texto);
668
862
  }
669
863
  this._desenhar();
@@ -673,4 +867,4 @@ class Entrada extends EventEmitter {
673
867
 
674
868
  function criar(op) { return new Entrada(op).iniciar(); }
675
869
 
676
- module.exports = { criar, Entrada, montar, quebrar, encher, pintarEtiquetas };
870
+ module.exports = { criar, Entrada, montar, quebrar, encher, pintarEtiquetas, painel, pedidoNaTela, PREFIXO, COR };
@@ -320,14 +320,28 @@ function creditos(arquivos = null, pasta = PASTA()) {
320
320
  * Qual ferramenta desta máquina sabe recortar. A decisão fica separada do
321
321
  * processo para poder ser testada sem recortar nada.
322
322
  */
323
- function comoTirarFundo({ temRembg, temBackgroundRemover, temPython, temFfmpeg } = {}) {
323
+ function comoTirarFundo({ temRembg, temBackgroundRemover, temPython, temUv, temFfmpeg } = {}) {
324
324
  if (temRembg) return 'rembg';
325
325
  if (temBackgroundRemover) return 'backgroundremover';
326
326
  if (temPython) return 'python-rembg';
327
+ /* Com o uv (o mesmo do recorte de pessoa), o rembg roda sem instalar nada:
328
+ o uv baixa o pacote num ambiente à parte. Antes, sem `pip install rembg`,
329
+ a foto caía no corte por cor e saía com o fundo inteiro (10/10). */
330
+ if (temUv) return 'uv-rembg';
327
331
  if (temFfmpeg) return 'ffmpeg-chroma'; // só resolve fundo de cor chapada
328
332
  return null;
329
333
  }
330
334
 
335
+ let _uv;
336
+ function acharUv() {
337
+ if (_uv !== undefined) return _uv;
338
+ const os = require('os');
339
+ for (const c of ['uv', path.join(os.homedir(), '.local', 'bin', 'uv'), '/opt/homebrew/bin/uv', '/usr/local/bin/uv']) {
340
+ try { if (spawnSync(c, ['--version'], { stdio: 'ignore', timeout: 5000 }).status === 0) return (_uv = c); } catch { /* próximo */ }
341
+ }
342
+ return (_uv = null);
343
+ }
344
+
331
345
  function existe(cmd) {
332
346
  try { return spawnSync(cmd, ['--help'], { stdio: 'ignore', timeout: 8000 }).status != null; }
333
347
  catch { return false; }
@@ -354,6 +368,7 @@ async function tirarFundo(arquivo, { destino, cor = '0x00FF00', tolerancia = 0.3
354
368
  try { return spawnSync('python3', ['-c', 'import rembg'], { stdio: 'ignore', timeout: 20000 }).status === 0; }
355
369
  catch { return false; }
356
370
  })(),
371
+ temUv: !!acharUv(),
357
372
  temFfmpeg: existe('ffmpeg'),
358
373
  });
359
374
  if (!metodo) {
@@ -381,6 +396,11 @@ async function tirarFundo(arquivo, { destino, cor = '0x00FF00', tolerancia = 0.3
381
396
  deu = await rodar('python3', ['-c',
382
397
  'import sys;from rembg import remove;open(sys.argv[2],"wb").write(remove(open(sys.argv[1],"rb").read()))',
383
398
  arquivo, saida]);
399
+ } else if (metodo === 'uv-rembg') {
400
+ // A primeira vez baixa o modelo (~170 MB, fica em ~/.u2net); depois é segundos por imagem.
401
+ deu = await rodar(acharUv(), ['run', '--quiet', '--python', '3.12', '--with', 'rembg[cpu]', '--with', 'pillow', 'python', '-c',
402
+ 'import sys;from rembg import remove, new_session;open(sys.argv[2],"wb").write(remove(open(sys.argv[1],"rb").read(), session=new_session("isnet-general-use")))',
403
+ arquivo, saida]);
384
404
  } else {
385
405
  deu = await rodar('ffmpeg', ['-y', '-i', arquivo, '-vf',
386
406
  `colorkey=${cor}:${tolerancia}:0.1`, '-c:a', 'copy', saida]);
@@ -25,20 +25,25 @@
25
25
 
26
26
  const variedade = require('./variedade.js');
27
27
 
28
- const ESTILOS = ['hibrido', 'gerativo', 'motion', 'vox', 'marca', 'lancamento', 'impacto', 'cinema', 'composicao', 'fluido'];
28
+ const ESTILOS = ['hibrido', 'gerativo', 'motion', 'vox', 'marca', 'lancamento', 'impacto', 'cinema', 'composicao', 'fluido', 'personagem'];
29
29
  // Nomes que o modelo usa para os mesmos estilos.
30
30
  /* "motion" agora é o GERATIVO ("não pode ter template", 29–30/09); o motion
31
31
  antigo segue pelo nome "classico" (e "limpo", o nome mais antigo dele). */
32
32
  const APELIDOS = { motion: 'gerativo', generativo: 'gerativo', kinetic: 'gerativo', cinetico: 'gerativo', 'cinético': 'gerativo',
33
33
  tipografico: 'gerativo', 'tipográfico': 'gerativo', animacao: 'gerativo', 'animação': 'gerativo',
34
34
  classico: 'motion', 'clássico': 'motion', 'motion-classico': 'motion', motion3d: 'motion',
35
- absurdo: 'hibrido', misto: 'hibrido', mix: 'hibrido', 'híbrido': 'hibrido', limpo: 'motion', explainer: 'vox', editorial: 'vox', brand: 'marca', branding: 'marca', 'brand-reveal': 'marca', minimalista: 'fluido', minimal: 'fluido', clean: 'fluido', apple: 'fluido', launch: 'lancamento', 'lançamento': 'lancamento', produto: 'lancamento' };
35
+ absurdo: 'hibrido', misto: 'hibrido', mix: 'hibrido', 'híbrido': 'hibrido', limpo: 'motion', explainer: 'vox', editorial: 'vox', brand: 'marca', branding: 'marca', 'brand-reveal': 'marca', minimalista: 'fluido', minimal: 'fluido', clean: 'fluido', apple: 'fluido', launch: 'lancamento', 'lançamento': 'lancamento', produto: 'lancamento',
36
+ personagens: 'personagem', boneco: 'personagem', bonecos: 'personagem', conversa: 'personagem', dialogo: 'personagem', 'diálogo': 'personagem', podcast: 'personagem', gravado: 'personagem' };
36
37
 
37
38
  const PEDE_FLUIDO = /\bfluid[oa]\b|minimalista|\bminimal\b|\bclean\b|\bapple\b/i;
38
39
  const PEDE_MOTION = /\bmotion\b|anima[çc][ãa]o|animad[oa]/i;
39
40
  const PEDE_VOX = /\bvox\b|explica|explainer|document[aá]rio|editorial|hist[oó]ria d[aoe]s?\b/i;
40
41
  const PEDE_MARCA = /\bbrand|identidade visual|revela[çc][ãa]o d[ae] marca|\blogo(tipo|marca)?\b|vinheta de marca/i;
41
42
  const PEDE_LANCAMENTO = /lan[çc]amento|\blaunch|\bsaas\b|produto novo|apresenta(ndo|r|ção d[oe]) (o |a )?(app|produto|plataforma|ferramenta)/i;
43
+ /* "Uma animação em 3D do Claude falando com o usuário... como se fosse gravado"
44
+ (10/10): personagem, boneco, avatar, conversa ou podcast em 3D vira o vídeo
45
+ GRAVADO em 3D (personagem.js). O 3D de produto continua no gerativo. */
46
+ const PEDE_PERSONAGEM = /personage(m|ns)|\bbonec[oa]s?\b|\bavatar|mascote falando|\bpodcast\b|entrevista|(conversa|di[aá]logo|falando|explicando)[^.]{0,60}\b3\s?d\b|\b3\s?d\b[^.]{0,60}(conversa|di[aá]logo|falando|explicando|pessoa|gente)/i;
42
47
  const PEDE_IMPACTO = /an[uú]nci|reels?|divulg|promo|lan[çc]a|vend|campanha|convite|inscri/i;
43
48
 
44
49
  const normal = (e) => {
@@ -73,7 +78,9 @@ function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = fa
73
78
  const doRoteiro = normal(roteiro);
74
79
  /* Pedir MOTION (ou animação) é pedir o gerativo — antes do vox, da marca e
75
80
  do lançamento, que são leiaute fixo ("não pode ter template", 30/09). */
76
- if (doRoteiro && !(doRoteiro !== 'gerativo' && doRoteiro !== 'fluido' && PEDE_MOTION.test(pedido))) return doRoteiro;
81
+ // O personagem também é animação: "animação 3D do Claude falando" não pode cair no gerativo.
82
+ if (doRoteiro && !(!['gerativo', 'fluido', 'personagem'].includes(doRoteiro) && PEDE_MOTION.test(pedido))) return doRoteiro;
83
+ if (PEDE_PERSONAGEM.test(pedido)) return 'personagem';
77
84
  // O fluido é um jeito de motion: "motion minimalista" é o fluido, não o gerativo.
78
85
  if (PEDE_FLUIDO.test(pedido)) return 'fluido';
79
86
  if (PEDE_MOTION.test(pedido)) return 'gerativo';
@@ -88,6 +95,6 @@ function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = fa
88
95
  }
89
96
 
90
97
  /** Os estilos que usam os efeitos leves e a trilha do ende.app (os de motion, não os de corte). */
91
- const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento', 'fluido']);
98
+ const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento', 'fluido', 'personagem']);
92
99
 
93
- module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO, RODIZIO_SEM_IMAGEM };
100
+ module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO, RODIZIO_SEM_IMAGEM, PEDE_PERSONAGEM };
@@ -42,6 +42,7 @@
42
42
  const { misturarCor, ehCor } = require('./morfar.js');
43
43
  const { volumesDaTrilha } = require('./ritmo.js');
44
44
  const { falaDe } = require('./composicao.js');
45
+ const { limitesNaCamera, MARGEM } = require('./margem.js');
45
46
 
46
47
  const FPS = 30;
47
48
  const QUADRO = 1 / FPS;
@@ -1255,6 +1256,23 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1255
1256
  } else camera.push({ t: r3(b.ate), x: 0, y: 0, zoom: r3(z1 + 0.03), rot: 0, e: 'camera' });
1256
1257
  });
1257
1258
 
1259
+ /* ── a área segura de cada batida (margem.js) ──────────────────────
1260
+ O bloco parado não pode encostar na borda DA TELA — e a tela vê o
1261
+ mundo pela câmera: com zoom 1,1 e a batida 110 px mais perto da lente,
1262
+ a margem de 8,5% do mundo virava 3% na tela ("pensam" colado na borda,
1263
+ 10/10). Aqui o limite da tela vira limite do mundo, batida a batida; o
1264
+ componente encaixa o bloco MEDIDO dentro dele. O ícone vive mais perto
1265
+ da lente (translateZ 150), então tem o limite dele. */
1266
+ const D = Math.round(Math.max(W, H) * 0.85);
1267
+ batidas.forEach((b) => {
1268
+ const assenta = Math.min(b.de + 0.55, (b.de + b.ate) / 2);
1269
+ const fimCalmo = Math.max(assenta + 0.1, b.ate - 0.32);
1270
+ const op = { W, H, camera, de: assenta, ate: fimCalmo, D };
1271
+ b.seguro = limitesNaCamera({ ...op, z: 110 });
1272
+ b.seguroIcone = limitesNaCamera({ ...op, z: 260 });
1273
+ b.vao = Math.round(Math.min(W, H) * MARGEM.vao);
1274
+ });
1275
+
1258
1276
  // ── o produto em 3D: mora nas batidas que o hospedam ─────────────────
1259
1277
  const produtos = batidas.filter((b) => b.produto3d).map((b) => ({ ...b.produto3d, de: b.de, ate: b.ate, modelo: roteiro.modelo3d,
1260
1278
  cor: roteiro.cor3d || '#1b1c20', corAcento: P.acento, ...(roteiro.variacao3d || {}) }));