primocode 11.1.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.
Files changed (47) hide show
  1. package/README.md +16 -2
  2. package/bin/primocode-rpc.js +100 -0
  3. package/lib/agentes-pessoais.js +120 -0
  4. package/lib/app-server/cli.js +64 -0
  5. package/lib/app-server/enderecos.js +43 -0
  6. package/lib/app-server/index.js +3 -0
  7. package/lib/app-server/pareamento.js +61 -0
  8. package/lib/app-server/protocolo.js +86 -0
  9. package/lib/app-server/servidor.js +805 -0
  10. package/lib/app-server/websocket.js +87 -0
  11. package/lib/avisos.js +65 -0
  12. package/lib/cli/sessao.js +202 -17
  13. package/lib/cli-nova.js +237 -0
  14. package/lib/config/config.js +22 -2
  15. package/lib/diff.js +12 -2
  16. package/lib/motor/aprovacao.js +114 -0
  17. package/lib/motor/eventos.js +77 -0
  18. package/lib/motor/index.js +8 -0
  19. package/lib/motor/tela-terminal.js +64 -0
  20. package/lib/motor/transcricao.js +57 -0
  21. package/lib/motor/turno.js +43 -0
  22. package/lib/motor/voz.js +65 -0
  23. package/lib/piloto.js +39 -0
  24. package/lib/plugin/entregaveis/nucleo.js +2 -2
  25. package/lib/plugin/estudio/criar.js +113 -0
  26. package/lib/plugin/estudio/gosto.js +3 -0
  27. package/lib/plugin/estudio/historico.js +60 -0
  28. package/lib/plugin/estudio/index.js +14 -1
  29. package/lib/plugin/estudio/kits/tela.html +3 -1
  30. package/lib/plugin/estudio/miniatura.js +56 -0
  31. package/lib/plugin/video/auto.js +1 -1
  32. package/lib/plugin/video/catalogo.js +20 -0
  33. package/lib/plugin/video/mutar.js +28 -0
  34. package/lib/plugin/video/ops-fx.js +6 -6
  35. package/lib/plugin/video/ops.js +2 -13
  36. package/lib/plugin/video/pedido.js +1 -1
  37. package/lib/plugin/video/zoom.js +1 -1
  38. package/lib/projetos-meta.js +107 -0
  39. package/lib/qr.js +209 -0
  40. package/lib/session/runner/act.js +127 -68
  41. package/lib/skill/catalogo.js +3 -1
  42. package/lib/tool/tools.js +7 -0
  43. package/lib/tui/entrada.js +143 -18
  44. package/lib/tui/tela.js +312 -49
  45. package/lib/tui/ui.js +78 -4
  46. package/package.json +10 -2
  47. package/voz/primo_voz/transcrever.py +12 -0
package/lib/tui/tela.js CHANGED
@@ -27,12 +27,26 @@
27
27
 
28
28
  'use strict';
29
29
 
30
- const { c, visibleWidth, truncate, logo } = require('./ui.js');
30
+ const { c, visibleWidth, truncate, logo, logoBloco, ampliar } = require('./ui.js');
31
31
 
32
32
  const RESET = '\x1b[0m';
33
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
34
+ /* A barra da direita. No OpenCode ela tem 42 colunas e só aparece de 120 para
35
+ cima. Aqui ela cabe em 100: de 100 a 119 colunas fica estreita (30, como no
36
+ artefato do estilo 1); de 120 para cima é a larga (42); abaixo de 100 some,
37
+ e a conversa fica com a tela inteira. Ctrl+X B liga e desliga, como lá. */
38
+ const LATERAL_W = 42;
39
+ const LATERAL_ESTREITA = 30;
40
+ const LATERAL_MIN = 100;
41
+ const LATERAL_LARGA = 120;
42
+ function larguraLateral(W, oculta) {
43
+ if (oculta) return 0;
44
+ /* Cresce com o terminal: 42 colunas até 159, 48 a partir de 160 e 56 a
45
+ partir de 200 (a conversa fica com o resto, que é sempre a maior parte). */
46
+ if (W >= 200) return 56;
47
+ if (W >= 160) return 48;
48
+ return W >= LATERAL_LARGA ? LATERAL_W : W >= LATERAL_MIN ? LATERAL_ESTREITA : 0;
49
+ }
36
50
  const RECUO = 3; // quanto a resposta entra em relação ao painel do pedido
37
51
 
