@promovaweb/specsfy 0.7.0 → 0.9.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/dist/tui.js CHANGED
@@ -14,7 +14,7 @@ import { resolvePath } from "./filesystem.js";
14
14
  import { AUXILIARY_SKILLS, DOCUMENTATION_SKILLS, FRAMEWORK_SKILLS, SkillInstaller, } from "./installer.js";
15
15
  import { scanSpecs, specsFingerprint, summarizeSpecs, } from "./progress.js";
16
16
  import { runProjectTests } from "./project-testing.js";
17
- import { ensureSkillsLock, skillsLockFingerprint, } from "./skill-lock.js";
17
+ import { ensureSkillsLock, skillsLockFingerprint } from "./skill-lock.js";
18
18
  import { VERSION } from "./version.js";
19
19
  marked.use(markedTerminal({
20
20
  reflowText: true,
@@ -87,6 +87,30 @@ export const TUI_TABS = [
87
87
  { id: "skills", label: "Skills", key: "C-k" },
88
88
  { id: "about", label: "Sobre", key: "C-o" },
89
89
  ];
90
+ /**
91
+ * Paleta semântica da TUI, derivada dos tokens oficiais do dark mode.
92
+ *
93
+ * Os pares de texto, seleção e foco mantêm contraste mensurável sem depender
94
+ * das cores configuráveis de 16 posições do terminal da pessoa usuária.
95
+ */
96
+ export const TUI_THEME = {
97
+ background: "#000A0E",
98
+ surface: "#001117",
99
+ surfaceRaised: "#03212A",
100
+ text: "#F2F8F9",
101
+ textMuted: "#B2C6CE",
102
+ border: "#5F7D8C",
103
+ accent: "#C4B5FD",
104
+ activeBackground: "#5EEDE1",
105
+ activeText: "#001117",
106
+ selectedBackground: "#6D28D9",
107
+ selectedText: "#F2F8F9",
108
+ primaryBackground: "#15626A",
109
+ primaryText: "#F2F8F9",
110
+ focusBackground: "#5EEDE1",
111
+ focusText: "#001117",
112
+ warning: "#FCD34D",
113
+ };
90
114
  /** Dashboard terminal principal. */
91
115
  export class SpecsfyTui {
92
116
  project;
@@ -95,6 +119,7 @@ export class SpecsfyTui {
95
119
  #status;
96
120
  #projectInput;
97
121
  #modal;
122
+ #modalFocus;
98
123
  #lastBackAt = 0;
99
124
  #tabButtons = new Map();
100
125
  #activeTab = "home";
@@ -105,6 +130,8 @@ export class SpecsfyTui {
105
130
  #catalogError = "";
106
131
  #installed = new Set();
107
132
  #selected = new Set();
133
+ #selectionDirty = false;
134
+ #loaded = false;
108
135
  #detected = new Set();
109
136
  #skillFilter = "all";
110
137
  #skillQuery = "";
@@ -112,10 +139,13 @@ export class SpecsfyTui {
112
139
  #suppressSkillToggleUntil = 0;
113
140
  #fingerprints = { specs: "", backlogs: "", lock: "" };
114
141
  #poller;
142
+ #refreshPromise;
115
143
  #testRunning = false;
116
144
  #testResultTab = "summary";
117
145
  #testSummary = "Nenhuma execução nesta sessão.";
118
146
  #testOutput = [];
147
+ #testSummaryPanel;
148
+ #testOutputPanel;
119
149
  constructor(project = process.cwd()) {
120
150
  this.project = resolvePath(project);
121
151
  }
@@ -134,6 +164,10 @@ export class SpecsfyTui {
134
164
  });
135
165
  await this.start({ screen });
136
166
  await new Promise((resolve) => {
167
+ if (screenDestroyed(screen)) {
168
+ resolve();
169
+ return;
170
+ }
137
171
  screen.once("destroy", () => {
138
172
  resolve();
139
173
  });
@@ -146,6 +180,9 @@ export class SpecsfyTui {
146
180
  * terminais virtuais, sem manter um processo pendurado durante os testes.
147
181
  */
148
182
  async start(options = {}) {
183
+ if (this.#screen && !screenDestroyed(this.#screen)) {
184
+ throw new Error("a TUI já foi iniciada nesta instância");
185
+ }
149
186
  const screen = options.screen ??
150
187
  blessed.screen({
151
188
  smartCSR: true,
@@ -163,13 +200,20 @@ export class SpecsfyTui {
163
200
  if (this.#poller)
164
201
  clearInterval(this.#poller);
165
202
  this.#poller = undefined;
203
+ this.#refreshPromise = undefined;
204
+ this.#modal = undefined;
205
+ this.#modalFocus = undefined;
166
206
  });
167
207
  this.showStatus("Carregando projeto…");
168
208
  await this.refreshAll(!options.catalog);
169
209
  this.showTab("home");
170
210
  if (options.watch !== false) {
171
211
  const config = await loadConfig(this.project);
172
- this.#poller = setInterval(() => void this.refreshIfChanged(), config.watch_interval * 1000);
212
+ if (screenDestroyed(screen))
213
+ return;
214
+ this.#poller = setInterval(() => void this.refreshIfChanged().catch((error) => {
215
+ this.showStatus(`Erro ao atualizar automaticamente: ${errorMessage(error)}`);
216
+ }), config.watch_interval * 1000);
173
217
  }
174
218
  }
175
219
  mountShell(screen) {
@@ -181,7 +225,11 @@ export class SpecsfyTui {
181
225
  height: 1,
182
226
  align: "center",
183
227
  content: "Specsfy — Dashboard de specs e skills",
184
- style: { fg: "white", bg: "black", bold: true },
228
+ style: {
229
+ fg: TUI_THEME.text,
230
+ bg: TUI_THEME.selectedBackground,
231
+ bold: true,
232
+ },
185
233
  });
186
234
  this.#projectInput = blessed.textbox({
187
235
  parent: screen,
@@ -195,10 +243,10 @@ export class SpecsfyTui {
195
243
  keys: true,
196
244
  mouse: true,
197
245
  style: {
198
- fg: "white",
199
- bg: "black",
200
- border: { fg: "gray" },
201
- focus: { border: { fg: "cyan" } },
246
+ fg: TUI_THEME.text,
247
+ bg: TUI_THEME.surface,
248
+ border: { fg: TUI_THEME.border },
249
+ focus: { border: { fg: TUI_THEME.focusBackground } },
202
250
  },
203
251
  });
204
252
  this.#projectInput.on("submit", (value) => {
@@ -238,10 +286,14 @@ export class SpecsfyTui {
238
286
  mouse: true,
239
287
  keys: true,
240
288
  style: {
241
- fg: "gray",
242
- bg: "black",
243
- focus: { fg: "white", bg: "blue", bold: true },
244
- hover: { fg: "white", bg: "blue" },
289
+ fg: TUI_THEME.textMuted,
290
+ bg: TUI_THEME.background,
291
+ focus: {
292
+ fg: TUI_THEME.focusText,
293
+ bg: TUI_THEME.focusBackground,
294
+ bold: true,
295
+ },
296
+ hover: { fg: TUI_THEME.focusText, bg: TUI_THEME.focusBackground },
245
297
  },
246
298
  });
247
299
  button.on("press", () => this.showTab(id));
@@ -254,7 +306,7 @@ export class SpecsfyTui {
254
306
  left: 0,
255
307
  width: "100%",
256
308
  orientation: "horizontal",
257
- style: { fg: "gray" },
309
+ style: { fg: TUI_THEME.border },
258
310
  });
259
311
  this.#body = blessed.box({
260
312
  parent: screen,
@@ -262,7 +314,7 @@ export class SpecsfyTui {
262
314
  left: 1,
263
315
  right: 1,
264
316
  bottom: 4,
265
- style: { fg: "white", bg: "black" },
317
+ style: { fg: TUI_THEME.text, bg: TUI_THEME.background },
266
318
  });
267
319
  this.#status = blessed.box({
268
320
  parent: screen,
@@ -271,7 +323,7 @@ export class SpecsfyTui {
271
323
  width: "100%",
272
324
  height: 1,
273
325
  padding: { left: 1 },
274
- style: { fg: "gray", bg: "black" },
326
+ style: { fg: TUI_THEME.textMuted, bg: TUI_THEME.surface },
275
327
  });
276
328
  blessed.box({
277
329
  parent: screen,
@@ -281,7 +333,7 @@ export class SpecsfyTui {
281
333
  height: 1,
282
334
  content: " ^ = Ctrl · Tab/Shift+Tab: foco · Setas: navegar · " +
283
335
  "Espaço: abrir spec/alternar skill · Esc: voltar · Mouse: disponível",
284
- style: { fg: "cyan", bg: "black" },
336
+ style: { fg: TUI_THEME.accent, bg: TUI_THEME.background },
285
337
  });
286
338
  blessed.box({
287
339
  parent: screen,
@@ -290,7 +342,7 @@ export class SpecsfyTui {
290
342
  width: "100%",
291
343
  height: 1,
292
344
  content: " ^Q Sair Esc Voltar",
293
- style: { fg: "white", bg: "black", bold: true },
345
+ style: { fg: TUI_THEME.text, bg: TUI_THEME.surface, bold: true },
294
346
  });
295
347
  }
296
348
  bindKeys(screen) {
@@ -339,12 +391,15 @@ export class SpecsfyTui {
339
391
  if (!body || !this.#screen)
340
392
  return;
341
393
  for (const [id, button] of this.#tabButtons) {
342
- button.style.bg = id === tab ? "cyan" : "black";
343
- button.style.fg = id === tab ? "black" : "gray";
394
+ button.style.bg =
395
+ id === tab ? TUI_THEME.activeBackground : TUI_THEME.background;
396
+ button.style.fg = id === tab ? TUI_THEME.activeText : TUI_THEME.textMuted;
344
397
  button.style.bold = id === tab;
345
398
  }
346
399
  for (const child of [...body.children])
347
400
  child.destroy();
401
+ this.#testSummaryPanel = undefined;
402
+ this.#testOutputPanel = undefined;
348
403
  if (tab === "home")
349
404
  this.renderHome(body);
350
405
  else if (tab === "backlogs")
@@ -357,7 +412,24 @@ export class SpecsfyTui {
357
412
  this.renderSkills(body);
358
413
  else
359
414
  this.renderAbout(body);
360
- this.#screen.render();
415
+ this.focusActiveTab();
416
+ if (!screenDestroyed(this.#screen))
417
+ this.#screen.render();
418
+ }
419
+ /** Recupera um alvo navegável quando um painel foi recriado ou fechado. */
420
+ focusActiveTab() {
421
+ const screen = this.#screen;
422
+ if (!screen || screenDestroyed(screen))
423
+ return;
424
+ const focused = screen.focused;
425
+ if (focused && !focused.detached && focused.visible)
426
+ return;
427
+ this.#tabButtons.get(this.#activeTab)?.focus();
428
+ }
429
+ /** Renderiza somente enquanto a tela ainda pertence ao ciclo atual. */
430
+ renderScreen() {
431
+ if (this.#screen && !screenDestroyed(this.#screen))
432
+ this.#screen.render();
361
433
  }
362
434
  /**
363
435
  * Retorna um nível por vez: fecha modal, limpa busca e volta ao Home.
@@ -373,7 +445,13 @@ export class SpecsfyTui {
373
445
  if (this.#modal) {
374
446
  this.#modal.destroy();
375
447
  this.#modal = undefined;
376
- this.#screen?.render();
448
+ const focus = this.#modalFocus;
449
+ this.#modalFocus = undefined;
450
+ if (focus && !focus.detached && focus.visible)
451
+ focus.focus();
452
+ else
453
+ this.focusActiveTab();
454
+ this.renderScreen();
377
455
  return;
378
456
  }
379
457
  if (this.#activeTab === "skills" && this.#skillQuery) {
@@ -387,24 +465,24 @@ export class SpecsfyTui {
387
465
  renderHome(body) {
388
466
  const summary = summarizeSpecs(this.#specs);
389
467
  const cards = [
390
- ["Specs", String(summary.total_specs), "#173e67", "#2f81f7"],
468
+ ["Specs", String(summary.total_specs), "#021C26", "#5EEDE1"],
391
469
  [
392
470
  `Tarefas · ${summary.pending_tasks} pendentes`,
393
471
  `${summary.completed_tasks}/${summary.total_tasks}`,
394
- "#3f2d63",
395
- "#a371f7",
472
+ "#2E1065",
473
+ "#C4B5FD",
396
474
  ],
397
475
  [
398
476
  `Itens · ${summary.pending_items} pendentes`,
399
477
  `${summary.completed_items}/${summary.total_items}`,
400
- "#174b43",
401
- "#2ea043",
478
+ "#03212A",
479
+ "#37E1D0",
402
480
  ],
403
481
  [
404
482
  "",
405
483
  `${summary.percent}%\n${progressBar(summary.percent, 16)}`,
406
- "#4a381c",
407
- "#d29922",
484
+ "#001117",
485
+ TUI_THEME.warning,
408
486
  ],
409
487
  ];
410
488
  cards.forEach(([label, value, background, border], index) => {
@@ -419,7 +497,7 @@ export class SpecsfyTui {
419
497
  valign: "middle",
420
498
  content: `{bold}${value}{/bold}${label ? `\n${label}` : ""}`,
421
499
  tags: true,
422
- style: { fg: "white", bg: background, border: { fg: border } },
500
+ style: { fg: TUI_THEME.text, bg: background, border: { fg: border } },
423
501
  });
424
502
  });
425
503
  blessed.box({
@@ -433,7 +511,11 @@ export class SpecsfyTui {
433
511
  valign: "middle",
434
512
  content: "Visão consolidada do andamento das especificações. " +
435
513
  "Os números são recalculados quando os arquivos mudam.",
436
- style: { fg: "white", border: { fg: "gray" } },
514
+ style: {
515
+ fg: TUI_THEME.text,
516
+ bg: TUI_THEME.surface,
517
+ border: { fg: TUI_THEME.border },
518
+ },
437
519
  });
438
520
  }
439
521
  renderBacklogs(body) {
@@ -451,8 +533,8 @@ export class SpecsfyTui {
451
533
  keys: true,
452
534
  vi: true,
453
535
  mouse: true,
454
- scrollbar: { ch: " ", track: { bg: "gray" }, style: { bg: "cyan" } },
455
- style: { border: { fg: "gray" }, focus: { border: { fg: "yellow" } } },
536
+ scrollbar: terminalScrollbar(),
537
+ style: focusablePanelStyle(),
456
538
  });
457
539
  const list = blessed.list({
458
540
  parent: body,
@@ -468,18 +550,22 @@ export class SpecsfyTui {
468
550
  mouse: true,
469
551
  tags: false,
470
552
  style: {
471
- selected: { fg: "white", bg: "blue", bold: true },
472
- item: { fg: "white" },
473
- border: { fg: "gray" },
474
- focus: { border: { fg: "yellow" } },
553
+ ...focusablePanelStyle(),
554
+ selected: {
555
+ fg: TUI_THEME.selectedText,
556
+ bg: TUI_THEME.selectedBackground,
557
+ bold: true,
558
+ },
559
+ item: { fg: TUI_THEME.text, bg: TUI_THEME.background },
475
560
  },
476
561
  });
477
562
  const updatePreview = () => {
478
563
  const item = this.#backlogs[selectedIndex(list)];
479
564
  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();
565
+ preview.setContent(item
566
+ ? renderMarkdown(formatBacklogPreview(item.content))
567
+ : "Crie arquivos em specs/backlog/<NNNN>-<slug>.md para visualizá-los aqui.");
568
+ this.renderScreen();
483
569
  };
484
570
  list.on("select item", updatePreview);
485
571
  list.on("keypress", updatePreview);
@@ -495,7 +581,7 @@ export class SpecsfyTui {
495
581
  width: "100%",
496
582
  height: "100%",
497
583
  border: "line",
498
- style: { border: { fg: "gray" } },
584
+ style: { border: { fg: TUI_THEME.border } },
499
585
  });
500
586
  blessed.box({
501
587
  parent: panel,
@@ -504,7 +590,11 @@ export class SpecsfyTui {
504
590
  width: "100%-2",
505
591
  height: 1,
506
592
  content: heading,
507
- style: { fg: "white", bg: "cyan", bold: true },
593
+ style: {
594
+ fg: TUI_THEME.activeText,
595
+ bg: TUI_THEME.activeBackground,
596
+ bold: true,
597
+ },
508
598
  });
509
599
  const list = blessed.list({
510
600
  parent: panel,
@@ -522,8 +612,12 @@ export class SpecsfyTui {
522
612
  vi: true,
523
613
  mouse: true,
524
614
  style: {
525
- selected: { fg: "white", bg: "blue", bold: true },
526
- item: { fg: "white" },
615
+ selected: {
616
+ fg: TUI_THEME.selectedText,
617
+ bg: TUI_THEME.selectedBackground,
618
+ bold: true,
619
+ },
620
+ item: { fg: TUI_THEME.text, bg: TUI_THEME.background },
527
621
  },
528
622
  });
529
623
  const open = () => {
@@ -558,10 +652,16 @@ export class SpecsfyTui {
558
652
  vi: true,
559
653
  mouse: true,
560
654
  padding: { left: 1, right: 1 },
561
- scrollbar: { ch: " ", track: { bg: "gray" }, style: { bg: "cyan" } },
562
- style: { fg: "white", bg: "black", border: { fg: "cyan" } },
655
+ scrollbar: terminalScrollbar(),
656
+ style: {
657
+ fg: TUI_THEME.text,
658
+ bg: TUI_THEME.background,
659
+ border: { fg: TUI_THEME.focusBackground },
660
+ },
563
661
  });
662
+ this.#modalFocus = screen.focused;
564
663
  this.#modal = modal;
664
+ screen.focusPush(modal);
565
665
  modal.focus();
566
666
  screen.render();
567
667
  }
@@ -576,12 +676,7 @@ export class SpecsfyTui {
576
676
  align: "center",
577
677
  mouse: true,
578
678
  keys: true,
579
- style: {
580
- fg: "white",
581
- bg: "blue",
582
- focus: { bg: "cyan", fg: "black" },
583
- hover: { bg: "cyan", fg: "black" },
584
- },
679
+ style: buttonStyle(true),
585
680
  });
586
681
  button.on("press", () => void this.runTests());
587
682
  blessed.box({
@@ -629,7 +724,7 @@ export class SpecsfyTui {
629
724
  this.showTab("tests");
630
725
  });
631
726
  if (this.#testResultTab === "summary") {
632
- blessed.box({
727
+ this.#testSummaryPanel = blessed.box({
633
728
  parent: body,
634
729
  top: 6,
635
730
  left: 1,
@@ -643,14 +738,14 @@ export class SpecsfyTui {
643
738
  vi: true,
644
739
  mouse: true,
645
740
  scrollable: true,
646
- style: { border: { fg: "gray" }, focus: { border: { fg: "yellow" } } },
741
+ style: focusablePanelStyle(),
647
742
  });
648
743
  }
649
744
  else {
650
745
  // O blessed.log agenda a rolagem depois de setContent e acessa widgets
651
746
  // destruídos quando a saída ao vivo recria a aba. A caixa preserva a
652
747
  // rolagem sem deixar callbacks pendentes entre duas renderizações.
653
- blessed.box({
748
+ this.#testOutputPanel = blessed.box({
654
749
  parent: body,
655
750
  top: 6,
656
751
  left: 1,
@@ -666,15 +761,15 @@ export class SpecsfyTui {
666
761
  alwaysScroll: true,
667
762
  tags: false,
668
763
  content: this.#testOutput.join("\n"),
669
- style: { border: { fg: "gray" }, focus: { border: { fg: "yellow" } } },
764
+ style: focusablePanelStyle(),
670
765
  });
671
766
  }
672
767
  button.focus();
673
768
  }
674
769
  async runTests() {
675
- this.showTab("tests");
676
- if (this.#testRunning)
770
+ if (this.#modal || this.#testRunning)
677
771
  return;
772
+ this.showTab("tests");
678
773
  this.#testRunning = true;
679
774
  this.#testResultTab = "output";
680
775
  this.#testOutput = [];
@@ -684,27 +779,32 @@ export class SpecsfyTui {
684
779
  try {
685
780
  const result = await runProjectTests(this.project, (line) => {
686
781
  this.#testOutput.push(line);
782
+ this.#testOutputPanel?.setContent(this.#testOutput.join("\n"));
687
783
  if (this.#activeTab === "tests" && this.#testResultTab === "output") {
688
- this.showTab("tests");
784
+ this.renderScreen();
689
785
  }
690
786
  });
691
787
  this.#testSummary = formatTestRun(result);
788
+ this.#testSummaryPanel?.setContent(this.#testSummary);
692
789
  this.#testResultTab = "summary";
693
790
  this.showStatus(`${result.exit_code === 0 ? "Testes passaram" : "Testes falharam"} · ` +
694
791
  `${result.duration_seconds.toFixed(2)}s · exit code ${result.exit_code}`);
695
- this.showTab("tests");
792
+ if (this.#activeTab === "tests")
793
+ this.showTab("tests");
696
794
  }
697
795
  catch (error) {
698
796
  const message = `Erro ao executar testes: ${errorMessage(error)}`;
699
797
  this.#testSummary = message;
798
+ this.#testSummaryPanel?.setContent(message);
700
799
  this.#testOutput.push(message);
701
800
  this.#testResultTab = "summary";
702
801
  this.showStatus(message);
703
- this.showTab("tests");
802
+ if (this.#activeTab === "tests")
803
+ this.showTab("tests");
704
804
  }
705
805
  finally {
706
806
  this.#testRunning = false;
707
- this.#screen?.render();
807
+ this.renderScreen();
708
808
  }
709
809
  }
710
810
  renderSkills(body) {
@@ -716,8 +816,10 @@ export class SpecsfyTui {
716
816
  height: 1,
717
817
  content: `${this.project}/skills-lock.json · ` +
718
818
  `${this.#installed.size} skill(s) Specsfy instalada(s)` +
719
- (this.#catalogError ? ` · catálogo indisponível: ${this.#catalogError}` : ""),
720
- style: { fg: "gray" },
819
+ (this.#catalogError
820
+ ? ` · catálogo indisponível: ${this.#catalogError}`
821
+ : ""),
822
+ style: { fg: TUI_THEME.textMuted },
721
823
  });
722
824
  const search = blessed.textbox({
723
825
  parent: body,
@@ -731,7 +833,7 @@ export class SpecsfyTui {
731
833
  inputOnFocus: false,
732
834
  keys: true,
733
835
  mouse: true,
734
- style: { border: { fg: "gray" }, focus: { border: { fg: "yellow" } } },
836
+ style: focusablePanelStyle(),
735
837
  });
736
838
  search.on("submit", (value) => {
737
839
  this.#skillQuery = String(value ?? "");
@@ -787,7 +889,7 @@ export class SpecsfyTui {
787
889
  height: 1,
788
890
  content: `${this.#selected.size} selecionada(s) · ${visible.length} visível(is) · ` +
789
891
  `${toInstall} para instalar · ${toRemove} para remover`,
790
- style: { fg: "white", bold: true },
892
+ style: { fg: TUI_THEME.text, bold: true },
791
893
  });
792
894
  const catalog = blessed.box({
793
895
  parent: body,
@@ -811,10 +913,13 @@ export class SpecsfyTui {
811
913
  vi: true,
812
914
  mouse: true,
813
915
  style: {
814
- selected: { fg: "white", bg: "blue", bold: true },
815
- item: { fg: "white" },
816
- border: { fg: "gray" },
817
- focus: { border: { fg: "yellow" } },
916
+ ...focusablePanelStyle(),
917
+ selected: {
918
+ fg: TUI_THEME.selectedText,
919
+ bg: TUI_THEME.selectedBackground,
920
+ bold: true,
921
+ },
922
+ item: { fg: TUI_THEME.text, bg: TUI_THEME.background },
818
923
  },
819
924
  });
820
925
  const detail = blessed.box({
@@ -830,7 +935,7 @@ export class SpecsfyTui {
830
935
  keys: true,
831
936
  vi: true,
832
937
  mouse: true,
833
- style: { border: { fg: "gray" }, focus: { border: { fg: "yellow" } } },
938
+ style: focusablePanelStyle(),
834
939
  });
835
940
  blessed.box({
836
941
  parent: catalog,
@@ -839,7 +944,11 @@ export class SpecsfyTui {
839
944
  width: "64%",
840
945
  height: 1,
841
946
  content: " PLANO SKILL CATEGORIA ESTADO ",
842
- style: { fg: "black", bg: "white", bold: true },
947
+ style: {
948
+ fg: TUI_THEME.activeText,
949
+ bg: TUI_THEME.activeBackground,
950
+ bold: true,
951
+ },
843
952
  });
844
953
  const updateDetail = () => {
845
954
  const option = visible[selectedIndex(list)];
@@ -847,7 +956,7 @@ export class SpecsfyTui {
847
956
  detail.setContent(option
848
957
  ? formatSkillDetail(option, this.#installed, this.#selected, this.#detected)
849
958
  : "Nenhuma skill visível.\n\nAjuste a busca ou os filtros para continuar.");
850
- this.#screen?.render();
959
+ this.renderScreen();
851
960
  };
852
961
  list.on("select item", updateDetail);
853
962
  list.on("keypress", () => setImmediate(updateDetail));
@@ -899,7 +1008,18 @@ export class SpecsfyTui {
899
1008
  "Ctrl+Q encerra a interface.",
900
1009
  });
901
1010
  }
902
- async refreshAll(reloadCatalog) {
1011
+ refreshAll(reloadCatalog) {
1012
+ if (this.#refreshPromise)
1013
+ return this.#refreshPromise;
1014
+ const promise = this.refreshAllInternal(reloadCatalog);
1015
+ const tracked = promise.finally(() => {
1016
+ if (this.#refreshPromise === tracked)
1017
+ this.#refreshPromise = undefined;
1018
+ });
1019
+ this.#refreshPromise = tracked;
1020
+ return tracked;
1021
+ }
1022
+ async refreshAllInternal(reloadCatalog) {
903
1023
  try {
904
1024
  const [specs, backlogs, lock] = await Promise.all([
905
1025
  scanSpecs(this.project),
@@ -908,8 +1028,8 @@ export class SpecsfyTui {
908
1028
  ]);
909
1029
  this.#specs = specs;
910
1030
  this.#backlogs = backlogs;
911
- this.#installed = new Set(Object.keys(lock.skills).filter(isSpecsfySkill));
912
- this.#selected = new Set(this.#installed);
1031
+ const installed = new Set(Object.keys(lock.skills).filter(isSpecsfySkill));
1032
+ this.#installed = installed;
913
1033
  if (reloadCatalog || !this.#catalog) {
914
1034
  try {
915
1035
  this.#catalog = await Catalog.fetch();
@@ -917,10 +1037,14 @@ export class SpecsfyTui {
917
1037
  this.#catalogError = "";
918
1038
  }
919
1039
  catch (error) {
920
- this.#catalogEntries = [];
921
1040
  this.#catalogError = errorMessage(error);
922
1041
  }
923
1042
  }
1043
+ const available = new Set(buildSkillOptions(this.#catalogEntries, installed).map((option) => option.name));
1044
+ this.#selected = this.#loaded && this.#selectionDirty
1045
+ ? new Set([...this.#selected].filter((name) => available.has(name)))
1046
+ : new Set(installed);
1047
+ this.#loaded = true;
924
1048
  this.#fingerprints = {
925
1049
  specs: await specsFingerprint(this.project),
926
1050
  backlogs: await backlogsFingerprint(this.project),
@@ -929,7 +1053,9 @@ export class SpecsfyTui {
929
1053
  const summary = summarizeSpecs(specs);
930
1054
  this.showStatus(`${this.project} · ${summary.completed_specs}/${summary.total_specs} specs completas · ` +
931
1055
  "atualização automática ativa" +
932
- (this.#catalogError ? ` · catálogo indisponível: ${this.#catalogError}` : ""));
1056
+ (this.#catalogError
1057
+ ? ` · catálogo indisponível: ${this.#catalogError}`
1058
+ : ""));
933
1059
  this.showTab(this.#activeTab);
934
1060
  }
935
1061
  catch (error) {
@@ -960,20 +1086,26 @@ export class SpecsfyTui {
960
1086
  }
961
1087
  selectFramework() {
962
1088
  this.#selected = new Set([...this.#selected, ...FRAMEWORK_SKILLS]);
1089
+ this.#selectionDirty = true;
963
1090
  this.showStatus("As skills do framework foram selecionadas. Use ^A para aplicar.");
964
1091
  this.showTab("skills");
965
1092
  }
966
1093
  async detectSkills() {
1094
+ if (this.#modal)
1095
+ return;
967
1096
  this.showTab("skills");
1097
+ const startedOnSkills = this.#activeTab === "skills";
968
1098
  try {
969
1099
  this.#catalog ??= await Catalog.fetch();
970
1100
  this.#catalogEntries = this.#catalog.entries;
971
1101
  const entries = await this.#catalog.detect(this.project);
972
1102
  this.#detected = new Set(entries.map((entry) => entry.name));
973
1103
  this.#selected = new Set([...this.#selected, ...this.#detected]);
1104
+ this.#selectionDirty = true;
974
1105
  this.#skillFilter = "detected";
975
1106
  this.showStatus(`Skills detectadas e selecionadas: ${[...this.#detected].sort().join(", ") || "nenhuma skill"}`);
976
- this.showTab("skills");
1107
+ if (startedOnSkills && this.#activeTab === "skills")
1108
+ this.showTab("skills");
977
1109
  }
978
1110
  catch (error) {
979
1111
  this.showStatus(`Erro: ${errorMessage(error)}`);
@@ -990,21 +1122,26 @@ export class SpecsfyTui {
990
1122
  else {
991
1123
  this.#selected.add(this.#selectedSkill);
992
1124
  }
1125
+ this.#selectionDirty = true;
993
1126
  this.showTab("skills");
994
1127
  }
995
1128
  selectVisible() {
996
1129
  this.#suppressSkillToggleUntil = Date.now() + 100;
997
1130
  for (const option of this.visibleSkills())
998
1131
  this.#selected.add(option.name);
1132
+ this.#selectionDirty = true;
999
1133
  this.showTab("skills");
1000
1134
  }
1001
1135
  clearVisible() {
1002
1136
  this.#suppressSkillToggleUntil = Date.now() + 100;
1003
1137
  for (const option of this.visibleSkills())
1004
1138
  this.#selected.delete(option.name);
1139
+ this.#selectionDirty = true;
1005
1140
  this.showTab("skills");
1006
1141
  }
1007
1142
  async applySkills() {
1143
+ if (this.#modal)
1144
+ return;
1008
1145
  this.showTab("skills");
1009
1146
  try {
1010
1147
  const selected = new Set([...this.#selected].filter(isSpecsfySkill));
@@ -1034,6 +1171,7 @@ export class SpecsfyTui {
1034
1171
  }
1035
1172
  if (toRemove.length)
1036
1173
  changed.push(...(await installer.remove(toRemove.sort())));
1174
+ this.#selectionDirty = false;
1037
1175
  await this.refreshAll(false);
1038
1176
  this.showStatus(changed.length
1039
1177
  ? `Seleção aplicada: ${changed.length} alteração(ões).`
@@ -1044,6 +1182,8 @@ export class SpecsfyTui {
1044
1182
  }
1045
1183
  }
1046
1184
  async updateSkills() {
1185
+ if (this.#modal)
1186
+ return;
1047
1187
  this.showTab("skills");
1048
1188
  try {
1049
1189
  const changed = await (await SkillInstaller.create(this.project)).updateAll();
@@ -1058,7 +1198,7 @@ export class SpecsfyTui {
1058
1198
  }
1059
1199
  showStatus(message) {
1060
1200
  this.#status?.setContent(message);
1061
- this.#screen?.render();
1201
+ this.renderScreen();
1062
1202
  }
1063
1203
  }
1064
1204
  /** Cria as opções base, especialistas e instalações fora do catálogo atual. */
@@ -1066,7 +1206,8 @@ export function buildSkillOptions(catalogEntries, installed) {
1066
1206
  const options = FRAMEWORK_SKILLS.map((name) => ({
1067
1207
  name,
1068
1208
  description: BASE_DESCRIPTIONS[name] ?? "Skill do framework Specsfy.",
1069
- kind: AUXILIARY_SKILLS.includes(name) || name === "specsfy-setup"
1209
+ kind: AUXILIARY_SKILLS.includes(name) ||
1210
+ name === "specsfy-setup"
1070
1211
  ? "auxiliary"
1071
1212
  : DOCUMENTATION_SKILLS.includes(name)
1072
1213
  ? "documentation"
@@ -1152,9 +1293,7 @@ function friendlySkillName(name) {
1152
1293
  };
1153
1294
  if (baseNames[name])
1154
1295
  return baseNames[name];
1155
- return titleCase(name
1156
- .replace(/^specsfy-(?:aux|specialist)-/u, "")
1157
- .replaceAll("-", " "));
1296
+ return titleCase(name.replace(/^specsfy-(?:aux|specialist)-/u, "").replaceAll("-", " "));
1158
1297
  }
1159
1298
  function isSpecsfySkill(name) {
1160
1299
  return (FRAMEWORK_SKILLS.includes(name) ||
@@ -1200,20 +1339,49 @@ function renderMarkdown(content) {
1200
1339
  }
1201
1340
  function buttonStyle(primary = false) {
1202
1341
  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" } },
1342
+ fg: primary ? TUI_THEME.primaryText : TUI_THEME.text,
1343
+ bg: primary ? TUI_THEME.primaryBackground : TUI_THEME.surface,
1344
+ border: { fg: primary ? TUI_THEME.focusBackground : TUI_THEME.border },
1345
+ focus: {
1346
+ fg: TUI_THEME.focusText,
1347
+ bg: TUI_THEME.focusBackground,
1348
+ border: { fg: TUI_THEME.focusBackground },
1349
+ },
1350
+ hover: {
1351
+ fg: TUI_THEME.focusText,
1352
+ bg: TUI_THEME.focusBackground,
1353
+ border: { fg: TUI_THEME.focusBackground },
1354
+ },
1208
1355
  };
1209
1356
  }
1210
1357
  function tabStyle(active) {
1211
1358
  return {
1212
- fg: active ? "black" : "gray",
1213
- bg: active ? "cyan" : "black",
1359
+ fg: active ? TUI_THEME.activeText : TUI_THEME.textMuted,
1360
+ bg: active ? TUI_THEME.activeBackground : TUI_THEME.background,
1214
1361
  bold: active,
1215
- focus: { fg: "white", bg: "blue" },
1216
- hover: { fg: "white", bg: "blue" },
1362
+ focus: { fg: TUI_THEME.focusText, bg: TUI_THEME.focusBackground },
1363
+ hover: { fg: TUI_THEME.focusText, bg: TUI_THEME.focusBackground },
1217
1364
  };
1218
1365
  }
1366
+ /** Mantém borda e foco legíveis em painéis, inputs e regiões roláveis. */
1367
+ function focusablePanelStyle() {
1368
+ return {
1369
+ fg: TUI_THEME.text,
1370
+ bg: TUI_THEME.background,
1371
+ border: { fg: TUI_THEME.border },
1372
+ focus: { border: { fg: TUI_THEME.focusBackground } },
1373
+ };
1374
+ }
1375
+ /** Usa trilho discreto e indicador turquesa na rolagem dos painéis. */
1376
+ function terminalScrollbar() {
1377
+ return {
1378
+ ch: " ",
1379
+ track: { bg: TUI_THEME.surfaceRaised },
1380
+ style: { bg: TUI_THEME.focusBackground },
1381
+ };
1382
+ }
1383
+ /** Detecta o estado encerrado sem depender da tipagem incompleta do blessed. */
1384
+ function screenDestroyed(screen) {
1385
+ return Boolean(screen.destroyed);
1386
+ }
1219
1387
  //# sourceMappingURL=tui.js.map