@promovaweb/specsfy 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +248 -0
- package/bin/specsfy.cjs +3 -0
- package/dist/backlog.d.ts +17 -0
- package/dist/backlog.d.ts.map +1 -0
- package/dist/backlog.js +73 -0
- package/dist/backlog.js.map +1 -0
- package/dist/catalog.d.ts +33 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +135 -0
- package/dist/catalog.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +258 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +44 -0
- package/dist/config.js.map +1 -0
- package/dist/filesystem.d.ts +19 -0
- package/dist/filesystem.d.ts.map +1 -0
- package/dist/filesystem.js +67 -0
- package/dist/filesystem.js.map +1 -0
- package/dist/github.d.ts +8 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/github.js +36 -0
- package/dist/github.js.map +1 -0
- package/dist/installer.d.ts +48 -0
- package/dist/installer.d.ts.map +1 -0
- package/dist/installer.js +613 -0
- package/dist/installer.js.map +1 -0
- package/dist/main.d.ts +5 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +13 -0
- package/dist/main.js.map +1 -0
- package/dist/progress.d.ts +46 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +148 -0
- package/dist/progress.js.map +1 -0
- package/dist/project-testing.d.ts +27 -0
- package/dist/project-testing.d.ts.map +1 -0
- package/dist/project-testing.js +150 -0
- package/dist/project-testing.js.map +1 -0
- package/dist/skill-lock.d.ts +23 -0
- package/dist/skill-lock.d.ts.map +1 -0
- package/dist/skill-lock.js +80 -0
- package/dist/skill-lock.js.map +1 -0
- package/dist/tui.d.ts +141 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +1219 -0
- package/dist/tui.js.map +1 -0
- package/dist/updater.d.ts +41 -0
- package/dist/updater.d.ts.map +1 -0
- package/dist/updater.js +221 -0
- package/dist/updater.js.map +1 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/package.json +58 -0
package/dist/tui.js
ADDED
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard terminal do Specsfy.
|
|
3
|
+
*
|
|
4
|
+
* Mantém as seis áreas públicas, navegação por teclado e mouse, polling dos
|
|
5
|
+
* arquivos do projeto e confirmação explícita antes de alterar skills.
|
|
6
|
+
*/
|
|
7
|
+
import blessed from "neo-blessed";
|
|
8
|
+
import { marked } from "marked";
|
|
9
|
+
import { markedTerminal } from "marked-terminal";
|
|
10
|
+
import { backlogsFingerprint, scanBacklogs, } from "./backlog.js";
|
|
11
|
+
import { Catalog } from "./catalog.js";
|
|
12
|
+
import { loadConfig } from "./config.js";
|
|
13
|
+
import { resolvePath } from "./filesystem.js";
|
|
14
|
+
import { AUXILIARY_SKILLS, DOCUMENTATION_SKILLS, FRAMEWORK_SKILLS, SkillInstaller, } from "./installer.js";
|
|
15
|
+
import { scanSpecs, specsFingerprint, summarizeSpecs, } from "./progress.js";
|
|
16
|
+
import { runProjectTests } from "./project-testing.js";
|
|
17
|
+
import { ensureSkillsLock, skillsLockFingerprint, } from "./skill-lock.js";
|
|
18
|
+
import { VERSION } from "./version.js";
|
|
19
|
+
marked.use(markedTerminal({
|
|
20
|
+
reflowText: true,
|
|
21
|
+
width: Math.max(40, Math.min(process.stdout.columns ?? 80, 120)),
|
|
22
|
+
showSectionPrefix: false,
|
|
23
|
+
}));
|
|
24
|
+
/** Descrições do framework exibidas quando o catálogo remoto não as fornece. */
|
|
25
|
+
export const BASE_DESCRIPTIONS = {
|
|
26
|
+
"specsfy-01-inbox": "Captura e pré-processa entradas sem fazer perguntas.",
|
|
27
|
+
"specsfy-02-backlog": "Refina entradas, reduz ambiguidades e prioriza o backlog.",
|
|
28
|
+
"specsfy-03-specify": "Criação e evolução da especificação normativa.",
|
|
29
|
+
"specsfy-04-validate": "Revisão de qualidade, riscos e gates.",
|
|
30
|
+
"specsfy-05-tasks": "Decomposição rastreável do trabalho.",
|
|
31
|
+
"specsfy-06-tdd-bdd": "BDD, TDD e rastreabilidade dos requisitos.",
|
|
32
|
+
"specsfy-07-implement": "Execução disciplinada e evidências da entrega.",
|
|
33
|
+
"specsfy-update-spec": "Atualiza pedidos tardios e reabre os gates afetados.",
|
|
34
|
+
"specsfy-progress": "Leitura e acompanhamento do progresso.",
|
|
35
|
+
"specsfy-setup": "Prepara e reconcilia o contexto persistente do projeto.",
|
|
36
|
+
"specsfy-aux-stack": "Mapeia e mantém o stack técnico observado.",
|
|
37
|
+
"specsfy-aux-rules": "Ajuda a registrar regras explícitas sem apagar as atuais.",
|
|
38
|
+
"specsfy-aux-database": "Mantém o mapa tabular completo da persistência.",
|
|
39
|
+
"specsfy-documentator": "Reconstrói a documentação técnica completa do sistema.",
|
|
40
|
+
};
|
|
41
|
+
const CATEGORY_LABELS = {
|
|
42
|
+
base: "Essenciais",
|
|
43
|
+
auxiliary: "Auxiliares",
|
|
44
|
+
documentation: "Documentação",
|
|
45
|
+
architecture: "Arquitetura",
|
|
46
|
+
backend: "Backend",
|
|
47
|
+
data: "Dados",
|
|
48
|
+
design: "Design e experiência",
|
|
49
|
+
engineering: "Engenharia",
|
|
50
|
+
frontend: "Frontend",
|
|
51
|
+
language: "Linguagens",
|
|
52
|
+
operations: "Operações",
|
|
53
|
+
platform: "Plataforma",
|
|
54
|
+
quality: "Qualidade",
|
|
55
|
+
instalada: "Outras instaladas",
|
|
56
|
+
};
|
|
57
|
+
/** Atalhos globais preservados entre as versões Python e Node.js. */
|
|
58
|
+
export const TUI_BINDINGS = {
|
|
59
|
+
"C-q": "Sair",
|
|
60
|
+
escape: "Voltar",
|
|
61
|
+
"C-u": "Atualizar",
|
|
62
|
+
"C-b": "Framework",
|
|
63
|
+
"C-d": "Detectar",
|
|
64
|
+
"C-e": "Alternar",
|
|
65
|
+
space: "Abrir/alternar",
|
|
66
|
+
"C-a": "Aplicar",
|
|
67
|
+
"C-r": "Atualizar skills",
|
|
68
|
+
"C-v": "Marcar",
|
|
69
|
+
"C-l": "Limpar",
|
|
70
|
+
"C-t": "Todas",
|
|
71
|
+
"C-n": "Instaladas",
|
|
72
|
+
"C-c": "Recomendadas",
|
|
73
|
+
"C-h": "Home",
|
|
74
|
+
"C-g": "Backlogs",
|
|
75
|
+
"C-s": "Specs",
|
|
76
|
+
"C-j": "Testes",
|
|
77
|
+
"C-x": "Executar testes",
|
|
78
|
+
"C-k": "Skills",
|
|
79
|
+
"C-o": "Sobre",
|
|
80
|
+
};
|
|
81
|
+
/** Abas públicas na ordem fixa apresentada no dashboard. */
|
|
82
|
+
export const TUI_TABS = [
|
|
83
|
+
{ id: "home", label: "Home", key: "C-h" },
|
|
84
|
+
{ id: "backlogs", label: "Backlogs", key: "C-g" },
|
|
85
|
+
{ id: "specs", label: "Specs", key: "C-s" },
|
|
86
|
+
{ id: "tests", label: "Testes", key: "C-j" },
|
|
87
|
+
{ id: "skills", label: "Skills", key: "C-k" },
|
|
88
|
+
{ id: "about", label: "Sobre", key: "C-o" },
|
|
89
|
+
];
|
|
90
|
+
/** Dashboard terminal principal. */
|
|
91
|
+
export class SpecsfyTui {
|
|
92
|
+
project;
|
|
93
|
+
#screen;
|
|
94
|
+
#body;
|
|
95
|
+
#status;
|
|
96
|
+
#projectInput;
|
|
97
|
+
#modal;
|
|
98
|
+
#lastBackAt = 0;
|
|
99
|
+
#tabButtons = new Map();
|
|
100
|
+
#activeTab = "home";
|
|
101
|
+
#specs = [];
|
|
102
|
+
#backlogs = [];
|
|
103
|
+
#catalogEntries = [];
|
|
104
|
+
#catalog;
|
|
105
|
+
#catalogError = "";
|
|
106
|
+
#installed = new Set();
|
|
107
|
+
#selected = new Set();
|
|
108
|
+
#detected = new Set();
|
|
109
|
+
#skillFilter = "all";
|
|
110
|
+
#skillQuery = "";
|
|
111
|
+
#selectedSkill = "";
|
|
112
|
+
#suppressSkillToggleUntil = 0;
|
|
113
|
+
#fingerprints = { specs: "", backlogs: "", lock: "" };
|
|
114
|
+
#poller;
|
|
115
|
+
#testRunning = false;
|
|
116
|
+
#testResultTab = "summary";
|
|
117
|
+
#testSummary = "Nenhuma execução nesta sessão.";
|
|
118
|
+
#testOutput = [];
|
|
119
|
+
constructor(project = process.cwd()) {
|
|
120
|
+
this.project = resolvePath(project);
|
|
121
|
+
}
|
|
122
|
+
/** Aba atualmente exibida, útil para integrações e testes de acessibilidade. */
|
|
123
|
+
get activeTab() {
|
|
124
|
+
return this.#activeTab;
|
|
125
|
+
}
|
|
126
|
+
/** Monta a aplicação e resolve depois do encerramento da tela. */
|
|
127
|
+
async run() {
|
|
128
|
+
const screen = blessed.screen({
|
|
129
|
+
smartCSR: true,
|
|
130
|
+
fullUnicode: true,
|
|
131
|
+
mouse: true,
|
|
132
|
+
title: "Specsfy",
|
|
133
|
+
dockBorders: true,
|
|
134
|
+
});
|
|
135
|
+
await this.start({ screen });
|
|
136
|
+
await new Promise((resolve) => {
|
|
137
|
+
screen.once("destroy", () => {
|
|
138
|
+
resolve();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Monta a TUI em um screen existente.
|
|
144
|
+
*
|
|
145
|
+
* A separação permite embutir a interface e exercitar o mesmo renderer em
|
|
146
|
+
* terminais virtuais, sem manter um processo pendurado durante os testes.
|
|
147
|
+
*/
|
|
148
|
+
async start(options = {}) {
|
|
149
|
+
const screen = options.screen ??
|
|
150
|
+
blessed.screen({
|
|
151
|
+
smartCSR: true,
|
|
152
|
+
fullUnicode: true,
|
|
153
|
+
mouse: true,
|
|
154
|
+
title: "Specsfy",
|
|
155
|
+
dockBorders: true,
|
|
156
|
+
});
|
|
157
|
+
this.#screen = screen;
|
|
158
|
+
this.#catalog = options.catalog;
|
|
159
|
+
this.#catalogEntries = options.catalog?.entries ?? [];
|
|
160
|
+
this.mountShell(screen);
|
|
161
|
+
this.bindKeys(screen);
|
|
162
|
+
screen.once("destroy", () => {
|
|
163
|
+
if (this.#poller)
|
|
164
|
+
clearInterval(this.#poller);
|
|
165
|
+
this.#poller = undefined;
|
|
166
|
+
});
|
|
167
|
+
this.showStatus("Carregando projeto…");
|
|
168
|
+
await this.refreshAll(!options.catalog);
|
|
169
|
+
this.showTab("home");
|
|
170
|
+
if (options.watch !== false) {
|
|
171
|
+
const config = await loadConfig(this.project);
|
|
172
|
+
this.#poller = setInterval(() => void this.refreshIfChanged(), config.watch_interval * 1000);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
mountShell(screen) {
|
|
176
|
+
blessed.box({
|
|
177
|
+
parent: screen,
|
|
178
|
+
top: 0,
|
|
179
|
+
left: 0,
|
|
180
|
+
width: "100%",
|
|
181
|
+
height: 1,
|
|
182
|
+
align: "center",
|
|
183
|
+
content: "Specsfy — Dashboard de specs e skills",
|
|
184
|
+
style: { fg: "white", bg: "black", bold: true },
|
|
185
|
+
});
|
|
186
|
+
this.#projectInput = blessed.textbox({
|
|
187
|
+
parent: screen,
|
|
188
|
+
top: 1,
|
|
189
|
+
left: 1,
|
|
190
|
+
right: 21,
|
|
191
|
+
height: 3,
|
|
192
|
+
border: "line",
|
|
193
|
+
value: this.project,
|
|
194
|
+
inputOnFocus: false,
|
|
195
|
+
keys: true,
|
|
196
|
+
mouse: true,
|
|
197
|
+
style: {
|
|
198
|
+
fg: "white",
|
|
199
|
+
bg: "black",
|
|
200
|
+
border: { fg: "gray" },
|
|
201
|
+
focus: { border: { fg: "cyan" } },
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
this.#projectInput.on("submit", (value) => {
|
|
205
|
+
this.project = resolvePath(String(value || this.project));
|
|
206
|
+
this.#projectInput?.setValue(this.project);
|
|
207
|
+
void this.refreshAll(true);
|
|
208
|
+
});
|
|
209
|
+
this.#projectInput.key(["enter"], () => this.#projectInput?.readInput());
|
|
210
|
+
this.#projectInput.on("click", () => this.#projectInput?.readInput());
|
|
211
|
+
const refresh = blessed.button({
|
|
212
|
+
parent: screen,
|
|
213
|
+
top: 1,
|
|
214
|
+
right: 1,
|
|
215
|
+
width: 19,
|
|
216
|
+
height: 3,
|
|
217
|
+
border: "line",
|
|
218
|
+
content: "Atualizar ^U",
|
|
219
|
+
align: "center",
|
|
220
|
+
valign: "middle",
|
|
221
|
+
mouse: true,
|
|
222
|
+
keys: true,
|
|
223
|
+
style: buttonStyle(true),
|
|
224
|
+
});
|
|
225
|
+
refresh.on("press", () => void this.refreshAll(true));
|
|
226
|
+
this.#tabButtons.clear();
|
|
227
|
+
let left = 1;
|
|
228
|
+
for (const { label, id } of TUI_TABS) {
|
|
229
|
+
const width = label.length + 2;
|
|
230
|
+
const button = blessed.button({
|
|
231
|
+
parent: screen,
|
|
232
|
+
top: 4,
|
|
233
|
+
left,
|
|
234
|
+
width,
|
|
235
|
+
height: 1,
|
|
236
|
+
content: label,
|
|
237
|
+
align: "center",
|
|
238
|
+
mouse: true,
|
|
239
|
+
keys: true,
|
|
240
|
+
style: {
|
|
241
|
+
fg: "gray",
|
|
242
|
+
bg: "black",
|
|
243
|
+
focus: { fg: "white", bg: "blue", bold: true },
|
|
244
|
+
hover: { fg: "white", bg: "blue" },
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
button.on("press", () => this.showTab(id));
|
|
248
|
+
this.#tabButtons.set(id, button);
|
|
249
|
+
left += width;
|
|
250
|
+
}
|
|
251
|
+
blessed.line({
|
|
252
|
+
parent: screen,
|
|
253
|
+
top: 5,
|
|
254
|
+
left: 0,
|
|
255
|
+
width: "100%",
|
|
256
|
+
orientation: "horizontal",
|
|
257
|
+
style: { fg: "gray" },
|
|
258
|
+
});
|
|
259
|
+
this.#body = blessed.box({
|
|
260
|
+
parent: screen,
|
|
261
|
+
top: 6,
|
|
262
|
+
left: 1,
|
|
263
|
+
right: 1,
|
|
264
|
+
bottom: 4,
|
|
265
|
+
style: { fg: "white", bg: "black" },
|
|
266
|
+
});
|
|
267
|
+
this.#status = blessed.box({
|
|
268
|
+
parent: screen,
|
|
269
|
+
bottom: 2,
|
|
270
|
+
left: 0,
|
|
271
|
+
width: "100%",
|
|
272
|
+
height: 1,
|
|
273
|
+
padding: { left: 1 },
|
|
274
|
+
style: { fg: "gray", bg: "black" },
|
|
275
|
+
});
|
|
276
|
+
blessed.box({
|
|
277
|
+
parent: screen,
|
|
278
|
+
bottom: 1,
|
|
279
|
+
left: 0,
|
|
280
|
+
width: "100%",
|
|
281
|
+
height: 1,
|
|
282
|
+
content: " ^ = Ctrl · Tab/Shift+Tab: foco · Setas: navegar · " +
|
|
283
|
+
"Espaço: abrir spec/alternar skill · Esc: voltar · Mouse: disponível",
|
|
284
|
+
style: { fg: "cyan", bg: "black" },
|
|
285
|
+
});
|
|
286
|
+
blessed.box({
|
|
287
|
+
parent: screen,
|
|
288
|
+
bottom: 0,
|
|
289
|
+
left: 0,
|
|
290
|
+
width: "100%",
|
|
291
|
+
height: 1,
|
|
292
|
+
content: " ^Q Sair Esc Voltar",
|
|
293
|
+
style: { fg: "white", bg: "black", bold: true },
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
bindKeys(screen) {
|
|
297
|
+
screen.key(["C-q"], () => screen.destroy());
|
|
298
|
+
screen.key(["escape"], () => this.goBack());
|
|
299
|
+
screen.key(["C-u"], () => void this.refreshAll(true));
|
|
300
|
+
// O neo-blessed normaliza Ctrl+H como Backspace e Ctrl+J como Line Feed.
|
|
301
|
+
// Os aliases mantêm os atalhos globais funcionais com bytes de TTY reais.
|
|
302
|
+
screen.key(["C-h", "backspace"], () => this.showTab("home"));
|
|
303
|
+
screen.key(["C-g"], () => this.showTab("backlogs"));
|
|
304
|
+
screen.key(["C-s"], () => this.showTab("specs"));
|
|
305
|
+
screen.key(["C-j", "linefeed"], () => this.showTab("tests"));
|
|
306
|
+
screen.key(["C-k"], () => this.showTab("skills"));
|
|
307
|
+
screen.key(["C-o"], () => this.showTab("about"));
|
|
308
|
+
screen.key(["C-x"], () => void this.runTests());
|
|
309
|
+
screen.key(["C-b"], () => this.selectFramework());
|
|
310
|
+
screen.key(["C-d"], () => void this.detectSkills());
|
|
311
|
+
screen.key(["C-e"], () => this.toggleSelectedSkill());
|
|
312
|
+
screen.key(["C-a"], () => void this.applySkills());
|
|
313
|
+
screen.key(["C-r"], () => void this.updateSkills());
|
|
314
|
+
screen.key(["C-v"], () => this.selectVisible());
|
|
315
|
+
screen.key(["C-l"], () => this.clearVisible());
|
|
316
|
+
screen.key(["C-t"], () => this.setSkillFilter("all"));
|
|
317
|
+
screen.key(["C-n"], () => this.setSkillFilter("installed"));
|
|
318
|
+
screen.key(["C-c"], () => this.setSkillFilter("detected"));
|
|
319
|
+
screen.on("keypress", (_character, key) => {
|
|
320
|
+
if (key.name !== "tab")
|
|
321
|
+
return;
|
|
322
|
+
if (!screen.focused || !screen.keyable.includes(screen.focused)) {
|
|
323
|
+
this.#projectInput?.focus();
|
|
324
|
+
}
|
|
325
|
+
else if (key.shift) {
|
|
326
|
+
screen.focusPrevious();
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
screen.focusNext();
|
|
330
|
+
}
|
|
331
|
+
screen.render();
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
showTab(tab) {
|
|
335
|
+
if (this.#modal)
|
|
336
|
+
return;
|
|
337
|
+
this.#activeTab = tab;
|
|
338
|
+
const body = this.#body;
|
|
339
|
+
if (!body || !this.#screen)
|
|
340
|
+
return;
|
|
341
|
+
for (const [id, button] of this.#tabButtons) {
|
|
342
|
+
button.style.bg = id === tab ? "cyan" : "black";
|
|
343
|
+
button.style.fg = id === tab ? "black" : "gray";
|
|
344
|
+
button.style.bold = id === tab;
|
|
345
|
+
}
|
|
346
|
+
for (const child of [...body.children])
|
|
347
|
+
child.destroy();
|
|
348
|
+
if (tab === "home")
|
|
349
|
+
this.renderHome(body);
|
|
350
|
+
else if (tab === "backlogs")
|
|
351
|
+
this.renderBacklogs(body);
|
|
352
|
+
else if (tab === "specs")
|
|
353
|
+
this.renderSpecs(body);
|
|
354
|
+
else if (tab === "tests")
|
|
355
|
+
this.renderTests(body);
|
|
356
|
+
else if (tab === "skills")
|
|
357
|
+
this.renderSkills(body);
|
|
358
|
+
else
|
|
359
|
+
this.renderAbout(body);
|
|
360
|
+
this.#screen.render();
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Retorna um nível por vez: fecha modal, limpa busca e volta ao Home.
|
|
364
|
+
*
|
|
365
|
+
* Essa ordem reproduz a ação `back` do Textual e evita que Escape descarte
|
|
366
|
+
* o contexto da aba enquanto o usuário fecha uma visualização.
|
|
367
|
+
*/
|
|
368
|
+
goBack() {
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
if (now - this.#lastBackAt < 50)
|
|
371
|
+
return;
|
|
372
|
+
this.#lastBackAt = now;
|
|
373
|
+
if (this.#modal) {
|
|
374
|
+
this.#modal.destroy();
|
|
375
|
+
this.#modal = undefined;
|
|
376
|
+
this.#screen?.render();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (this.#activeTab === "skills" && this.#skillQuery) {
|
|
380
|
+
this.#skillQuery = "";
|
|
381
|
+
this.showTab("skills");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (this.#activeTab !== "home")
|
|
385
|
+
this.showTab("home");
|
|
386
|
+
}
|
|
387
|
+
renderHome(body) {
|
|
388
|
+
const summary = summarizeSpecs(this.#specs);
|
|
389
|
+
const cards = [
|
|
390
|
+
["Specs", String(summary.total_specs), "#173e67", "#2f81f7"],
|
|
391
|
+
[
|
|
392
|
+
`Tarefas · ${summary.pending_tasks} pendentes`,
|
|
393
|
+
`${summary.completed_tasks}/${summary.total_tasks}`,
|
|
394
|
+
"#3f2d63",
|
|
395
|
+
"#a371f7",
|
|
396
|
+
],
|
|
397
|
+
[
|
|
398
|
+
`Itens · ${summary.pending_items} pendentes`,
|
|
399
|
+
`${summary.completed_items}/${summary.total_items}`,
|
|
400
|
+
"#174b43",
|
|
401
|
+
"#2ea043",
|
|
402
|
+
],
|
|
403
|
+
[
|
|
404
|
+
"",
|
|
405
|
+
`${summary.percent}%\n${progressBar(summary.percent, 16)}`,
|
|
406
|
+
"#4a381c",
|
|
407
|
+
"#d29922",
|
|
408
|
+
],
|
|
409
|
+
];
|
|
410
|
+
cards.forEach(([label, value, background, border], index) => {
|
|
411
|
+
blessed.box({
|
|
412
|
+
parent: body,
|
|
413
|
+
top: 0,
|
|
414
|
+
left: `${index * 25}%`,
|
|
415
|
+
width: "25%",
|
|
416
|
+
height: 7,
|
|
417
|
+
border: "line",
|
|
418
|
+
align: "center",
|
|
419
|
+
valign: "middle",
|
|
420
|
+
content: `{bold}${value}{/bold}${label ? `\n${label}` : ""}`,
|
|
421
|
+
tags: true,
|
|
422
|
+
style: { fg: "white", bg: background, border: { fg: border } },
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
blessed.box({
|
|
426
|
+
parent: body,
|
|
427
|
+
top: 9,
|
|
428
|
+
left: 0,
|
|
429
|
+
right: 0,
|
|
430
|
+
height: 5,
|
|
431
|
+
border: "line",
|
|
432
|
+
padding: { left: 2, right: 2 },
|
|
433
|
+
valign: "middle",
|
|
434
|
+
content: "Visão consolidada do andamento das especificações. " +
|
|
435
|
+
"Os números são recalculados quando os arquivos mudam.",
|
|
436
|
+
style: { fg: "white", border: { fg: "gray" } },
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
renderBacklogs(body) {
|
|
440
|
+
const preview = blessed.box({
|
|
441
|
+
parent: body,
|
|
442
|
+
top: 0,
|
|
443
|
+
left: "35%",
|
|
444
|
+
right: 0,
|
|
445
|
+
bottom: 0,
|
|
446
|
+
border: "line",
|
|
447
|
+
label: " Selecione um backlog ",
|
|
448
|
+
padding: { left: 1, right: 1 },
|
|
449
|
+
scrollable: true,
|
|
450
|
+
alwaysScroll: true,
|
|
451
|
+
keys: true,
|
|
452
|
+
vi: true,
|
|
453
|
+
mouse: true,
|
|
454
|
+
scrollbar: { ch: " ", track: { bg: "gray" }, style: { bg: "cyan" } },
|
|
455
|
+
style: { border: { fg: "gray" }, focus: { border: { fg: "yellow" } } },
|
|
456
|
+
});
|
|
457
|
+
const list = blessed.list({
|
|
458
|
+
parent: body,
|
|
459
|
+
top: 0,
|
|
460
|
+
left: 0,
|
|
461
|
+
width: "34%",
|
|
462
|
+
bottom: 0,
|
|
463
|
+
border: "line",
|
|
464
|
+
label: ` Backlogs · ${this.#backlogs.length} `,
|
|
465
|
+
items: this.#backlogs.map((item) => item.title),
|
|
466
|
+
keys: true,
|
|
467
|
+
vi: true,
|
|
468
|
+
mouse: true,
|
|
469
|
+
tags: false,
|
|
470
|
+
style: {
|
|
471
|
+
selected: { fg: "white", bg: "blue", bold: true },
|
|
472
|
+
item: { fg: "white" },
|
|
473
|
+
border: { fg: "gray" },
|
|
474
|
+
focus: { border: { fg: "yellow" } },
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
const updatePreview = () => {
|
|
478
|
+
const item = this.#backlogs[selectedIndex(list)];
|
|
479
|
+
preview.setLabel(item ? ` ${item.identifier} · ${item.status} ` : " Nenhum backlog ");
|
|
480
|
+
preview.setContent(item ? renderMarkdown(formatBacklogPreview(item.content)) :
|
|
481
|
+
"Crie arquivos em specs/backlog/<NNNN>-<slug>.md para visualizá-los aqui.");
|
|
482
|
+
this.#screen?.render();
|
|
483
|
+
};
|
|
484
|
+
list.on("select item", updatePreview);
|
|
485
|
+
list.on("keypress", updatePreview);
|
|
486
|
+
updatePreview();
|
|
487
|
+
list.focus();
|
|
488
|
+
}
|
|
489
|
+
renderSpecs(body) {
|
|
490
|
+
const heading = " Spec Status Gates Tarefas Checklist Progresso ";
|
|
491
|
+
const panel = blessed.box({
|
|
492
|
+
parent: body,
|
|
493
|
+
top: 0,
|
|
494
|
+
left: 0,
|
|
495
|
+
width: "100%",
|
|
496
|
+
height: "100%",
|
|
497
|
+
border: "line",
|
|
498
|
+
style: { border: { fg: "gray" } },
|
|
499
|
+
});
|
|
500
|
+
blessed.box({
|
|
501
|
+
parent: panel,
|
|
502
|
+
top: 0,
|
|
503
|
+
left: 0,
|
|
504
|
+
width: "100%-2",
|
|
505
|
+
height: 1,
|
|
506
|
+
content: heading,
|
|
507
|
+
style: { fg: "white", bg: "cyan", bold: true },
|
|
508
|
+
});
|
|
509
|
+
const list = blessed.list({
|
|
510
|
+
parent: panel,
|
|
511
|
+
top: 1,
|
|
512
|
+
left: 0,
|
|
513
|
+
right: 0,
|
|
514
|
+
bottom: 0,
|
|
515
|
+
items: this.#specs.map((spec) => ` ${spec.slug.slice(0, 32).padEnd(32)} ` +
|
|
516
|
+
`${spec.status.slice(0, 12).padEnd(12)} ` +
|
|
517
|
+
`${`${spec.passed_gates}/${spec.total_gates}`.padEnd(7)} ` +
|
|
518
|
+
`${`${spec.completed_tasks}/${spec.total_tasks}`.padEnd(8)} ` +
|
|
519
|
+
`${`${spec.completed_items}/${spec.total_items}`.padEnd(8)} ` +
|
|
520
|
+
`${progressBar(spec.percent)} ${spec.percent}%`),
|
|
521
|
+
keys: true,
|
|
522
|
+
vi: true,
|
|
523
|
+
mouse: true,
|
|
524
|
+
style: {
|
|
525
|
+
selected: { fg: "white", bg: "blue", bold: true },
|
|
526
|
+
item: { fg: "white" },
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
const open = () => {
|
|
530
|
+
const spec = this.#specs[selectedIndex(list)];
|
|
531
|
+
if (spec)
|
|
532
|
+
this.openSpec(spec);
|
|
533
|
+
};
|
|
534
|
+
list.on("select", open);
|
|
535
|
+
list.key(["space"], open);
|
|
536
|
+
list.focus();
|
|
537
|
+
}
|
|
538
|
+
openSpec(spec) {
|
|
539
|
+
const screen = this.#screen;
|
|
540
|
+
if (!screen)
|
|
541
|
+
return;
|
|
542
|
+
const modal = blessed.box({
|
|
543
|
+
parent: screen,
|
|
544
|
+
top: "4%",
|
|
545
|
+
left: "4%",
|
|
546
|
+
width: "92%",
|
|
547
|
+
height: "92%",
|
|
548
|
+
border: "line",
|
|
549
|
+
label: ` ${spec.slug} · ${spec.status} `,
|
|
550
|
+
content: `${spec.title} · Gates ${spec.passed_gates}/${spec.total_gates} · ` +
|
|
551
|
+
`Tarefas ${spec.completed_tasks}/${spec.total_tasks} · ` +
|
|
552
|
+
`Checklist ${spec.completed_items}/${spec.total_items} · ${spec.percent}%\n` +
|
|
553
|
+
`${spec.path}\n\n${renderMarkdown(spec.content)}\n\n` +
|
|
554
|
+
"Esc: voltar para a lista de specs",
|
|
555
|
+
scrollable: true,
|
|
556
|
+
alwaysScroll: true,
|
|
557
|
+
keys: true,
|
|
558
|
+
vi: true,
|
|
559
|
+
mouse: true,
|
|
560
|
+
padding: { left: 1, right: 1 },
|
|
561
|
+
scrollbar: { ch: " ", track: { bg: "gray" }, style: { bg: "cyan" } },
|
|
562
|
+
style: { fg: "white", bg: "black", border: { fg: "cyan" } },
|
|
563
|
+
});
|
|
564
|
+
this.#modal = modal;
|
|
565
|
+
modal.focus();
|
|
566
|
+
screen.render();
|
|
567
|
+
}
|
|
568
|
+
renderTests(body) {
|
|
569
|
+
const button = blessed.button({
|
|
570
|
+
parent: body,
|
|
571
|
+
top: 1,
|
|
572
|
+
right: 2,
|
|
573
|
+
width: 24,
|
|
574
|
+
height: 3,
|
|
575
|
+
content: " Executar testes ^X ",
|
|
576
|
+
align: "center",
|
|
577
|
+
mouse: true,
|
|
578
|
+
keys: true,
|
|
579
|
+
style: {
|
|
580
|
+
fg: "white",
|
|
581
|
+
bg: "blue",
|
|
582
|
+
focus: { bg: "cyan", fg: "black" },
|
|
583
|
+
hover: { bg: "cyan", fg: "black" },
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
button.on("press", () => void this.runTests());
|
|
587
|
+
blessed.box({
|
|
588
|
+
parent: body,
|
|
589
|
+
top: 1,
|
|
590
|
+
left: 2,
|
|
591
|
+
right: 28,
|
|
592
|
+
height: 3,
|
|
593
|
+
content: this.#testRunning
|
|
594
|
+
? "Executando os testes do projeto…"
|
|
595
|
+
: this.#testSummary === "Nenhuma execução nesta sessão."
|
|
596
|
+
? this.#testSummary
|
|
597
|
+
: "A última execução está disponível abaixo.",
|
|
598
|
+
});
|
|
599
|
+
const summaryTab = blessed.button({
|
|
600
|
+
parent: body,
|
|
601
|
+
top: 5,
|
|
602
|
+
left: 1,
|
|
603
|
+
width: 10,
|
|
604
|
+
height: 1,
|
|
605
|
+
content: "Resumo",
|
|
606
|
+
align: "center",
|
|
607
|
+
keys: true,
|
|
608
|
+
mouse: true,
|
|
609
|
+
style: tabStyle(this.#testResultTab === "summary"),
|
|
610
|
+
});
|
|
611
|
+
const outputTab = blessed.button({
|
|
612
|
+
parent: body,
|
|
613
|
+
top: 5,
|
|
614
|
+
left: 11,
|
|
615
|
+
width: 10,
|
|
616
|
+
height: 1,
|
|
617
|
+
content: "Testes",
|
|
618
|
+
align: "center",
|
|
619
|
+
keys: true,
|
|
620
|
+
mouse: true,
|
|
621
|
+
style: tabStyle(this.#testResultTab === "output"),
|
|
622
|
+
});
|
|
623
|
+
summaryTab.on("press", () => {
|
|
624
|
+
this.#testResultTab = "summary";
|
|
625
|
+
this.showTab("tests");
|
|
626
|
+
});
|
|
627
|
+
outputTab.on("press", () => {
|
|
628
|
+
this.#testResultTab = "output";
|
|
629
|
+
this.showTab("tests");
|
|
630
|
+
});
|
|
631
|
+
if (this.#testResultTab === "summary") {
|
|
632
|
+
blessed.box({
|
|
633
|
+
parent: body,
|
|
634
|
+
top: 6,
|
|
635
|
+
left: 1,
|
|
636
|
+
right: 1,
|
|
637
|
+
bottom: 0,
|
|
638
|
+
border: "line",
|
|
639
|
+
label: " Resumo ",
|
|
640
|
+
content: this.#testSummary,
|
|
641
|
+
padding: { left: 2, right: 2, top: 1 },
|
|
642
|
+
keys: true,
|
|
643
|
+
vi: true,
|
|
644
|
+
mouse: true,
|
|
645
|
+
scrollable: true,
|
|
646
|
+
style: { border: { fg: "gray" }, focus: { border: { fg: "yellow" } } },
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
// O blessed.log agenda a rolagem depois de setContent e acessa widgets
|
|
651
|
+
// destruídos quando a saída ao vivo recria a aba. A caixa preserva a
|
|
652
|
+
// rolagem sem deixar callbacks pendentes entre duas renderizações.
|
|
653
|
+
blessed.box({
|
|
654
|
+
parent: body,
|
|
655
|
+
top: 6,
|
|
656
|
+
left: 1,
|
|
657
|
+
right: 1,
|
|
658
|
+
bottom: 0,
|
|
659
|
+
border: "line",
|
|
660
|
+
label: " Testes ",
|
|
661
|
+
padding: { left: 1, right: 1 },
|
|
662
|
+
keys: true,
|
|
663
|
+
vi: true,
|
|
664
|
+
mouse: true,
|
|
665
|
+
scrollable: true,
|
|
666
|
+
alwaysScroll: true,
|
|
667
|
+
tags: false,
|
|
668
|
+
content: this.#testOutput.join("\n"),
|
|
669
|
+
style: { border: { fg: "gray" }, focus: { border: { fg: "yellow" } } },
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
button.focus();
|
|
673
|
+
}
|
|
674
|
+
async runTests() {
|
|
675
|
+
this.showTab("tests");
|
|
676
|
+
if (this.#testRunning)
|
|
677
|
+
return;
|
|
678
|
+
this.#testRunning = true;
|
|
679
|
+
this.#testResultTab = "output";
|
|
680
|
+
this.#testOutput = [];
|
|
681
|
+
this.#testSummary = "Preparando o runner de testes…";
|
|
682
|
+
this.showTab("tests");
|
|
683
|
+
this.showStatus("Executando os testes do projeto…");
|
|
684
|
+
try {
|
|
685
|
+
const result = await runProjectTests(this.project, (line) => {
|
|
686
|
+
this.#testOutput.push(line);
|
|
687
|
+
if (this.#activeTab === "tests" && this.#testResultTab === "output") {
|
|
688
|
+
this.showTab("tests");
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
this.#testSummary = formatTestRun(result);
|
|
692
|
+
this.#testResultTab = "summary";
|
|
693
|
+
this.showStatus(`${result.exit_code === 0 ? "Testes passaram" : "Testes falharam"} · ` +
|
|
694
|
+
`${result.duration_seconds.toFixed(2)}s · exit code ${result.exit_code}`);
|
|
695
|
+
this.showTab("tests");
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
const message = `Erro ao executar testes: ${errorMessage(error)}`;
|
|
699
|
+
this.#testSummary = message;
|
|
700
|
+
this.#testOutput.push(message);
|
|
701
|
+
this.#testResultTab = "summary";
|
|
702
|
+
this.showStatus(message);
|
|
703
|
+
this.showTab("tests");
|
|
704
|
+
}
|
|
705
|
+
finally {
|
|
706
|
+
this.#testRunning = false;
|
|
707
|
+
this.#screen?.render();
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
renderSkills(body) {
|
|
711
|
+
blessed.box({
|
|
712
|
+
parent: body,
|
|
713
|
+
top: 0,
|
|
714
|
+
left: 0,
|
|
715
|
+
right: 0,
|
|
716
|
+
height: 1,
|
|
717
|
+
content: `${this.project}/skills-lock.json · ` +
|
|
718
|
+
`${this.#installed.size} skill(s) Specsfy instalada(s)` +
|
|
719
|
+
(this.#catalogError ? ` · catálogo indisponível: ${this.#catalogError}` : ""),
|
|
720
|
+
style: { fg: "gray" },
|
|
721
|
+
});
|
|
722
|
+
const search = blessed.textbox({
|
|
723
|
+
parent: body,
|
|
724
|
+
top: 1,
|
|
725
|
+
left: 0,
|
|
726
|
+
right: 20,
|
|
727
|
+
height: 3,
|
|
728
|
+
border: "line",
|
|
729
|
+
label: " Buscar por nome, tecnologia ou categoria… ",
|
|
730
|
+
value: this.#skillQuery,
|
|
731
|
+
inputOnFocus: false,
|
|
732
|
+
keys: true,
|
|
733
|
+
mouse: true,
|
|
734
|
+
style: { border: { fg: "gray" }, focus: { border: { fg: "yellow" } } },
|
|
735
|
+
});
|
|
736
|
+
search.on("submit", (value) => {
|
|
737
|
+
this.#skillQuery = String(value ?? "");
|
|
738
|
+
this.showTab("skills");
|
|
739
|
+
});
|
|
740
|
+
search.key(["enter"], () => search.readInput());
|
|
741
|
+
search.on("click", () => search.readInput());
|
|
742
|
+
const update = blessed.button({
|
|
743
|
+
parent: body,
|
|
744
|
+
top: 1,
|
|
745
|
+
right: 0,
|
|
746
|
+
width: 19,
|
|
747
|
+
height: 3,
|
|
748
|
+
border: "line",
|
|
749
|
+
content: "Atualizar ^R",
|
|
750
|
+
align: "center",
|
|
751
|
+
valign: "middle",
|
|
752
|
+
keys: true,
|
|
753
|
+
mouse: true,
|
|
754
|
+
style: buttonStyle(),
|
|
755
|
+
});
|
|
756
|
+
update.on("press", () => void this.updateSkills());
|
|
757
|
+
const filters = [
|
|
758
|
+
["Todas ^T", "all"],
|
|
759
|
+
["Instaladas ^N", "installed"],
|
|
760
|
+
["Recomendadas ^C", "detected"],
|
|
761
|
+
];
|
|
762
|
+
filters.forEach(([label, filter], index) => {
|
|
763
|
+
const button = blessed.button({
|
|
764
|
+
parent: body,
|
|
765
|
+
top: 4,
|
|
766
|
+
left: `${index * 33}%`,
|
|
767
|
+
width: index === 2 ? "34%" : "33%",
|
|
768
|
+
height: 3,
|
|
769
|
+
border: "line",
|
|
770
|
+
content: label,
|
|
771
|
+
align: "center",
|
|
772
|
+
valign: "middle",
|
|
773
|
+
keys: true,
|
|
774
|
+
mouse: true,
|
|
775
|
+
style: buttonStyle(this.#skillFilter === filter),
|
|
776
|
+
});
|
|
777
|
+
button.on("press", () => this.setSkillFilter(filter));
|
|
778
|
+
});
|
|
779
|
+
const visible = this.visibleSkills();
|
|
780
|
+
const toInstall = [...this.#selected].filter((name) => !this.#installed.has(name)).length;
|
|
781
|
+
const toRemove = [...this.#installed].filter((name) => !this.#selected.has(name)).length;
|
|
782
|
+
blessed.box({
|
|
783
|
+
parent: body,
|
|
784
|
+
top: 7,
|
|
785
|
+
left: 0,
|
|
786
|
+
right: 0,
|
|
787
|
+
height: 1,
|
|
788
|
+
content: `${this.#selected.size} selecionada(s) · ${visible.length} visível(is) · ` +
|
|
789
|
+
`${toInstall} para instalar · ${toRemove} para remover`,
|
|
790
|
+
style: { fg: "white", bold: true },
|
|
791
|
+
});
|
|
792
|
+
const catalog = blessed.box({
|
|
793
|
+
parent: body,
|
|
794
|
+
top: 8,
|
|
795
|
+
left: 0,
|
|
796
|
+
right: 0,
|
|
797
|
+
bottom: 4,
|
|
798
|
+
});
|
|
799
|
+
const list = blessed.list({
|
|
800
|
+
parent: catalog,
|
|
801
|
+
top: 1,
|
|
802
|
+
left: 0,
|
|
803
|
+
width: "64%",
|
|
804
|
+
bottom: 0,
|
|
805
|
+
border: "line",
|
|
806
|
+
items: visible.map((option) => ` ${skillPlanLabel(option.name, this.#installed, this.#selected).padEnd(8)} ` +
|
|
807
|
+
`${friendlySkillName(option.name).slice(0, 28).padEnd(28)} ` +
|
|
808
|
+
`${(CATEGORY_LABELS[option.kind] ?? titleCase(option.kind)).slice(0, 18).padEnd(18)} ` +
|
|
809
|
+
`${skillStateLabel(option.name, this.#installed, this.#detected)}`),
|
|
810
|
+
keys: true,
|
|
811
|
+
vi: true,
|
|
812
|
+
mouse: true,
|
|
813
|
+
style: {
|
|
814
|
+
selected: { fg: "white", bg: "blue", bold: true },
|
|
815
|
+
item: { fg: "white" },
|
|
816
|
+
border: { fg: "gray" },
|
|
817
|
+
focus: { border: { fg: "yellow" } },
|
|
818
|
+
},
|
|
819
|
+
});
|
|
820
|
+
const detail = blessed.box({
|
|
821
|
+
parent: catalog,
|
|
822
|
+
top: 0,
|
|
823
|
+
left: "64%",
|
|
824
|
+
right: 0,
|
|
825
|
+
bottom: 0,
|
|
826
|
+
border: "line",
|
|
827
|
+
label: " Detalhes ",
|
|
828
|
+
padding: { left: 1, right: 1 },
|
|
829
|
+
scrollable: true,
|
|
830
|
+
keys: true,
|
|
831
|
+
vi: true,
|
|
832
|
+
mouse: true,
|
|
833
|
+
style: { border: { fg: "gray" }, focus: { border: { fg: "yellow" } } },
|
|
834
|
+
});
|
|
835
|
+
blessed.box({
|
|
836
|
+
parent: catalog,
|
|
837
|
+
top: 0,
|
|
838
|
+
left: 0,
|
|
839
|
+
width: "64%",
|
|
840
|
+
height: 1,
|
|
841
|
+
content: " PLANO SKILL CATEGORIA ESTADO ",
|
|
842
|
+
style: { fg: "black", bg: "white", bold: true },
|
|
843
|
+
});
|
|
844
|
+
const updateDetail = () => {
|
|
845
|
+
const option = visible[selectedIndex(list)];
|
|
846
|
+
this.#selectedSkill = option?.name ?? "";
|
|
847
|
+
detail.setContent(option
|
|
848
|
+
? formatSkillDetail(option, this.#installed, this.#selected, this.#detected)
|
|
849
|
+
: "Nenhuma skill visível.\n\nAjuste a busca ou os filtros para continuar.");
|
|
850
|
+
this.#screen?.render();
|
|
851
|
+
};
|
|
852
|
+
list.on("select item", updateDetail);
|
|
853
|
+
list.on("keypress", () => setImmediate(updateDetail));
|
|
854
|
+
const toggle = () => {
|
|
855
|
+
updateDetail();
|
|
856
|
+
this.toggleSelectedSkill();
|
|
857
|
+
};
|
|
858
|
+
list.on("select", toggle);
|
|
859
|
+
list.key(["space"], toggle);
|
|
860
|
+
const actions = [
|
|
861
|
+
["Detectar ^D", () => void this.detectSkills()],
|
|
862
|
+
["Framework ^B", () => this.selectFramework()],
|
|
863
|
+
["Marcar ^V", () => this.selectVisible()],
|
|
864
|
+
["Limpar ^L", () => this.clearVisible()],
|
|
865
|
+
["Alternar ^E", () => this.toggleSelectedSkill()],
|
|
866
|
+
["Aplicar ^A", () => void this.applySkills(), true],
|
|
867
|
+
];
|
|
868
|
+
actions.forEach(([label, action, primary], index) => {
|
|
869
|
+
const button = blessed.button({
|
|
870
|
+
parent: body,
|
|
871
|
+
bottom: 0,
|
|
872
|
+
left: `${index * (100 / 6)}%`,
|
|
873
|
+
width: `${100 / 6}%`,
|
|
874
|
+
height: 3,
|
|
875
|
+
border: "line",
|
|
876
|
+
content: label,
|
|
877
|
+
align: "center",
|
|
878
|
+
valign: "middle",
|
|
879
|
+
keys: true,
|
|
880
|
+
mouse: true,
|
|
881
|
+
style: buttonStyle(Boolean(primary)),
|
|
882
|
+
});
|
|
883
|
+
button.on("press", action);
|
|
884
|
+
});
|
|
885
|
+
updateDetail();
|
|
886
|
+
list.focus();
|
|
887
|
+
}
|
|
888
|
+
renderAbout(body) {
|
|
889
|
+
blessed.box({
|
|
890
|
+
parent: body,
|
|
891
|
+
top: 2,
|
|
892
|
+
left: 3,
|
|
893
|
+
right: 3,
|
|
894
|
+
height: 12,
|
|
895
|
+
content: `Specsfy CLI ${VERSION}\n\n` +
|
|
896
|
+
"Framework para transformar ideias em especificações rastreáveis e " +
|
|
897
|
+
"acompanhar sua entrega.\n\n" +
|
|
898
|
+
"Skills instaladas são lidas do skills-lock.json do projeto. " +
|
|
899
|
+
"Ctrl+Q encerra a interface.",
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
async refreshAll(reloadCatalog) {
|
|
903
|
+
try {
|
|
904
|
+
const [specs, backlogs, lock] = await Promise.all([
|
|
905
|
+
scanSpecs(this.project),
|
|
906
|
+
scanBacklogs(this.project),
|
|
907
|
+
ensureSkillsLock(this.project),
|
|
908
|
+
]);
|
|
909
|
+
this.#specs = specs;
|
|
910
|
+
this.#backlogs = backlogs;
|
|
911
|
+
this.#installed = new Set(Object.keys(lock.skills).filter(isSpecsfySkill));
|
|
912
|
+
this.#selected = new Set(this.#installed);
|
|
913
|
+
if (reloadCatalog || !this.#catalog) {
|
|
914
|
+
try {
|
|
915
|
+
this.#catalog = await Catalog.fetch();
|
|
916
|
+
this.#catalogEntries = this.#catalog.entries;
|
|
917
|
+
this.#catalogError = "";
|
|
918
|
+
}
|
|
919
|
+
catch (error) {
|
|
920
|
+
this.#catalogEntries = [];
|
|
921
|
+
this.#catalogError = errorMessage(error);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
this.#fingerprints = {
|
|
925
|
+
specs: await specsFingerprint(this.project),
|
|
926
|
+
backlogs: await backlogsFingerprint(this.project),
|
|
927
|
+
lock: await skillsLockFingerprint(this.project),
|
|
928
|
+
};
|
|
929
|
+
const summary = summarizeSpecs(specs);
|
|
930
|
+
this.showStatus(`${this.project} · ${summary.completed_specs}/${summary.total_specs} specs completas · ` +
|
|
931
|
+
"atualização automática ativa" +
|
|
932
|
+
(this.#catalogError ? ` · catálogo indisponível: ${this.#catalogError}` : ""));
|
|
933
|
+
this.showTab(this.#activeTab);
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
this.showStatus(`Erro: ${errorMessage(error)}`);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
async refreshIfChanged() {
|
|
940
|
+
const current = {
|
|
941
|
+
specs: await specsFingerprint(this.project),
|
|
942
|
+
backlogs: await backlogsFingerprint(this.project),
|
|
943
|
+
lock: await skillsLockFingerprint(this.project),
|
|
944
|
+
};
|
|
945
|
+
if (current.specs !== this.#fingerprints.specs ||
|
|
946
|
+
current.backlogs !== this.#fingerprints.backlogs ||
|
|
947
|
+
current.lock !== this.#fingerprints.lock) {
|
|
948
|
+
await this.refreshAll(false);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
skillOptions() {
|
|
952
|
+
return buildSkillOptions(this.#catalogEntries, this.#installed);
|
|
953
|
+
}
|
|
954
|
+
visibleSkills() {
|
|
955
|
+
return filterSkillOptions(this.skillOptions(), this.#skillFilter, this.#skillQuery, this.#installed, this.#detected);
|
|
956
|
+
}
|
|
957
|
+
setSkillFilter(filter) {
|
|
958
|
+
this.#skillFilter = filter;
|
|
959
|
+
this.showTab("skills");
|
|
960
|
+
}
|
|
961
|
+
selectFramework() {
|
|
962
|
+
this.#selected = new Set([...this.#selected, ...FRAMEWORK_SKILLS]);
|
|
963
|
+
this.showStatus("As skills do framework foram selecionadas. Use ^A para aplicar.");
|
|
964
|
+
this.showTab("skills");
|
|
965
|
+
}
|
|
966
|
+
async detectSkills() {
|
|
967
|
+
this.showTab("skills");
|
|
968
|
+
try {
|
|
969
|
+
this.#catalog ??= await Catalog.fetch();
|
|
970
|
+
this.#catalogEntries = this.#catalog.entries;
|
|
971
|
+
const entries = await this.#catalog.detect(this.project);
|
|
972
|
+
this.#detected = new Set(entries.map((entry) => entry.name));
|
|
973
|
+
this.#selected = new Set([...this.#selected, ...this.#detected]);
|
|
974
|
+
this.#skillFilter = "detected";
|
|
975
|
+
this.showStatus(`Skills detectadas e selecionadas: ${[...this.#detected].sort().join(", ") || "nenhuma skill"}`);
|
|
976
|
+
this.showTab("skills");
|
|
977
|
+
}
|
|
978
|
+
catch (error) {
|
|
979
|
+
this.showStatus(`Erro: ${errorMessage(error)}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
toggleSelectedSkill() {
|
|
983
|
+
if (Date.now() < this.#suppressSkillToggleUntil)
|
|
984
|
+
return;
|
|
985
|
+
if (this.#activeTab !== "skills" || !this.#selectedSkill)
|
|
986
|
+
return;
|
|
987
|
+
if (this.#selected.has(this.#selectedSkill)) {
|
|
988
|
+
this.#selected.delete(this.#selectedSkill);
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
this.#selected.add(this.#selectedSkill);
|
|
992
|
+
}
|
|
993
|
+
this.showTab("skills");
|
|
994
|
+
}
|
|
995
|
+
selectVisible() {
|
|
996
|
+
this.#suppressSkillToggleUntil = Date.now() + 100;
|
|
997
|
+
for (const option of this.visibleSkills())
|
|
998
|
+
this.#selected.add(option.name);
|
|
999
|
+
this.showTab("skills");
|
|
1000
|
+
}
|
|
1001
|
+
clearVisible() {
|
|
1002
|
+
this.#suppressSkillToggleUntil = Date.now() + 100;
|
|
1003
|
+
for (const option of this.visibleSkills())
|
|
1004
|
+
this.#selected.delete(option.name);
|
|
1005
|
+
this.showTab("skills");
|
|
1006
|
+
}
|
|
1007
|
+
async applySkills() {
|
|
1008
|
+
this.showTab("skills");
|
|
1009
|
+
try {
|
|
1010
|
+
const selected = new Set([...this.#selected].filter(isSpecsfySkill));
|
|
1011
|
+
const specialistNames = [...selected].filter((name) => name.startsWith("specsfy-specialist-"));
|
|
1012
|
+
if (specialistNames.length) {
|
|
1013
|
+
this.#catalog ??= await Catalog.fetch();
|
|
1014
|
+
for (const entry of this.#catalog.resolve(specialistNames)) {
|
|
1015
|
+
selected.add(entry.name);
|
|
1016
|
+
this.#selected.add(entry.name);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const toAdd = [...selected].filter((name) => !this.#installed.has(name));
|
|
1020
|
+
const toRemove = [...this.#installed].filter((name) => !selected.has(name));
|
|
1021
|
+
const base = toAdd.filter((name) => FRAMEWORK_SKILLS.includes(name));
|
|
1022
|
+
const specialists = toAdd.filter((name) => name.startsWith("specsfy-specialist-"));
|
|
1023
|
+
const installer = await SkillInstaller.create(this.project);
|
|
1024
|
+
const changed = [];
|
|
1025
|
+
if (base.length)
|
|
1026
|
+
changed.push(...(await installer.installBaseSelection(base)));
|
|
1027
|
+
if (specialists.length) {
|
|
1028
|
+
this.#catalog ??= await Catalog.fetch();
|
|
1029
|
+
const names = this.#catalog
|
|
1030
|
+
.resolve(specialists)
|
|
1031
|
+
.map((entry) => entry.name)
|
|
1032
|
+
.filter((name) => !this.#installed.has(name));
|
|
1033
|
+
changed.push(...(await installer.installSpecialists(names)));
|
|
1034
|
+
}
|
|
1035
|
+
if (toRemove.length)
|
|
1036
|
+
changed.push(...(await installer.remove(toRemove.sort())));
|
|
1037
|
+
await this.refreshAll(false);
|
|
1038
|
+
this.showStatus(changed.length
|
|
1039
|
+
? `Seleção aplicada: ${changed.length} alteração(ões).`
|
|
1040
|
+
: "A seleção já corresponde ao skills-lock.json.");
|
|
1041
|
+
}
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
this.showStatus(`Erro: ${errorMessage(error)}`);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
async updateSkills() {
|
|
1047
|
+
this.showTab("skills");
|
|
1048
|
+
try {
|
|
1049
|
+
const changed = await (await SkillInstaller.create(this.project)).updateAll();
|
|
1050
|
+
await this.refreshAll(true);
|
|
1051
|
+
this.showStatus(changed.length
|
|
1052
|
+
? `Skills atualizadas: ${changed.length} alteração(ões).`
|
|
1053
|
+
: "Todas as skills instaladas já estão atualizadas.");
|
|
1054
|
+
}
|
|
1055
|
+
catch (error) {
|
|
1056
|
+
this.showStatus(`Erro: ${errorMessage(error)}`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
showStatus(message) {
|
|
1060
|
+
this.#status?.setContent(message);
|
|
1061
|
+
this.#screen?.render();
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
/** Cria as opções base, especialistas e instalações fora do catálogo atual. */
|
|
1065
|
+
export function buildSkillOptions(catalogEntries, installed) {
|
|
1066
|
+
const options = FRAMEWORK_SKILLS.map((name) => ({
|
|
1067
|
+
name,
|
|
1068
|
+
description: BASE_DESCRIPTIONS[name] ?? "Skill do framework Specsfy.",
|
|
1069
|
+
kind: AUXILIARY_SKILLS.includes(name) || name === "specsfy-setup"
|
|
1070
|
+
? "auxiliary"
|
|
1071
|
+
: DOCUMENTATION_SKILLS.includes(name)
|
|
1072
|
+
? "documentation"
|
|
1073
|
+
: "base",
|
|
1074
|
+
installed: installed.has(name),
|
|
1075
|
+
}));
|
|
1076
|
+
const known = new Set(FRAMEWORK_SKILLS);
|
|
1077
|
+
for (const entry of catalogEntries) {
|
|
1078
|
+
known.add(entry.name);
|
|
1079
|
+
options.push({
|
|
1080
|
+
name: entry.name,
|
|
1081
|
+
description: entry.description,
|
|
1082
|
+
kind: entry.category,
|
|
1083
|
+
installed: installed.has(entry.name),
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
for (const name of [...installed].sort()) {
|
|
1087
|
+
if (!known.has(name) && isSpecsfySkill(name)) {
|
|
1088
|
+
options.push({
|
|
1089
|
+
name,
|
|
1090
|
+
description: "Skill Specsfy instalada fora do catálogo atual.",
|
|
1091
|
+
kind: "instalada",
|
|
1092
|
+
installed: true,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const categories = Object.keys(CATEGORY_LABELS);
|
|
1097
|
+
return options.sort((left, right) => {
|
|
1098
|
+
const category = categories.indexOf(left.kind) - categories.indexOf(right.kind);
|
|
1099
|
+
return category || left.name.localeCompare(right.name);
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
/** Retorna o plano explícito de uma skill sem executar mudanças. */
|
|
1103
|
+
export function skillPlanLabel(name, installed, selected) {
|
|
1104
|
+
if (selected.has(name))
|
|
1105
|
+
return installed.has(name) ? "Manter" : "Instalar";
|
|
1106
|
+
return installed.has(name) ? "Remover" : "Ignorar";
|
|
1107
|
+
}
|
|
1108
|
+
/** Filtra o catálogo com a mesma regra usada pela busca e pelos botões. */
|
|
1109
|
+
export function filterSkillOptions(options, filter, queryValue, installed, detected) {
|
|
1110
|
+
const query = queryValue.trim().toLocaleLowerCase("pt-BR");
|
|
1111
|
+
return options.filter((option) => {
|
|
1112
|
+
const searchable = `${option.name} ${option.description} ${option.kind}`.toLocaleLowerCase("pt-BR");
|
|
1113
|
+
if (query && !searchable.includes(query))
|
|
1114
|
+
return false;
|
|
1115
|
+
if (filter === "installed" && !installed.has(option.name))
|
|
1116
|
+
return false;
|
|
1117
|
+
if (filter === "detected" && !detected.has(option.name))
|
|
1118
|
+
return false;
|
|
1119
|
+
return true;
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
/** Formata o painel lateral da skill selecionada. */
|
|
1123
|
+
export function formatSkillDetail(option, installed, selected, detected) {
|
|
1124
|
+
const states = [installed.has(option.name) ? "Instalada" : "Não instalada"];
|
|
1125
|
+
if (detected.has(option.name))
|
|
1126
|
+
states.push("Recomendada");
|
|
1127
|
+
return (`${friendlySkillName(option.name)}\n` +
|
|
1128
|
+
`Plano: ${skillPlanLabel(option.name, installed, selected)} · ` +
|
|
1129
|
+
`Estado: ${states.join(" · ")}\n` +
|
|
1130
|
+
`Categoria: ${CATEGORY_LABELS[option.kind] ?? titleCase(option.kind)}\n` +
|
|
1131
|
+
`${option.description}\n\nID: ${option.name}`);
|
|
1132
|
+
}
|
|
1133
|
+
function skillStateLabel(name, installed, detected) {
|
|
1134
|
+
const states = [installed.has(name) ? "Instalada" : "Disponível"];
|
|
1135
|
+
if (detected.has(name))
|
|
1136
|
+
states.push("Recomendada");
|
|
1137
|
+
return states.join(" · ");
|
|
1138
|
+
}
|
|
1139
|
+
function friendlySkillName(name) {
|
|
1140
|
+
const baseNames = {
|
|
1141
|
+
"specsfy-01-inbox": "01 · Inbox",
|
|
1142
|
+
"specsfy-02-backlog": "02 · Backlog",
|
|
1143
|
+
"specsfy-03-specify": "03 · Especificar",
|
|
1144
|
+
"specsfy-04-validate": "04 · Validar",
|
|
1145
|
+
"specsfy-05-tasks": "05 · Tarefas",
|
|
1146
|
+
"specsfy-06-tdd-bdd": "06 · TDD orientado por BDD",
|
|
1147
|
+
"specsfy-07-implement": "07 · Implementar",
|
|
1148
|
+
"specsfy-update-spec": "Atualizar especificação",
|
|
1149
|
+
"specsfy-progress": "Progresso",
|
|
1150
|
+
"specsfy-setup": "Setup",
|
|
1151
|
+
"specsfy-documentator": "Documentator",
|
|
1152
|
+
};
|
|
1153
|
+
if (baseNames[name])
|
|
1154
|
+
return baseNames[name];
|
|
1155
|
+
return titleCase(name
|
|
1156
|
+
.replace(/^specsfy-(?:aux|specialist)-/u, "")
|
|
1157
|
+
.replaceAll("-", " "));
|
|
1158
|
+
}
|
|
1159
|
+
function isSpecsfySkill(name) {
|
|
1160
|
+
return (FRAMEWORK_SKILLS.includes(name) ||
|
|
1161
|
+
DOCUMENTATION_SKILLS.includes(name) ||
|
|
1162
|
+
name.startsWith("specsfy-aux-") ||
|
|
1163
|
+
name.startsWith("specsfy-specialist-"));
|
|
1164
|
+
}
|
|
1165
|
+
/** Formata o resumo estável de uma execução de testes. */
|
|
1166
|
+
export function formatTestRun(result) {
|
|
1167
|
+
return (`${result.exit_code === 0 ? "PASSOU" : "FALHOU"}\n\n` +
|
|
1168
|
+
`Runner: ${result.command.label}\n` +
|
|
1169
|
+
`Comando: ${result.command.display}\n` +
|
|
1170
|
+
`Projeto: ${result.command.cwd}\n` +
|
|
1171
|
+
`Duração: ${result.duration_seconds.toFixed(2)}s\n` +
|
|
1172
|
+
`Exit code: ${result.exit_code}\n\n` +
|
|
1173
|
+
(result.summary_lines.join("\n") || "O runner não emitiu um resumo."));
|
|
1174
|
+
}
|
|
1175
|
+
function titleCase(value) {
|
|
1176
|
+
return value.replace(/\b\p{L}/gu, (letter) => letter.toUpperCase());
|
|
1177
|
+
}
|
|
1178
|
+
/** Constrói a barra de progresso textual usada nos cards e tabelas. */
|
|
1179
|
+
export function progressBar(percent, width = 10) {
|
|
1180
|
+
const filled = Math.round((percent * width) / 100);
|
|
1181
|
+
return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
|
|
1182
|
+
}
|
|
1183
|
+
/** Preserva cada metainformação do backlog em sua própria linha no Markdown. */
|
|
1184
|
+
export function formatBacklogPreview(content) {
|
|
1185
|
+
return content
|
|
1186
|
+
.split(/\r?\n/u)
|
|
1187
|
+
.map((line) => line.startsWith("**") && line.includes("**:") && !line.endsWith(" ")
|
|
1188
|
+
? `${line} `
|
|
1189
|
+
: line)
|
|
1190
|
+
.join("\n");
|
|
1191
|
+
}
|
|
1192
|
+
function errorMessage(error) {
|
|
1193
|
+
return error instanceof Error ? error.message : String(error);
|
|
1194
|
+
}
|
|
1195
|
+
function selectedIndex(list) {
|
|
1196
|
+
return list.selected;
|
|
1197
|
+
}
|
|
1198
|
+
function renderMarkdown(content) {
|
|
1199
|
+
return String(marked.parse(content));
|
|
1200
|
+
}
|
|
1201
|
+
function buttonStyle(primary = false) {
|
|
1202
|
+
return {
|
|
1203
|
+
fg: "white",
|
|
1204
|
+
bg: primary ? "blue" : "black",
|
|
1205
|
+
border: { fg: primary ? "cyan" : "gray" },
|
|
1206
|
+
focus: { fg: "white", bg: "blue", border: { fg: "cyan" } },
|
|
1207
|
+
hover: { fg: "white", bg: "blue", border: { fg: "cyan" } },
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
function tabStyle(active) {
|
|
1211
|
+
return {
|
|
1212
|
+
fg: active ? "black" : "gray",
|
|
1213
|
+
bg: active ? "cyan" : "black",
|
|
1214
|
+
bold: active,
|
|
1215
|
+
focus: { fg: "white", bg: "blue" },
|
|
1216
|
+
hover: { fg: "white", bg: "blue" },
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
//# sourceMappingURL=tui.js.map
|