38
52
  /* ── A conversa como texto ───────────────────────────────────────────────
@@ -57,11 +71,34 @@ function acrescentar(linhas, texto) {
57
71
  if (k >= 0) linhas[linhas.length - 1] = p.slice(k + 1);
58
72
  else linhas[linhas.length - 1] += p;
59
73
  });
74
+ fecharAcao(linhas);
60
75
  // Guarda as últimas 5.000: uma sessão longa não pode crescer sem fim.
61
76
  if (linhas.length > 5000) linhas.splice(0, linhas.length - 5000);
62
77
  return linhas;
63
78
  }
64
79
 
80
+ /* A FERRAMENTA QUE TERMINOU. O motor escreve a ação com "●" quando ela começa
81
+ e o resultado ("⎿ ...") quando ela acaba. Na tela cheia a linha da ação vira
82
+ "✓" (verde) quando o resultado chega, e leva o "+N −M" do resultado, como as
83
+ linhas de ferramenta do OpenCode. Roda a cada texto novo e só mexe numa
84
+ linha que ainda esteja com "●". */
85
+ const SEM_COR = (t) => String(t).replace(/\x1b\[[0-9;]*m/g, '');
86
+ const VERDE = '\x1b[38;5;78m';
87
+ function fecharAcao(linhas) {
88
+ let n = linhas.length;
89
+ while (n > 0 && !/\S/.test(SEM_COR(linhas[n - 1]))) n--; // o texto acabou em "\n": a última linha vem vazia
90
+ if (n < 2 || !/^\s*⎿/.test(SEM_COR(linhas[n - 1]))) return;
91
+ for (let i = n - 2; i >= 0 && i >= n - 40; i--) {
92
+ const t = SEM_COR(linhas[i]);
93
+ if (/^\s*✓/.test(t)) return; // já fechada
94
+ if (!/^\s*●/.test(t)) continue;
95
+ const m = SEM_COR(linhas[n - 1]).match(/\+(\d+)\s+[−-](\d+)/);
96
+ linhas[i] = linhas[i].replace(/●/, VERDE + '✓' + RESET)
97
+ + (m ? ' ' + c.ok('+' + m[1]) + ' ' + c.err('−' + m[2]) : '');
98
+ return;
99
+ }
100
+ }
101
+
65
102
  /**
66
103
  * Quebra uma linha com cor em pedaços de no máximo `w` colunas: no ESPAÇO
67
104
  * quando dá (palavra cortada ao meio é o que faz texto de terminal parecer
@@ -107,24 +144,113 @@ function caber(s, w, fundo) {
107
144
  return fundo + String(s).split(RESET).join(RESET + fundo) + ' '.repeat(falta) + RESET;
108
145
  }
109
146
 
147
+ /* ── Pedaços de linha com cor ────────────────────────────────────────── */
148
+
149
+ /** As colunas visíveis [de, ate) de uma linha com cor, religando a cor que estava ativa. */
150
+ function fatiar(linha, de, ate) {
151
+ let col = 0, ativo = '', out = '', dentro = false;
152
+ for (const m of String(linha).matchAll(/\x1b\[[0-9;]*m|[\s\S]/gu)) {
153
+ const t = m[0];
154
+ if (t.startsWith('\x1b[')) {
155
+ ativo = (t === RESET || t === '\x1b[m') ? '' : ativo + t;
156
+ if (dentro) out += t;
157
+ continue;
158
+ }
159
+ const w = visibleWidth(t);
160
+ if (col >= ate) break;
161
+ if (col >= de && col + w <= ate) { if (!dentro) { dentro = true; out += ativo; } out += t; }
162
+ col += w;
163
+ }
164
+ return out + (dentro && ativo ? RESET : '');
165
+ }
166
+
167
+ /** Põe `texto` (já colorido, `larg` colunas) por cima de `linha`, a partir da coluna `ini`. */
168
+ function sobrepor(linha, ini, texto, larg, W) {
169
+ const antes = caber(fatiar(linha, 0, ini), ini);
170
+ return antes + RESET + texto + RESET + fatiar(linha, ini + larg, W);
171
+ }
172
+
173
+ /* ── A cor do terminal ────────────────────────────────────────────────────
174
+ O desenho usa as 256 cores (funciona em qualquer terminal). Quando o terminal
175
+ diz que tem cor verdadeira, cada cor vira RGB, e a da marca é exatamente a
176
+ #22d3ee. NO_COLOR tira toda a cor (o item escolhido vira vídeo invertido). */
177
+ function modoDeCor(env = process.env) {
178
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return 'none';
179
+ if (env.TERM === 'dumb') return 'none';
180
+ const ct = String(env.COLORTERM || '').toLowerCase();
181
+ if (ct.includes('truecolor') || ct.includes('24bit')) return 'true';
182
+ if (/^(iTerm\.app|vscode|WezTerm|ghostty|Hyper)$/.test(env.TERM_PROGRAM || '') || /direct|truecolor|kitty|ghostty|wezterm/.test(env.TERM || '')) return 'true';
183
+ return '256';
184
+ }
185
+ const NIVEL = [0, 95, 135, 175, 215, 255];
186
+ function rgb256(n) {
187
+ if (n < 16) return [[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0], [0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
188
+ [128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0], [0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]][n];
189
+ if (n >= 232) { const v = 8 + (n - 232) * 10; return [v, v, v]; }
190
+ const k = n - 16;
191
+ return [NIVEL[Math.floor(k / 36)], NIVEL[Math.floor(k / 6) % 6], NIVEL[k % 6]];
192
+ }
193
+ // As cores da marca e dos fundos de diff/permissão, exatas em cor verdadeira.
194
+ const EXATAS = {
195
+ 45: [34, 211, 238], 31: [8, 145, 178], 254: [226, 232, 240], 247: [148, 163, 184], 243: [100, 116, 139],
196
+ 214: [251, 191, 36], 204: [251, 113, 133],
197
+ '48:22': [13, 38, 24], '48:52': [43, 18, 22], '48:58': [42, 34, 12], '48:234': [20, 24, 33], '48:235': [29, 35, 49],
198
+ };
199
+ function adaptarCor(texto, modo) {
200
+ const t = String(texto);
201
+ if (modo === '256' || !modo) return t;
202
+ if (modo === 'none') {
203
+ return t.replace(/\x1b\[48;5;216m/g, '\x1b[7m')
204
+ .replace(/\x1b\[(?:38|48);5;\d+m/g, '').replace(/\x1b\[(?:3[0-7]|4[0-7]|9[0-7]|10[0-7])m/g, '');
205
+ }
206
+ return t.replace(/\x1b\[(38|48);5;(\d+)m/g, (_, tipo, n) => {
207
+ const [r, g, b] = EXATAS[tipo + ':' + n] || (tipo === '38' && EXATAS[n]) || rgb256(+n);
208
+ return `\x1b[${tipo};2;${r};${g};${b}m`;
209
+ });
210
+ }
211
+
212
+ /* ── O painel colorido (pedido, permissão): barra grossa + fundo ─────────── */
213
+ const BARRA = { brand: '\x1b[38;5;45m', warn: '\x1b[38;5;214m', err: '\x1b[38;5;204m' };
214
+ const FUNDO = { brand: '\x1b[48;5;235m', warn: '\x1b[48;5;58m', err: '\x1b[48;5;52m' };
215
+ function painelCor(linhas, tipo, w) {
216
+ const barra = BARRA[tipo] || BARRA.brand, fundo = FUNDO[tipo] || FUNDO.brand;
217
+ const vazia = fundo + barra + '┃' + RESET + fundo + ' '.repeat(Math.max(0, w - 1)) + RESET;
218
+ const corpo = linhas.map((l) => {
219
+ const t = truncate(l, w - 4);
220
+ return fundo + barra + '┃' + RESET + fundo + ' ' + String(t).split(RESET).join(RESET + fundo)
221
+ + ' '.repeat(Math.max(0, w - 3 - visibleWidth(t))) + RESET;
222
+ });
223
+ return [vazia, ...corpo, vazia];
224
+ }
225
+
226
+ /** Barra de progresso "████░░░░" com `w` colunas. */
227
+ function barraDe(pct, w, cor) {
228
+ const cheio = Math.max(0, Math.min(w, Math.round((Math.max(0, Math.min(100, pct)) / 100) * w)));
229
+ return (cor || c.brand)('█'.repeat(cheio)) + c.dim('░'.repeat(w - cheio));
230
+ }
231
+
110
232
  /* ── A barra da direita ──────────────────────────────────────────────── */
111
233
 
112
234
  /**
113
- * @param {object} lat { titulo, secoes: [{titulo, linhas}], rodape: [] }
235
+ * O que a barra mostra, de cima para baixo (a ordem e o espírito da do OpenCode):
236
+ * título da sessão, as seções (Contexto, Plano, Tarefas, Arquivos alterados,
237
+ * MCP/LSP), e no pé o Primo pequeno com o estado, a pasta e a versão.
238
+ * @param {object} lat { titulo, subtitulo, secoes: [{titulo, linhas}], pe: [], rodape: [] }
114
239
  * @param {number} alto quantas linhas a tela tem
240
+ * @param {number} larg a largura da barra (30 ou 42)
115
241
  */
116
- function lateral(lat, alto) {
117
- const w = LATERAL_W - 4; // 2 de margem de cada lado
242
+ function lateral(lat, alto, larg = LATERAL_W) {
243
+ const w = larg - 4; // 2 de margem de cada lado
118
244
  const corpo = [''];
119
- // O topo: o logo (animado enquanto trabalha) e o estado do Primo agora.
120
- if (lat.topo && lat.topo.length) corpo.push(...lat.topo, '');
121
245
  for (const l of quebrarComCor(c.bold(c.white(lat.titulo || 'Nova sessão')), w)) corpo.push(l);
246
+ if (lat.subtitulo) corpo.push(c.dim(truncate(lat.subtitulo, w)));
122
247
  for (const s of (lat.secoes || [])) {
248
+ if (!s.linhas || !s.linhas.length) continue; // seção vazia não aparece
123
249
  corpo.push('');
124
250
  corpo.push(c.bold(c.white(s.titulo)));
125
251
  for (const l of s.linhas) corpo.push(...quebrarComCor(l, w));
126
252
  }
127
- const rodape = (lat.rodape || []).slice();
253
+ const rodape = [...(lat.pe && lat.pe.length ? [...lat.pe, ''] : []), ...(lat.rodape || [])];
128
254
  rodape.push('');
129
255
  const linhas = [];
130
256
  for (let i = 0; i < alto; i++) {
@@ -132,13 +258,100 @@ function lateral(lat, alto) {
132
258
  let l = '';
133
259
  if (deBaixo < rodape.length) l = rodape[rodape.length - 1 - deBaixo];
134
260
  else if (i < corpo.length) l = corpo[i];
135
- linhas.push(caber(' ' + caber(l, w) + ' ', LATERAL_W, FUNDO_LATERAL));
261
+ linhas.push(caber(' ' + caber(l, w) + ' ', larg, FUNDO_LATERAL));
136
262
  }
137
263
  return linhas;
138
264
  }
139
265
 
266
+ /* ── Diálogos e avisos (a paleta ctrl+p, modelo, agente, sessões, tema) ──── */
267
+
268
+ const SEL_FUNDO = '\x1b[48;5;45m\x1b[38;5;16m';
269
+ const DLG_FUNDO = '\x1b[48;5;235m';
270
+
271
+ /**
272
+ * O diálogo no meio da tela, como o `dialog-select` do OpenCode: título e "esc",
273
+ * a linha de busca, e a lista (agrupada por seção) com o item escolhido numa faixa.
274
+ * @param {object} d { titulo, filtro, itens: [{nome, desc, tecla, secao}], sel, vazio }
275
+ * @returns {{linhas: string[], largura: number, cursorLinha: number, cursorColuna: number}}
276
+ */
277
+ function dialogo(d, W, H) {
278
+ const largura = Math.max(28, Math.min(Math.max(64, Math.round(W * 0.4)), 110, W - 4));
279
+ const dentro = largura - 4;
280
+ const pad = (s) => DLG_FUNDO + String(s).split(RESET).join(RESET + DLG_FUNDO) + ' '.repeat(Math.max(0, largura - visibleWidth(s))) + RESET;
281
+ const linha = (s) => pad(' ' + caber(s, dentro) + ' ');
282
+ const itens = d.itens || [];
283
+ const maxLista = Math.max(3, Math.min(Math.max(14, Math.round(H * 0.4)), H - 10));
284
+ // Linhas da lista: cabeçalho de seção quando ela muda, depois o item.
285
+ const lista = [];
286
+ let ultima = null;
287
+ itens.forEach((it, idx) => {
288
+ if (it.secao && it.secao !== ultima) { lista.push({ cab: it.secao }); ultima = it.secao; }
289
+ lista.push({ it, idx });
290
+ });
291
+ const posSel = Math.max(0, lista.findIndex((x) => x.idx === d.sel));
292
+ const topo = Math.max(0, Math.min(posSel - Math.floor(maxLista / 2), lista.length - maxLista));
293
+ const vis = lista.slice(topo, topo + maxLista);
294
+
295
+ const out = [];
296
+ out.push(pad(''));
297
+ const esc = c.dim('esc');
298
+ out.push(pad(' ' + c.bold(c.white(d.titulo || '')) + ' '.repeat(Math.max(1, dentro - visibleWidth(d.titulo || '') - 3)) + esc + ' '));
299
+ out.push(pad(''));
300
+ const buscar = (d.filtro ? c.white(d.filtro) : c.dim('Buscar'));
301
+ out.push(linha(buscar));
302
+ const cursorLinha = out.length - 1;
303
+ const cursorColuna = 2 + (d.filtro ? visibleWidth(d.filtro) : 0);
304
+ out.push(pad(''));
305
+ if (!itens.length) out.push(linha(c.dim(d.vazio || 'Nada achado')));
306
+ for (const x of vis) {
307
+ if (x.cab) { out.push(linha(c.bold(c.brand(x.cab)))); continue; }
308
+ const it = x.it;
309
+ const tecla = it.tecla || '';
310
+ const nomeW = Math.max(8, dentro - visibleWidth(tecla) - 2);
311
+ const esq = it.nome + (it.desc ? ' ' + it.desc : '');
312
+ if (x.idx === d.sel) {
313
+ const txt = ' ' + caber(truncate(it.nome + (it.desc ? ' ' + stripCor(it.desc) : ''), nomeW - 1), nomeW) + tecla + ' ';
314
+ out.push(pad(' ' + SEL_FUNDO + caber(txt, dentro) + RESET + DLG_FUNDO + ' '));
315
+ } else {
316
+ const corpo = c.white(it.nome) + (it.desc ? ' ' + c.dim(stripCor(it.desc)) : '');
317
+ const tx = truncate(corpo, nomeW);
318
+ out.push(linha(tx + ' '.repeat(Math.max(1, dentro - visibleWidth(tx) - visibleWidth(tecla))) + c.dim(tecla)));
319
+ }
320
+ void esq;
321
+ }
322
+ if (lista.length > maxLista) out.push(linha(c.dim(`↑↓ para ver mais (${itens.length} itens)`)));
323
+ out.push(pad(''));
324
+ return { linhas: out, largura, cursorLinha, cursorColuna };
325
+ }
326
+ const stripCor = SEM_COR;
327
+
328
+ /** O aviso que aparece uns segundos no canto de cima (toast do OpenCode). */
329
+ function toast(t, W) {
330
+ const tipo = t.tipo === 'erro' ? 'err' : t.tipo === 'aviso' ? 'warn' : 'brand';
331
+ const larg = Math.max(20, Math.min(Math.max(40, Math.round(W * 0.25)), 64, W - 4));
332
+ const texto = quebrarComCor(String(t.texto || ''), larg - 4).slice(0, 4);
333
+ const linhas = painelCor(texto, tipo, larg);
334
+ return { linhas, largura: larg };
335
+ }
336
+
140
337
  /* ── O quadro inteiro ─────────────────────────────────────────────────── */
141
338
 
339
+ /** Os comandos que a home lista, com o atalho de cada um (o líder é o ctrl+x, como no OpenCode). */
340
+ const COMANDOS_HOME = [
341
+ ['/clear', 'nova conversa', 'ctrl+x n'],
342
+ ['/projects', 'abrir um projeto', 'ctrl+x p'],
343
+ ['/estudio', 'criar vídeo, slides ou motion', 'ctrl+x s'],
344
+ ['/agentes', 'ver quem está disponível', 'ctrl+x a'],
345
+ ['/mode', 'trocar o modo ou o esforço', 'ctrl+x m'],
346
+ ];
347
+
348
+ function linhaDeComando(cmd, desc, tecla, largura) {
349
+ const cw = 12;
350
+ const meio = largura - cw - visibleWidth(tecla) - 2;
351
+ return c.brand(cmd.padEnd(cw)) + c.white(truncate(desc, Math.max(4, meio))).padEnd(0)
352
+ + ' '.repeat(Math.max(1, meio - visibleWidth(truncate(desc, Math.max(4, meio))))) + c.dim(tecla);
353
+ }
354
+
142
355
  /**
143
356
  * @param {object} e
144
357
  * @param {number} e.colunas, e.linhas o tamanho do terminal
@@ -147,48 +360,96 @@ function lateral(lat, alto) {
147
360
  * @param {boolean} e.home ainda não houve pedido: logo no meio
148
361
  * @param {(colunas:number, centro:boolean)=>{linhas:string[], cursorLinha:number, cursorColuna:number}} e.caixa
149
362
  * @param {object} e.lateral o conteúdo da barra da direita
150
- * @param {string} e.versao, e.pasta o rodapé da home
363
+ * @param {boolean} e.lateralOculta a pessoa desligou a barra (ctrl+x b)
364
+ * @param {string} e.versao, e.pasta, e.plano o rodapé da home
365
+ * @param {object} e.dialogo, e.toast o diálogo aberto e o aviso do canto
151
366
  * @returns {{linhas: string[], cursor: {linha:number, coluna:number}, rolagemMax: number}}
152
367
  */
153
368
  function quadro(e) {
154
369
  const W = Math.max(30, e.colunas || 80);
155
370
  const H = Math.max(10, e.linhas || 24);
371
+ const q = e.home ? quadroHome(e, W, H) : quadroSessao(e, W, H);
372
+ return aplicarSobreposicoes(q, e, W, H);
373
+ }
156
374
 
157
- if (e.home) {
158
- const caixa = e.caixa(W, true);
159
- const lg = W >= 60 && e.logo ? e.logo : W >= 50 ? logo() : [c.bold(c.white('primocode'))];
160
- /* O painel fica PARADO no lugar dele: o menu, quando abre, cobre o
161
- que está em cima (o logo), em vez de empurrar tudo para cima. */
162
- const menu = Math.max(0, caixa.linhas.findIndex((l) => l.includes('┃')) - (/┃/.test(caixa.linhas[0]) ? 0 : 0));
163
- const bloco = lg.length + 2 + caixa.linhas.length - menu;
164
- const topo = Math.max(0, Math.floor((H - bloco) / 2) - 1);
165
- const linhas = [];
166
- for (let i = 0; i < topo; i++) linhas.push('');
167
- for (const l of lg) linhas.push(' '.repeat(Math.max(0, Math.floor((W - visibleWidth(l)) / 2))) + l);
168
- linhas.push('', '');
169
- const inicioCaixa = Math.max(0, linhas.length - menu);
170
- linhas.length = inicioCaixa;
171
- linhas.push(...caixa.linhas);
172
- while (linhas.length < H - 1) linhas.push('');
173
- linhas.length = H - 1;
174
- const esq = ' ' + c.dim(e.pasta || '');
175
- // Folga à direita: terminais como o Orca põem um botão flutuante no
176
- // canto de baixo, e ele cobria a versão ("10.").
177
- const dir = c.dim(e.versao || '') + ' ';
178
- linhas.push(esq + ' '.repeat(Math.max(1, W - visibleWidth(esq) - visibleWidth(dir))) + dir);
179
- return {
180
- linhas: linhas.map((l) => caber(l, W)),
181
- cursor: { linha: inicioCaixa + caixa.cursorLinha, coluna: caixa.cursorColuna },
182
- rolagemMax: 0,
183
- };
375
+ function aplicarSobreposicoes(q, e, W, H) {
376
+ let linhas = q.linhas.slice();
377
+ if (e.dialogo) {
378
+ // O fundo escurece (o "scrim" do OpenCode): sem cor, num cinza baixo, para o diálogo saltar.
379
+ linhas = linhas.map((l) => '\x1b[38;5;238m' + SEM_COR(l) + RESET);
380
+ const d = dialogo(e.dialogo, W, H);
381
+ const topo = Math.max(1, Math.min(Math.floor(H / 5), H - d.linhas.length - 1));
382
+ const ini = Math.floor((W - d.largura) / 2);
383
+ d.linhas.forEach((l, i) => { if (topo + i < H) linhas[topo + i] = sobrepor(linhas[topo + i], ini, l, d.largura, W); });
384
+ q = { ...q, cursor: { linha: topo + d.cursorLinha, coluna: ini + d.cursorColuna } };
184
385
  }
386
+ if (e.toast && e.toast.texto) {
387
+ const t = toast(e.toast, W);
388
+ const ini = W - t.largura - 2;
389
+ t.linhas.forEach((l, i) => { if (1 + i < H) linhas[1 + i] = sobrepor(linhas[1 + i], ini, l, t.largura, W); });
390
+ }
391
+ return { ...q, linhas };
392
+ }
393
+
394
+ function quadroHome(e, W, H) {
395
+ const caixa = e.caixa(W, true);
396
+ /* Terminal grande (160x45 para cima): o Primo e o logo em 2x, pixel a pixel. */
397
+ const grande = W >= 160 && H >= 45;
398
+ const dobrar = (a) => (grande ? ampliar(a) : a);
399
+ const art = dobrar(e.logo && e.logo.length ? e.logo : (W >= 50 ? logoBloco() : [c.bold(c.white('primocode'))]));
400
+ /* O painel fica PARADO no lugar dele: o menu, quando abre, cobre o que está
401
+ em cima (o logo), em vez de empurrar tudo para cima. */
402
+ const menu = Math.max(0, caixa.linhas.findIndex((l) => l.includes('┃')));
403
+ const nucleo = caixa.linhas.slice(menu);
404
+ const dica = ' '.repeat(0) + c.white('tab') + ' ' + c.dim('troca o modo') + ' ' + c.white('ctrl+p') + ' ' + c.dim('comandos');
405
+ const cmds = e.comandosHome || COMANDOS_HOME;
406
+ // A lista acompanha a caixa (que cresce com o terminal): mesma borda, só que mais estreita.
407
+ const larguraBox = Math.max(0, ...caixa.linhas.filter((l) => l.includes('┃')).map((l) => visibleWidth(l) - (SEM_COR(l).match(/^ */)[0].length)));
408
+ const larguraCmd = Math.min(W >= 160 ? Math.max(52, larguraBox - 8) : 52, W - 6);
409
+ // Quanto cabe: primeiro some a lista de comandos, depois o Primo.
410
+ const montar = (comLista, comPrimo) => {
411
+ const arte = comPrimo ? art : dobrar(e.logoSimples || logoBloco());
412
+ const topo = [...arte, '', c.dim('v' + String(e.versao || '').replace(/^v/, '')), ''];
413
+ const base = [dica];
414
+ if (comLista) base.push('', ...cmds.map(([a, b, k]) => ' '.repeat(Math.floor((W - larguraCmd) / 2)) + linhaDeComando(a, b, k, larguraCmd)));
415
+ return { topo, base };
416
+ };
417
+ let m = montar(true, true);
418
+ const altoDe = (x) => x.topo.length + nucleo.length + 1 + x.base.length;
419
+ if (altoDe(m) > H - 2) m = montar(false, true);
420
+ if (altoDe(m) > H - 2) m = montar(false, false);
421
+ const total = altoDe(m);
422
+ const inicio = Math.max(0, Math.floor((H - 1 - total) / 2));
423
+ const linhas = new Array(H - 1).fill('');
424
+ const centrar = (l) => ' '.repeat(Math.max(0, Math.floor((W - visibleWidth(l)) / 2))) + l;
425
+ m.topo.forEach((l, i) => { if (inicio + i < H - 1) linhas[inicio + i] = centrar(l); });
426
+ const linhaCaixa = inicio + m.topo.length;
427
+ // o menu aberto sobe por cima do logo (as linhas antes do painel)
428
+ caixa.linhas.slice(0, menu).forEach((l, i) => { const k = linhaCaixa - menu + i; if (k >= 0 && k < H - 1) linhas[k] = l; });
429
+ nucleo.forEach((l, i) => { if (linhaCaixa + i < H - 1) linhas[linhaCaixa + i] = l; });
430
+ const larguraBase = linhaCaixa + nucleo.length + 1;
431
+ m.base.forEach((l, i) => {
432
+ if (larguraBase + i < H - 1) linhas[larguraBase + i] = l.startsWith(' ') ? l : centrar(l);
433
+ });
434
+ const esq = ' ' + c.dim(e.pasta || '');
435
+ // Folga à direita: terminais como o Orca põem um botão flutuante no canto de baixo.
436
+ const dir = c.dim((e.plano ? e.plano + ' · ' : '') + (e.versao || '')) + ' ';
437
+ linhas.push(esq + ' '.repeat(Math.max(1, W - visibleWidth(esq) - visibleWidth(dir))) + dir);
438
+ return {
439
+ linhas: linhas.map((l) => caber(l, W)),
440
+ cursor: { linha: linhaCaixa + caixa.cursorLinha - menu, coluna: caixa.cursorColuna },
441
+ rolagemMax: 0,
442
+ };
443
+ }
185
444
 
445
+ function quadroSessao(e, W, H) {
186
446
  /* O PAINEL de um comando que desenha a própria tela (o slider do
187
447
  /effort, o marketplace): fica no pé da conversa, logo acima da caixa,
188
448
  por cima do que estiver ali — a tela cheia nunca sai do ar. */
189
449
  const painel = (e.painel ? String(e.painel).split('\n') : []);
190
- const comLateral = W >= LATERAL_MIN;
191
- const mainW = comLateral ? W - LATERAL_W : W;
450
+ const lw = larguraLateral(W, e.lateralOculta);
451
+ const comLateral = lw > 0;
452
+ const mainW = comLateral ? W - lw : W;
192
453
  /* Uma coluna de margem dos dois lados, para a conversa e para a caixa: o
193
454
  pedido mandado (um painel) e a caixa de digitar ficam na MESMA borda. */
194
455
  const caixa0 = e.caixa(mainW - 1, false);
@@ -197,13 +458,12 @@ function quadro(e) {
197
458
 
198
459
  /* A conversa, quebrada na largura da coluna. Os PAINÉIS (o pedido que se
199
460
  mandou) ficam na borda da caixa; o resto — a resposta, as ações, o
200
- fecho "▣" — entra RECUADO, como no OpenCode. Sem o recuo, tudo saía
201
- colado na borda e um bloco não se separava do outro. */
461
+ fecho "▣" — entra RECUADO, como no OpenCode. */
202
462
  const visiveis = [];
203
463
  for (const l of (e.conversa || [])) {
204
- const painel = /^(\x1b\[[0-9;]*m)*┃/.test(l);
205
- const margem = painel ? ' ' : ' '.repeat(RECUO + 1);
206
- for (const p of quebrarComCor(l, painel ? mainW - 2 : mainW - 2 - RECUO)) visiveis.push(margem + p);
464
+ const ehPainel = /^(\x1b\[[0-9;]*m)*┃/.test(l);
465
+ const margem = ehPainel ? ' ' : ' '.repeat(RECUO + 1);
466
+ for (const p of quebrarComCor(l, ehPainel ? mainW - 2 : mainW - 2 - RECUO)) visiveis.push(margem + p);
207
467
  }
208
468
  while (visiveis.length && !temTexto(visiveis[visiveis.length - 1])) visiveis.pop();
209
469
  if (visiveis.length) visiveis.push(''); // um respiro entre a conversa e a caixa
@@ -225,7 +485,7 @@ function quadro(e) {
225
485
  }
226
486
 
227
487
  const esquerda = [...conversa, ...caixa.linhas].map((l) => caber(l, mainW));
228
- const direita = comLateral ? lateral(e.lateral || {}, H) : null;
488
+ const direita = comLateral ? lateral(e.lateral || {}, H, lw) : null;
229
489
  const linhas = esquerda.map((l, i) => (direita ? l + direita[i] : l));
230
490
  return {
231
491
  linhas,
@@ -237,9 +497,12 @@ function quadro(e) {
237
497
  function temTexto(l) { return visibleWidth(l) > 0 && /\S/.test(l.replace(/\x1b\[[0-9;]*m/g, '')); }
238
498
 
239
499
  /** A largura que o texto da conversa tem, numa tela de `colunas`. */
240
- function larguraDaConversa(colunas) {
500
+ function larguraDaConversa(colunas, oculta) {
241
501
  const W = Math.max(30, colunas || 80);
242
- return (W >= LATERAL_MIN ? W - LATERAL_W : W) - 2 - RECUO;
502
+ return (W - larguraLateral(W, oculta)) - 2 - RECUO;
243
503
  }
244
504
 
245
- module.exports = { RECUO, larguraDaConversa, quadro, acrescentar, quebrarComCor, lateral, caber, LATERAL_W, LATERAL_MIN };
505
+ module.exports = {
506
+ RECUO, larguraDaConversa, quadro, acrescentar, quebrarComCor, lateral, caber, LATERAL_W, LATERAL_ESTREITA, LATERAL_MIN, LATERAL_LARGA,
507
+ larguraLateral, dialogo, toast, painelCor, barraDe, fatiar, sobrepor, modoDeCor, adaptarCor, COMANDOS_HOME, fecharAcao,
508
+ };
package/lib/tui/ui.js CHANGED
@@ -36,6 +36,20 @@ const PALETTE = {
36
36
  reset: '\x1b[0m',
37
37
  };
38
38
 
39
+ /* NO_COLOR (https://no-color.org): sem nenhuma cor. Negrito e reset ficam. */
40
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') {
41
+ for (const k of Object.keys(PALETTE)) if (k !== 'bold' && k !== 'reset') PALETTE[k] = '';
42
+ }
43
+
44
+ /* O que a barra da direita da tela cheia mostra e que vem do trabalho do agente:
45
+ as tarefas (write_todos) e os arquivos alterados, com +N −M. O act.js preenche. */
46
+ const estadoSessao = { todos: [], arquivos: new Map() };
47
+ function registrarArquivo(caminho, mais, menos) {
48
+ if (!caminho) return;
49
+ const a = estadoSessao.arquivos.get(caminho) || { mais: 0, menos: 0 };
50
+ estadoSessao.arquivos.set(caminho, { mais: a.mais + mais, menos: a.menos + menos });
51
+ }
52
+
39
53
  const c = {
40
54
  ...PALETTE,
41
55
  brand: (s) => `${PALETTE.brand}${s}${PALETTE.reset}`,
@@ -351,6 +365,66 @@ function letreiro(palavra) {
351
365
  return out;
352
366
  }
353
367
 
368
+
369
+ /* ── O logo em letras de bloco de 3 linhas (a home do estilo 2) ─────────
370
+ "primo" no tom do texto e "code" na cor da marca, como no artefato
371
+ .refazer/cli-alvo.html. Desenho próprio, não de fonte nenhuma. */
372
+ const BLOCOS = {
373
+ p: ['█▀▀█', '█▄▄█', '█ '], r: ['█▀▀▀', '█ ', '▀ '], i: ['▀█▀', ' █ ', '▀▀▀'],
374
+ m: ['█▀▄▀█', '█ ▀ █', '▀ ▀'], o: ['█▀▀█', '█ █', '▀▀▀▀'], c: ['█▀▀▀', '█ ', '▀▀▀▀'],
375
+ d: ['█▀▀▄', '█ █', '▀▀▀ '], e: ['█▀▀▀', '█▀▀ ', '▀▀▀▀'],
376
+ };
377
+ const palavraBloco = (w) => [0, 1, 2].map((j) => [...w].map((ch) => BLOCOS[ch][j]).join(' '));
378
+ function logoBloco() {
379
+ const a = palavraBloco('primo'), b = palavraBloco('code');
380
+ return a.map((l, i) => `\x1b[38;5;254m${l}${PALETTE.reset} ${PALETTE.brand}${b[i]}${PALETTE.reset}`);
381
+ }
382
+
383
+ /* Escala 2x, pixel a pixel: cada célula vira 2x2 células e cada quarto de
384
+ célula (os 4 pixels que ▀▄▌▐▛▜▙▟ desenham) vira UMA célula cheia, na cor
385
+ da célula de origem. É o mesmo desenho, maior, sem redesenhar nada. */
386
+ const QUARTOS_BITS = { '▀': 0b1100, '▄': 0b0011, '█': 0b1111, '▌': 0b1010, '▐': 0b0101, '▘': 0b1000, '▝': 0b0100, '▖': 0b0010, '▗': 0b0001,
387
+ '▛': 0b1110, '▜': 0b1101, '▙': 0b1011, '▟': 0b0111, '▚': 0b1001, '▞': 0b0110 };
388
+ function ampliar(linhas) {
389
+ const out = [];
390
+ for (const linha of linhas) {
391
+ let cor = '';
392
+ const cel = [];
393
+ for (const m of String(linha).matchAll(/(\x1b\[[0-9;]*m)|([\s\S])/gu)) {
394
+ if (m[1]) { cor = m[1] === '\x1b[0m' ? '' : m[1]; continue; }
395
+ cel.push({ ch: m[2], cor });
396
+ }
397
+ const topo = [], base = [];
398
+ for (const { ch, cor: k } of cel) {
399
+ const q = ch === ' ' ? 0 : (QUARTOS_BITS[ch] !== undefined ? QUARTOS_BITS[ch] : 0b1111);
400
+ const px = (b) => (q & b ? k + '█' + (k ? '\x1b[0m' : '') : ' ');
401
+ topo.push(px(8) + px(4)); base.push(px(2) + px(1));
402
+ }
403
+ out.push(topo.join(''), base.join(''));
404
+ }
405
+ return out;
406
+ }
407
+
408
+ /* TEMAS: o escuro é o padrão, com a cor da marca (#22d3ee). Os outros só
409
+ trocam a cor de destaque: `c.brand` lê o PALETTE na hora de pintar. */
410
+ const TEMAS = {
411
+ marca: { nome: 'Marca (ciano)', brand: 45, brandDim: 31 },
412
+ ambar: { nome: 'Âmbar', brand: 214, brandDim: 172 },
413
+ violeta: { nome: 'Violeta', brand: 141, brandDim: 98 },
414
+ verde: { nome: 'Verde', brand: 78, brandDim: 35 },
415
+ rosa: { nome: 'Rosa', brand: 211, brandDim: 168 },
416
+ };
417
+ let temaAtual = 'marca';
418
+ function definirTema(id) {
419
+ const t = TEMAS[id];
420
+ if (!t) return false;
421
+ PALETTE.brand = `\x1b[38;5;${t.brand}m`; PALETTE.brandDim = `\x1b[38;5;${t.brandDim}m`;
422
+ PALETTE.ok = PALETTE.brand; PALETTE.cyan = PALETTE.brand;
423
+ temaAtual = id;
424
+ return true;
425
+ }
426
+ const temaDoMomento = () => temaAtual;
427
+
354
428
  /** O logo: "primo" apagado e "code" aceso, lado a lado. */
355
429
  function logo() {
356
430
  const a = letreiro('primo'), b = letreiro('code');
@@ -363,7 +437,7 @@ const LOGO_W = visibleWidth(logo()[0]);
363
437
  home ele cresce 2×: cada quarto de bloco (▘ ▝ ▖ ▗) vira um bloco cheio, e
364
438
  cada bloco cheio vira 2×2. Ficam 6 linhas × 18 colunas, com a cor de cada
365
439
  pedaço preservada — o mesmo desenho, não um desenho novo. */
366
- const QUARTOS = {
440
+ const QUARTOS_AMP = {
367
441
  ' ': [0, 0, 0, 0], '█': [1, 1, 1, 1], '▀': [1, 1, 0, 0], '▄': [0, 0, 1, 1],
368
442
  '▌': [1, 0, 1, 0], '▐': [0, 1, 0, 1], '▘': [1, 0, 0, 0], '▝': [0, 1, 0, 0],
369
443
  '▖': [0, 0, 1, 0], '▗': [0, 0, 0, 1], '▛': [1, 1, 1, 0], '▜': [1, 1, 0, 1],
@@ -381,7 +455,7 @@ function mascoteGrande(style) {
381
455
  }
382
456
  const cima = [], baixo = [];
383
457
  for (const [ch, cr] of cels) {
384
- const q = QUARTOS[ch] || (ch === ' ' ? QUARTOS[' '] : QUARTOS['█']);
458
+ const q = QUARTOS_AMP[ch] || (ch === ' ' ? QUARTOS_AMP[' '] : QUARTOS_AMP['█']);
385
459
  const bloco = (b) => (b ? `${cr}█${PALETTE.reset}` : ' ');
386
460
  cima.push(bloco(q[0]) + bloco(q[1]));
387
461
  baixo.push(bloco(q[2]) + bloco(q[3]));
@@ -566,7 +640,7 @@ const ROTULOS = {
566
640
  estudio_site:'Estúdio·marca',
567
641
  desktop_windows:'Desktop·janelas', desktop_focus:'Desktop·foco',
568
642
  desktop_elements:'Desktop·elementos', desktop_click_element:'Desktop·click',
569
- estudio_midia:'Estúdio·mídia', estudio_ae:'Estúdio·After Effects', estudio_motion:'Estúdio·Motion', estudio_editar:'Estúdio·Editar', construir_app:'Construir',
643
+ estudio_midia:'Estúdio·mídia', estudio_ae:'Estúdio·After Effects', estudio_motion:'Estúdio·Motion', estudio_editar:'Estúdio·Editar', estudio_criar:'Estúdio·Criar', construir_app:'Construir',
570
644
  remember_general:'Lembrar', lembrar:'Memória',
571
645
  mcp_procurar:'MCP·procurar', mcp_conectar:'MCP·conectar', mcp_usar:'MCP',
572
646
  };
@@ -760,4 +834,4 @@ function friendlyError(raw) {
760
834
 
761
835
  module.exports = {
762
836
  extraDoSpinner,
763
- titulo, agora, c, logo, logoComMascote, mascoteGrande, logoAnimado, letreiro, convite, EXEMPLOS, mascot, MASCOTES, resolverMascote, mascoteInfo, galeriaMascotes, reacao, welcomeBox, Spinner, PENSANDO, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
837
+ estadoSessao, registrarArquivo, logoBloco, ampliar, TEMAS, definirTema, temaDoMomento, titulo, agora, c, logo, logoComMascote, mascoteGrande, logoAnimado, letreiro, convite, EXEMPLOS, mascot, MASCOTES, resolverMascote, mascoteInfo, galeriaMascotes, reacao, welcomeBox, Spinner, PENSANDO, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "11.1.0",
3
+ "version": "11.3.0",
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": {
@@ -10,7 +10,11 @@
10
10
  "start": "node bin/primocode.js",
11
11
  "postinstall": "node lib/preparar.js || true",
12
12
  "test:server": "cd server && npm test",
13
- "test": "node scripts/testes.js"
13
+ "test": "node scripts/testes.js",
14
+ "test:app": "node test/app-fluxo.test.mjs && node test/app-primeira.test.mjs && node test/app-entrar.test.mjs && node test/app-tela-estudio.test.mjs && node test/app-celular-telas.test.mjs",
15
+ "app:build": "cd app && npm run dist",
16
+ "app:dev": "cd app && npm start",
17
+ "mobile:build": "cd mobile && npm run build"
14
18
  },
15
19
  "engines": {
16
20
  "node": ">=18.17.0"
@@ -51,5 +55,9 @@
51
55
  ],
52
56
  "primocode": {
53
57
  "servidorMinimo": "8.12.0"
58
+ },
59
+ "devDependencies": {
60
+ "pixelmatch": "^5.3.0",
61
+ "pngjs": "^7.0.0"
54
62
  }
55
63
  }
@@ -200,6 +200,18 @@ def main() -> int:
200
200
  return 1
201
201
  print(json.dumps({"ok": True, "palavras": ws}, ensure_ascii=False))
202
202
  return 0
203
+ if acao == "arquivo":
204
+ # `... arquivo <wav> [idioma]`: a fala de um arquivo vira texto (o app desktop grava no microfone e manda para cá).
205
+ if not disponivel():
206
+ print(json.dumps({"ok": False, "error": "o faster-whisper não está instalado"}))
207
+ return 1
208
+ try:
209
+ texto = transcrever(Path(sys.argv[2]), sys.argv[3] if len(sys.argv) > 3 else "pt")
210
+ except Exception as e:
211
+ print(json.dumps({"ok": False, "error": str(e)}))
212
+ return 1
213
+ print(json.dumps({"ok": True, "texto": texto}, ensure_ascii=False))
214
+ return 0
203
215
  if acao != "baixar":
204
216
  print(json.dumps({"ok": False, "error": f"ação desconhecida: {acao}"}))
205
217
  return 2