specsmd 0.1.30 → 0.1.31

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.
@@ -30,6 +30,27 @@ function clearTerminalOutput(stream = process.stdout) {
30
30
  stream.write('\u001B[2J\u001B[3J\u001B[H');
31
31
  }
32
32
 
33
+ function createInkStdout(stream = process.stdout) {
34
+ if (!stream || typeof stream.write !== 'function') {
35
+ return stream;
36
+ }
37
+
38
+ return {
39
+ isTTY: true,
40
+ get columns() {
41
+ return stream.columns;
42
+ },
43
+ get rows() {
44
+ return stream.rows;
45
+ },
46
+ write: (...args) => stream.write(...args),
47
+ on: (...args) => (typeof stream.on === 'function' ? stream.on(...args) : undefined),
48
+ off: (...args) => (typeof stream.off === 'function' ? stream.off(...args) : undefined),
49
+ once: (...args) => (typeof stream.once === 'function' ? stream.once(...args) : undefined),
50
+ removeListener: (...args) => (typeof stream.removeListener === 'function' ? stream.removeListener(...args) : undefined)
51
+ };
52
+ }
53
+
33
54
  const FLOW_CONFIG = {
34
55
  fire: {
35
56
  markerDir: '.specs-fire',
@@ -45,6 +66,14 @@ const FLOW_CONFIG = {
45
66
  }
46
67
  };
47
68
 
69
+ function resolveRootPathForFlow(workspacePath, flow) {
70
+ const config = FLOW_CONFIG[flow];
71
+ if (!config) {
72
+ return workspacePath;
73
+ }
74
+ return path.join(workspacePath, config.markerDir);
75
+ }
76
+
48
77
  function formatStaticFlowText(flow, snapshot, error) {
49
78
  if (flow === 'fire') {
50
79
  return formatDashboardText({
@@ -95,7 +124,7 @@ function formatStaticFlowText(flow, snapshot, error) {
95
124
  return 'Unsupported flow.';
96
125
  }
97
126
 
98
- async function runFlowDashboard(options, flow) {
127
+ async function runFlowDashboard(options, flow, availableFlows = []) {
99
128
  const workspacePath = path.resolve(options.path || process.cwd());
100
129
  const config = FLOW_CONFIG[flow];
101
130
 
@@ -105,13 +134,28 @@ async function runFlowDashboard(options, flow) {
105
134
  return;
106
135
  }
107
136
 
108
- const rootPath = path.join(workspacePath, config.markerDir);
137
+ const flowIds = Array.from(new Set([
138
+ String(flow || '').toLowerCase(),
139
+ ...(Array.isArray(availableFlows) ? availableFlows.map((value) => String(value || '').toLowerCase()) : [])
140
+ ].filter(Boolean)));
141
+
109
142
  const watchEnabled = options.watch !== false;
110
143
  const refreshMs = parseRefreshMs(options.refreshMs);
144
+ const parseSnapshotForFlow = async (flowId) => {
145
+ const flowConfig = FLOW_CONFIG[flowId];
146
+ if (!flowConfig) {
147
+ return {
148
+ ok: false,
149
+ error: {
150
+ code: 'UNSUPPORTED_FLOW',
151
+ message: `Flow \"${flowId}\" is not supported.`
152
+ }
153
+ };
154
+ }
155
+ return flowConfig.parse(workspacePath);
156
+ };
111
157
 
112
- const parseSnapshot = async () => config.parse(workspacePath);
113
-
114
- const initialResult = await parseSnapshot();
158
+ const initialResult = await parseSnapshotForFlow(flow);
115
159
  clearTerminalOutput();
116
160
 
117
161
  if (!watchEnabled) {
@@ -134,10 +178,11 @@ async function runFlowDashboard(options, flow) {
134
178
  const App = createDashboardApp({
135
179
  React,
136
180
  ink,
137
- parseSnapshot,
181
+ parseSnapshotForFlow,
138
182
  workspacePath,
139
- rootPath,
140
183
  flow,
184
+ availableFlows: flowIds,
185
+ resolveRootPathForFlow: (flowId) => resolveRootPathForFlow(workspacePath, flowId),
141
186
  refreshMs,
142
187
  watchEnabled,
143
188
  initialSnapshot: initialResult.ok ? initialResult.snapshot : null,
@@ -145,7 +190,9 @@ async function runFlowDashboard(options, flow) {
145
190
  });
146
191
 
147
192
  const { waitUntilExit } = ink.render(React.createElement(App), {
148
- exitOnCtrlC: true
193
+ exitOnCtrlC: true,
194
+ stdout: createInkStdout(process.stdout),
195
+ stdin: process.stdin
149
196
  });
150
197
 
151
198
  await waitUntilExit();
@@ -173,7 +220,7 @@ async function run(options = {}) {
173
220
  console.warn(`Warning: ${detection.warning}`);
174
221
  }
175
222
 
176
- await runFlowDashboard(options, detection.flow);
223
+ await runFlowDashboard(options, detection.flow, detection.availableFlows);
177
224
  }
178
225
 
179
226
  module.exports = {
@@ -181,5 +228,6 @@ module.exports = {
181
228
  runFlowDashboard,
182
229
  parseRefreshMs,
183
230
  formatStaticFlowText,
184
- clearTerminalOutput
231
+ clearTerminalOutput,
232
+ createInkStdout
185
233
  };
@@ -1,5 +1,7 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
1
3
  const { createWatchRuntime } = require('../runtime/watch-runtime');
2
- const { createInitialUIState, cycleView, cycleViewBackward, cycleRunFilter } = require('./store');
4
+ const { createInitialUIState, cycleView, cycleViewBackward } = require('./store');
3
5
 
4
6
  function toDashboardError(error, defaultCode = 'DASHBOARD_ERROR') {
5
7
  if (!error) {
@@ -89,15 +91,41 @@ function truncate(value, width) {
89
91
  return `${text.slice(0, width - 3)}...`;
90
92
  }
91
93
 
94
+ function normalizePanelLine(line) {
95
+ if (line && typeof line === 'object' && !Array.isArray(line)) {
96
+ return {
97
+ text: typeof line.text === 'string' ? line.text : String(line.text ?? ''),
98
+ color: line.color,
99
+ bold: Boolean(line.bold)
100
+ };
101
+ }
102
+
103
+ return {
104
+ text: String(line ?? ''),
105
+ color: undefined,
106
+ bold: false
107
+ };
108
+ }
109
+
92
110
  function fitLines(lines, maxLines, width) {
93
- const safeLines = (Array.isArray(lines) ? lines : []).map((line) => truncate(line, width));
111
+ const safeLines = (Array.isArray(lines) ? lines : []).map((line) => {
112
+ const normalized = normalizePanelLine(line);
113
+ return {
114
+ ...normalized,
115
+ text: truncate(normalized.text, width)
116
+ };
117
+ });
94
118
 
95
119
  if (safeLines.length <= maxLines) {
96
120
  return safeLines;
97
121
  }
98
122
 
99
123
  const visible = safeLines.slice(0, Math.max(1, maxLines - 1));
100
- visible.push(truncate(`... +${safeLines.length - visible.length} more`, width));
124
+ visible.push({
125
+ text: truncate(`... +${safeLines.length - visible.length} more`, width),
126
+ color: 'gray',
127
+ bold: false
128
+ });
101
129
  return visible;
102
130
  }
103
131
 
@@ -138,11 +166,11 @@ function buildShortStats(snapshot, flow) {
138
166
  return `runs ${stats.activeRunsCount || 0}/${stats.completedRuns || 0} | intents ${stats.completedIntents || 0}/${stats.totalIntents || 0} | work ${stats.completedWorkItems || 0}/${stats.totalWorkItems || 0}`;
139
167
  }
140
168
 
141
- function buildHeaderLine(snapshot, flow, watchEnabled, watchStatus, lastRefreshAt, view, runFilter, width) {
169
+ function buildHeaderLine(snapshot, flow, watchEnabled, watchStatus, lastRefreshAt, view, width) {
142
170
  const projectName = snapshot?.project?.name || 'Unnamed project';
143
171
  const shortStats = buildShortStats(snapshot, flow);
144
172
 
145
- const line = `${flow.toUpperCase()} | ${projectName} | ${shortStats} | watch:${watchEnabled ? watchStatus : 'off'} | ${view}/${runFilter} | ${formatTime(lastRefreshAt)}`;
173
+ const line = `${flow.toUpperCase()} | ${projectName} | ${shortStats} | watch:${watchEnabled ? watchStatus : 'off'} | ${view} | ${formatTime(lastRefreshAt)}`;
146
174
 
147
175
  return truncate(line, width);
148
176
  }
@@ -233,25 +261,7 @@ function buildFireCurrentRunLines(snapshot, width) {
233
261
  return lines.map((line) => truncate(line, width));
234
262
  }
235
263
 
236
- function buildFireRunFilesLines(snapshot, width, icons) {
237
- const run = getCurrentRun(snapshot);
238
- if (!run) {
239
- return [truncate('No run files (no active run)', width)];
240
- }
241
-
242
- const files = ['run.md'];
243
- if (run.hasPlan) files.push('plan.md');
244
- if (run.hasTestReport) files.push('test-report.md');
245
- if (run.hasWalkthrough) files.push('walkthrough.md');
246
-
247
- return files.map((file) => truncate(`${icons.runFile} ${file}`, width));
248
- }
249
-
250
- function buildFirePendingLines(snapshot, runFilter, width) {
251
- if (runFilter === 'completed') {
252
- return [truncate('Hidden by run filter: completed', width)];
253
- }
254
-
264
+ function buildFirePendingLines(snapshot, width) {
255
265
  const pending = snapshot?.pendingItems || [];
256
266
  if (pending.length === 0) {
257
267
  return [truncate('No pending work items', width)];
@@ -263,11 +273,7 @@ function buildFirePendingLines(snapshot, runFilter, width) {
263
273
  });
264
274
  }
265
275
 
266
- function buildFireCompletedLines(snapshot, runFilter, width) {
267
- if (runFilter === 'active') {
268
- return [truncate('Hidden by run filter: active', width)];
269
- }
270
-
276
+ function buildFireCompletedLines(snapshot, width) {
271
277
  const completedRuns = snapshot?.completedRuns || [];
272
278
  if (completedRuns.length === 0) {
273
279
  return [truncate('No completed runs yet', width)];
@@ -406,25 +412,7 @@ function buildAidlcCurrentRunLines(snapshot, width) {
406
412
  return lines.map((line) => truncate(line, width));
407
413
  }
408
414
 
409
- function buildAidlcRunFilesLines(snapshot, width, icons) {
410
- const bolt = getCurrentBolt(snapshot);
411
- if (!bolt) {
412
- return [truncate('No bolt files (no active bolt)', width)];
413
- }
414
-
415
- const files = Array.isArray(bolt.files) ? bolt.files : [];
416
- if (files.length === 0) {
417
- return [truncate('No markdown files found in active bolt', width)];
418
- }
419
-
420
- return files.map((file) => truncate(`${icons.runFile} ${file}`, width));
421
- }
422
-
423
- function buildAidlcPendingLines(snapshot, runFilter, width) {
424
- if (runFilter === 'completed') {
425
- return [truncate('Hidden by run filter: completed', width)];
426
- }
427
-
415
+ function buildAidlcPendingLines(snapshot, width) {
428
416
  const pendingBolts = Array.isArray(snapshot?.pendingBolts) ? snapshot.pendingBolts : [];
429
417
  if (pendingBolts.length === 0) {
430
418
  return [truncate('No queued bolts', width)];
@@ -439,11 +427,7 @@ function buildAidlcPendingLines(snapshot, runFilter, width) {
439
427
  });
440
428
  }
441
429
 
442
- function buildAidlcCompletedLines(snapshot, runFilter, width) {
443
- if (runFilter === 'active') {
444
- return [truncate('Hidden by run filter: active', width)];
445
- }
446
-
430
+ function buildAidlcCompletedLines(snapshot, width) {
447
431
  const completedBolts = Array.isArray(snapshot?.completedBolts) ? snapshot.completedBolts : [];
448
432
  if (completedBolts.length === 0) {
449
433
  return [truncate('No completed bolts yet', width)];
@@ -550,29 +534,7 @@ function buildSimpleCurrentRunLines(snapshot, width) {
550
534
  return lines.map((line) => truncate(line, width));
551
535
  }
552
536
 
553
- function buildSimpleRunFilesLines(snapshot, width, icons) {
554
- const spec = getCurrentSpec(snapshot);
555
- if (!spec) {
556
- return [truncate('No spec files (no active spec)', width)];
557
- }
558
-
559
- const files = [];
560
- if (spec.hasRequirements) files.push('requirements.md');
561
- if (spec.hasDesign) files.push('design.md');
562
- if (spec.hasTasks) files.push('tasks.md');
563
-
564
- if (files.length === 0) {
565
- return [truncate('No files found in active spec folder', width)];
566
- }
567
-
568
- return files.map((file) => truncate(`${icons.runFile} ${file}`, width));
569
- }
570
-
571
- function buildSimplePendingLines(snapshot, runFilter, width) {
572
- if (runFilter === 'completed') {
573
- return [truncate('Hidden by run filter: completed', width)];
574
- }
575
-
537
+ function buildSimplePendingLines(snapshot, width) {
576
538
  const pendingSpecs = Array.isArray(snapshot?.pendingSpecs) ? snapshot.pendingSpecs : [];
577
539
  if (pendingSpecs.length === 0) {
578
540
  return [truncate('No pending specs', width)];
@@ -583,11 +545,7 @@ function buildSimplePendingLines(snapshot, runFilter, width) {
583
545
  );
584
546
  }
585
547
 
586
- function buildSimpleCompletedLines(snapshot, runFilter, width) {
587
- if (runFilter === 'active') {
588
- return [truncate('Hidden by run filter: active', width)];
589
- }
590
-
548
+ function buildSimpleCompletedLines(snapshot, width) {
591
549
  const completedSpecs = Array.isArray(snapshot?.completedSpecs) ? snapshot.completedSpecs : [];
592
550
  if (completedSpecs.length === 0) {
593
551
  return [truncate('No completed specs yet', width)];
@@ -659,37 +617,26 @@ function buildCurrentRunLines(snapshot, width, flow) {
659
617
  return buildFireCurrentRunLines(snapshot, width);
660
618
  }
661
619
 
662
- function buildRunFilesLines(snapshot, width, icons, flow) {
620
+ function buildPendingLines(snapshot, width, flow) {
663
621
  const effectiveFlow = getEffectiveFlow(flow, snapshot);
664
622
  if (effectiveFlow === 'aidlc') {
665
- return buildAidlcRunFilesLines(snapshot, width, icons);
623
+ return buildAidlcPendingLines(snapshot, width);
666
624
  }
667
625
  if (effectiveFlow === 'simple') {
668
- return buildSimpleRunFilesLines(snapshot, width, icons);
626
+ return buildSimplePendingLines(snapshot, width);
669
627
  }
670
- return buildFireRunFilesLines(snapshot, width, icons);
628
+ return buildFirePendingLines(snapshot, width);
671
629
  }
672
630
 
673
- function buildPendingLines(snapshot, runFilter, width, flow) {
631
+ function buildCompletedLines(snapshot, width, flow) {
674
632
  const effectiveFlow = getEffectiveFlow(flow, snapshot);
675
633
  if (effectiveFlow === 'aidlc') {
676
- return buildAidlcPendingLines(snapshot, runFilter, width);
634
+ return buildAidlcCompletedLines(snapshot, width);
677
635
  }
678
636
  if (effectiveFlow === 'simple') {
679
- return buildSimplePendingLines(snapshot, runFilter, width);
637
+ return buildSimpleCompletedLines(snapshot, width);
680
638
  }
681
- return buildFirePendingLines(snapshot, runFilter, width);
682
- }
683
-
684
- function buildCompletedLines(snapshot, runFilter, width, flow) {
685
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
686
- if (effectiveFlow === 'aidlc') {
687
- return buildAidlcCompletedLines(snapshot, runFilter, width);
688
- }
689
- if (effectiveFlow === 'simple') {
690
- return buildSimpleCompletedLines(snapshot, runFilter, width);
691
- }
692
- return buildFireCompletedLines(snapshot, runFilter, width);
639
+ return buildFireCompletedLines(snapshot, width);
693
640
  }
694
641
 
695
642
  function buildStatsLines(snapshot, width, flow) {
@@ -772,6 +719,203 @@ function getPanelTitles(flow, snapshot) {
772
719
  };
773
720
  }
774
721
 
722
+ function getRunFileEntries(snapshot, flow) {
723
+ const effectiveFlow = getEffectiveFlow(flow, snapshot);
724
+
725
+ if (effectiveFlow === 'aidlc') {
726
+ const bolt = getCurrentBolt(snapshot);
727
+ if (!bolt || typeof bolt.path !== 'string') {
728
+ return [];
729
+ }
730
+ const files = Array.isArray(bolt.files) ? bolt.files : [];
731
+ return files.map((fileName) => ({
732
+ name: fileName,
733
+ path: path.join(bolt.path, fileName)
734
+ }));
735
+ }
736
+
737
+ if (effectiveFlow === 'simple') {
738
+ const spec = getCurrentSpec(snapshot);
739
+ if (!spec || typeof spec.path !== 'string') {
740
+ return [];
741
+ }
742
+
743
+ const files = [];
744
+ if (spec.hasRequirements) files.push('requirements.md');
745
+ if (spec.hasDesign) files.push('design.md');
746
+ if (spec.hasTasks) files.push('tasks.md');
747
+
748
+ return files.map((fileName) => ({
749
+ name: fileName,
750
+ path: path.join(spec.path, fileName)
751
+ }));
752
+ }
753
+
754
+ const run = getCurrentRun(snapshot);
755
+ if (!run || typeof run.folderPath !== 'string') {
756
+ return [];
757
+ }
758
+
759
+ const files = ['run.md'];
760
+ if (run.hasPlan) files.push('plan.md');
761
+ if (run.hasTestReport) files.push('test-report.md');
762
+ if (run.hasWalkthrough) files.push('walkthrough.md');
763
+
764
+ return files.map((fileName) => ({
765
+ name: fileName,
766
+ path: path.join(run.folderPath, fileName)
767
+ }));
768
+ }
769
+
770
+ function clampIndex(value, length) {
771
+ if (!Number.isFinite(value)) {
772
+ return 0;
773
+ }
774
+ if (!Number.isFinite(length) || length <= 0) {
775
+ return 0;
776
+ }
777
+ return Math.max(0, Math.min(length - 1, Math.floor(value)));
778
+ }
779
+
780
+ function getNoFileMessage(flow) {
781
+ if (flow === 'aidlc') {
782
+ return 'No bolt files (no active bolt)';
783
+ }
784
+ if (flow === 'simple') {
785
+ return 'No spec files (no active spec)';
786
+ }
787
+ return 'No run files (no active run)';
788
+ }
789
+
790
+ function buildSelectableRunFileLines(fileEntries, selectedIndex, icons, width, flow) {
791
+ if (!Array.isArray(fileEntries) || fileEntries.length === 0) {
792
+ return [truncate(getNoFileMessage(flow), width)];
793
+ }
794
+
795
+ const clampedIndex = clampIndex(selectedIndex, fileEntries.length);
796
+ return fileEntries.map((file, index) => {
797
+ const isSelected = index === clampedIndex;
798
+ const prefix = isSelected ? '>' : ' ';
799
+ return {
800
+ text: truncate(`${prefix} ${icons.runFile} ${file.name}`, width),
801
+ color: isSelected ? 'cyan' : undefined,
802
+ bold: isSelected
803
+ };
804
+ });
805
+ }
806
+
807
+ function colorizeMarkdownLine(line, inCodeBlock) {
808
+ const text = String(line ?? '');
809
+
810
+ if (/^\s*```/.test(text)) {
811
+ return {
812
+ color: 'magenta',
813
+ bold: true,
814
+ togglesCodeBlock: true
815
+ };
816
+ }
817
+
818
+ if (/^\s{0,3}#{1,6}\s+/.test(text)) {
819
+ return {
820
+ color: 'cyan',
821
+ bold: true,
822
+ togglesCodeBlock: false
823
+ };
824
+ }
825
+
826
+ if (/^\s*[-*+]\s+\[[ xX]\]/.test(text) || /^\s*[-*+]\s+/.test(text) || /^\s*\d+\.\s+/.test(text)) {
827
+ return {
828
+ color: 'yellow',
829
+ bold: false,
830
+ togglesCodeBlock: false
831
+ };
832
+ }
833
+
834
+ if (/^\s*>\s+/.test(text)) {
835
+ return {
836
+ color: 'gray',
837
+ bold: false,
838
+ togglesCodeBlock: false
839
+ };
840
+ }
841
+
842
+ if (/^\s*---\s*$/.test(text)) {
843
+ return {
844
+ color: 'yellow',
845
+ bold: false,
846
+ togglesCodeBlock: false
847
+ };
848
+ }
849
+
850
+ if (inCodeBlock) {
851
+ return {
852
+ color: 'green',
853
+ bold: false,
854
+ togglesCodeBlock: false
855
+ };
856
+ }
857
+
858
+ return {
859
+ color: undefined,
860
+ bold: false,
861
+ togglesCodeBlock: false
862
+ };
863
+ }
864
+
865
+ function buildPreviewLines(fileEntry, width, scrollOffset) {
866
+ if (!fileEntry || typeof fileEntry.path !== 'string') {
867
+ return [{ text: truncate('No file selected', width), color: 'gray', bold: false }];
868
+ }
869
+
870
+ let content;
871
+ try {
872
+ content = fs.readFileSync(fileEntry.path, 'utf8');
873
+ } catch (error) {
874
+ return [{
875
+ text: truncate(`Unable to read ${fileEntry.name}: ${error.message}`, width),
876
+ color: 'red',
877
+ bold: false
878
+ }];
879
+ }
880
+
881
+ const rawLines = String(content).split(/\r?\n/);
882
+ const headLine = {
883
+ text: truncate(`file: ${fileEntry.path}`, width),
884
+ color: 'cyan',
885
+ bold: true
886
+ };
887
+
888
+ const cappedLines = rawLines.slice(0, 300);
889
+ const hiddenLineCount = Math.max(0, rawLines.length - cappedLines.length);
890
+ let inCodeBlock = false;
891
+
892
+ const highlighted = cappedLines.map((rawLine, index) => {
893
+ const prefixedLine = `${String(index + 1).padStart(4, ' ')} | ${rawLine}`;
894
+ const { color, bold, togglesCodeBlock } = colorizeMarkdownLine(rawLine, inCodeBlock);
895
+ if (togglesCodeBlock) {
896
+ inCodeBlock = !inCodeBlock;
897
+ }
898
+ return {
899
+ text: truncate(prefixedLine, width),
900
+ color,
901
+ bold
902
+ };
903
+ });
904
+
905
+ if (hiddenLineCount > 0) {
906
+ highlighted.push({
907
+ text: truncate(`... ${hiddenLineCount} additional lines hidden`, width),
908
+ color: 'gray',
909
+ bold: false
910
+ });
911
+ }
912
+
913
+ const clampedOffset = clampIndex(scrollOffset, highlighted.length);
914
+ const body = highlighted.slice(clampedOffset);
915
+
916
+ return [headLine, { text: '', color: undefined, bold: false }, ...body];
917
+ }
918
+
775
919
  function allocateSingleColumnPanels(candidates, rowsBudget) {
776
920
  const filtered = (candidates || []).filter(Boolean);
777
921
  if (filtered.length === 0) {
@@ -810,9 +954,12 @@ function createDashboardApp(deps) {
810
954
  React,
811
955
  ink,
812
956
  parseSnapshot,
957
+ parseSnapshotForFlow,
813
958
  workspacePath,
814
959
  rootPath,
815
960
  flow,
961
+ availableFlows,
962
+ resolveRootPathForFlow,
816
963
  refreshMs,
817
964
  watchEnabled,
818
965
  initialSnapshot,
@@ -847,7 +994,15 @@ function createDashboardApp(deps) {
847
994
  marginBottom: marginBottom || 0
848
995
  },
849
996
  React.createElement(Text, { bold: true, color: 'cyan' }, truncate(title, contentWidth)),
850
- ...visibleLines.map((line, index) => React.createElement(Text, { key: `${title}-${index}` }, line))
997
+ ...visibleLines.map((line, index) => React.createElement(
998
+ Text,
999
+ {
1000
+ key: `${title}-${index}`,
1001
+ color: line.color,
1002
+ bold: line.bold
1003
+ },
1004
+ line.text
1005
+ ))
851
1006
  );
852
1007
  }
853
1008
 
@@ -878,17 +1033,53 @@ function createDashboardApp(deps) {
878
1033
  );
879
1034
  }
880
1035
 
1036
+ function FlowBar(props) {
1037
+ const { activeFlow, width, flowIds } = props;
1038
+ if (!Array.isArray(flowIds) || flowIds.length <= 1) {
1039
+ return null;
1040
+ }
1041
+
1042
+ return React.createElement(
1043
+ Box,
1044
+ { width, flexWrap: 'nowrap' },
1045
+ ...flowIds.map((flowId) => {
1046
+ const isActive = flowId === activeFlow;
1047
+ return React.createElement(
1048
+ Text,
1049
+ {
1050
+ key: flowId,
1051
+ bold: isActive,
1052
+ color: isActive ? 'black' : 'gray',
1053
+ backgroundColor: isActive ? 'green' : undefined
1054
+ },
1055
+ ` ${flowId.toUpperCase()} `
1056
+ );
1057
+ })
1058
+ );
1059
+ }
1060
+
881
1061
  function DashboardApp() {
882
1062
  const { exit } = useApp();
883
1063
  const { stdout } = useStdout();
884
1064
 
1065
+ const fallbackFlow = (initialSnapshot?.flow || flow || 'fire').toLowerCase();
1066
+ const availableFlowIds = Array.from(new Set(
1067
+ (Array.isArray(availableFlows) && availableFlows.length > 0 ? availableFlows : [fallbackFlow])
1068
+ .map((value) => String(value || '').toLowerCase().trim())
1069
+ .filter(Boolean)
1070
+ ));
1071
+
885
1072
  const initialNormalizedError = initialError ? toDashboardError(initialError) : null;
886
1073
  const snapshotHashRef = useRef(safeJsonHash(initialSnapshot || null));
887
1074
  const errorHashRef = useRef(initialNormalizedError ? safeJsonHash(initialNormalizedError) : null);
888
1075
 
1076
+ const [activeFlow, setActiveFlow] = useState(fallbackFlow);
889
1077
  const [snapshot, setSnapshot] = useState(initialSnapshot || null);
890
1078
  const [error, setError] = useState(initialNormalizedError);
891
1079
  const [ui, setUi] = useState(createInitialUIState());
1080
+ const [selectedFileIndex, setSelectedFileIndex] = useState(0);
1081
+ const [previewOpen, setPreviewOpen] = useState(false);
1082
+ const [previewScroll, setPreviewScroll] = useState(0);
892
1083
  const [lastRefreshAt, setLastRefreshAt] = useState(new Date().toISOString());
893
1084
  const [watchStatus, setWatchStatus] = useState(watchEnabled ? 'watching' : 'off');
894
1085
  const [terminalSize, setTerminalSize] = useState(() => ({
@@ -896,15 +1087,35 @@ function createDashboardApp(deps) {
896
1087
  rows: stdout?.rows || process.stdout.rows || 40
897
1088
  }));
898
1089
  const icons = resolveIconSet();
1090
+ const parseSnapshotForActiveFlow = useCallback(async (flowId) => {
1091
+ if (typeof parseSnapshotForFlow === 'function') {
1092
+ return parseSnapshotForFlow(flowId);
1093
+ }
1094
+ if (typeof parseSnapshot === 'function') {
1095
+ return parseSnapshot();
1096
+ }
1097
+ return {
1098
+ ok: false,
1099
+ error: {
1100
+ code: 'PARSE_CALLBACK_MISSING',
1101
+ message: 'Dashboard parser callback is not configured.'
1102
+ }
1103
+ };
1104
+ }, [parseSnapshotForFlow, parseSnapshot]);
1105
+ const runFileEntries = getRunFileEntries(snapshot, activeFlow);
1106
+ const clampedSelectedFileIndex = clampIndex(selectedFileIndex, runFileEntries.length);
1107
+ const selectedFile = runFileEntries[clampedSelectedFileIndex] || null;
899
1108
 
900
1109
  const refresh = useCallback(async () => {
901
1110
  const now = new Date().toISOString();
902
1111
 
903
1112
  try {
904
- const result = await parseSnapshot();
1113
+ const result = await parseSnapshotForActiveFlow(activeFlow);
905
1114
 
906
1115
  if (result?.ok) {
907
- const nextSnapshot = result.snapshot || null;
1116
+ const nextSnapshot = result.snapshot
1117
+ ? { ...result.snapshot, flow: getEffectiveFlow(activeFlow, result.snapshot) }
1118
+ : null;
908
1119
  const nextSnapshotHash = safeJsonHash(nextSnapshot);
909
1120
 
910
1121
  if (nextSnapshotHash !== snapshotHashRef.current) {
@@ -942,7 +1153,7 @@ function createDashboardApp(deps) {
942
1153
  setLastRefreshAt(now);
943
1154
  }
944
1155
  }
945
- }, [parseSnapshot, watchEnabled]);
1156
+ }, [activeFlow, parseSnapshotForActiveFlow, watchEnabled]);
946
1157
 
947
1158
  useInput((input, key) => {
948
1159
  if ((key.ctrl && input === 'c') || input === 'q') {
@@ -955,6 +1166,41 @@ function createDashboardApp(deps) {
955
1166
  return;
956
1167
  }
957
1168
 
1169
+ if (input === 'v' && ui.view === 'runs') {
1170
+ if (selectedFile) {
1171
+ setPreviewOpen((previous) => !previous);
1172
+ setPreviewScroll(0);
1173
+ }
1174
+ return;
1175
+ }
1176
+
1177
+ if (key.escape && previewOpen) {
1178
+ setPreviewOpen(false);
1179
+ setPreviewScroll(0);
1180
+ return;
1181
+ }
1182
+
1183
+ if (ui.view === 'runs' && (key.upArrow || key.downArrow || input === 'j' || input === 'k')) {
1184
+ const moveDown = key.downArrow || input === 'j';
1185
+ const moveUp = key.upArrow || input === 'k';
1186
+
1187
+ if (previewOpen) {
1188
+ if (moveDown) {
1189
+ setPreviewScroll((previous) => previous + 1);
1190
+ } else if (moveUp) {
1191
+ setPreviewScroll((previous) => Math.max(0, previous - 1));
1192
+ }
1193
+ return;
1194
+ }
1195
+
1196
+ if (moveDown) {
1197
+ setSelectedFileIndex((previous) => clampIndex(previous + 1, runFileEntries.length));
1198
+ } else if (moveUp) {
1199
+ setSelectedFileIndex((previous) => clampIndex(previous - 1, runFileEntries.length));
1200
+ }
1201
+ return;
1202
+ }
1203
+
958
1204
  if (input === 'h' || input === '?') {
959
1205
  setUi((previous) => ({ ...previous, showHelp: !previous.showHelp }));
960
1206
  return;
@@ -990,8 +1236,37 @@ function createDashboardApp(deps) {
990
1236
  return;
991
1237
  }
992
1238
 
993
- if (input === 'f') {
994
- setUi((previous) => ({ ...previous, runFilter: cycleRunFilter(previous.runFilter) }));
1239
+ if ((input === ']' || input === 'm') && availableFlowIds.length > 1) {
1240
+ snapshotHashRef.current = safeJsonHash(null);
1241
+ errorHashRef.current = null;
1242
+ setSnapshot(null);
1243
+ setError(null);
1244
+ setActiveFlow((previous) => {
1245
+ const index = availableFlowIds.indexOf(previous);
1246
+ const nextIndex = index >= 0
1247
+ ? ((index + 1) % availableFlowIds.length)
1248
+ : 0;
1249
+ return availableFlowIds[nextIndex];
1250
+ });
1251
+ setPreviewOpen(false);
1252
+ setPreviewScroll(0);
1253
+ return;
1254
+ }
1255
+
1256
+ if (input === '[' && availableFlowIds.length > 1) {
1257
+ snapshotHashRef.current = safeJsonHash(null);
1258
+ errorHashRef.current = null;
1259
+ setSnapshot(null);
1260
+ setError(null);
1261
+ setActiveFlow((previous) => {
1262
+ const index = availableFlowIds.indexOf(previous);
1263
+ const nextIndex = index >= 0
1264
+ ? ((index - 1 + availableFlowIds.length) % availableFlowIds.length)
1265
+ : 0;
1266
+ return availableFlowIds[nextIndex];
1267
+ });
1268
+ setPreviewOpen(false);
1269
+ setPreviewScroll(0);
995
1270
  }
996
1271
  });
997
1272
 
@@ -999,6 +1274,21 @@ function createDashboardApp(deps) {
999
1274
  void refresh();
1000
1275
  }, [refresh]);
1001
1276
 
1277
+ useEffect(() => {
1278
+ setSelectedFileIndex((previous) => clampIndex(previous, runFileEntries.length));
1279
+ if (runFileEntries.length === 0) {
1280
+ setPreviewOpen(false);
1281
+ setPreviewScroll(0);
1282
+ }
1283
+ }, [activeFlow, runFileEntries.length, snapshot?.generatedAt]);
1284
+
1285
+ useEffect(() => {
1286
+ if (ui.view !== 'runs') {
1287
+ setPreviewOpen(false);
1288
+ setPreviewScroll(0);
1289
+ }
1290
+ }, [ui.view]);
1291
+
1002
1292
  useEffect(() => {
1003
1293
  if (!stdout || typeof stdout.on !== 'function') {
1004
1294
  setTerminalSize({
@@ -1032,8 +1322,12 @@ function createDashboardApp(deps) {
1032
1322
  return undefined;
1033
1323
  }
1034
1324
 
1325
+ const watchRootPath = resolveRootPathForFlow
1326
+ ? resolveRootPathForFlow(activeFlow)
1327
+ : (rootPath || `${workspacePath}/.specs-fire`);
1328
+
1035
1329
  const runtime = createWatchRuntime({
1036
- rootPath: rootPath || `${workspacePath}/.specs-fire`,
1330
+ rootPath: watchRootPath,
1037
1331
  debounceMs: 200,
1038
1332
  onRefresh: () => {
1039
1333
  void refresh();
@@ -1062,7 +1356,7 @@ function createDashboardApp(deps) {
1062
1356
  clearInterval(interval);
1063
1357
  void runtime.close();
1064
1358
  };
1065
- }, [watchEnabled, refreshMs, refresh, rootPath, workspacePath]);
1359
+ }, [watchEnabled, refreshMs, refresh, rootPath, workspacePath, resolveRootPathForFlow, activeFlow]);
1066
1360
 
1067
1361
  const cols = Number.isFinite(terminalSize.columns) ? terminalSize.columns : (process.stdout.columns || 120);
1068
1362
  const rows = Number.isFinite(terminalSize.rows) ? terminalSize.rows : (process.stdout.rows || 40);
@@ -1078,7 +1372,9 @@ function createDashboardApp(deps) {
1078
1372
  const reservedRows = 2 + (showHelpLine ? 1 : 0) + (showErrorPanel ? 5 : 0) + (showErrorInline ? 1 : 0);
1079
1373
  const contentRowsBudget = Math.max(4, rows - reservedRows);
1080
1374
  const ultraCompact = rows <= 14;
1081
- const panelTitles = getPanelTitles(flow, snapshot);
1375
+ const panelTitles = getPanelTitles(activeFlow, snapshot);
1376
+ const runFileLines = buildSelectableRunFileLines(runFileEntries, clampedSelectedFileIndex, icons, compactWidth, activeFlow);
1377
+ const previewLines = previewOpen ? buildPreviewLines(selectedFile, compactWidth, previewScroll) : [];
1082
1378
 
1083
1379
  let panelCandidates;
1084
1380
  if (ui.view === 'overview') {
@@ -1086,19 +1382,19 @@ function createDashboardApp(deps) {
1086
1382
  {
1087
1383
  key: 'project',
1088
1384
  title: 'Project + Workspace',
1089
- lines: buildOverviewProjectLines(snapshot, compactWidth, flow),
1385
+ lines: buildOverviewProjectLines(snapshot, compactWidth, activeFlow),
1090
1386
  borderColor: 'green'
1091
1387
  },
1092
1388
  {
1093
1389
  key: 'intent-status',
1094
1390
  title: 'Intent Status',
1095
- lines: buildOverviewIntentLines(snapshot, compactWidth, flow),
1391
+ lines: buildOverviewIntentLines(snapshot, compactWidth, activeFlow),
1096
1392
  borderColor: 'yellow'
1097
1393
  },
1098
1394
  {
1099
1395
  key: 'standards',
1100
1396
  title: 'Standards',
1101
- lines: buildOverviewStandardsLines(snapshot, compactWidth, flow),
1397
+ lines: buildOverviewStandardsLines(snapshot, compactWidth, activeFlow),
1102
1398
  borderColor: 'blue'
1103
1399
  }
1104
1400
  ];
@@ -1107,7 +1403,7 @@ function createDashboardApp(deps) {
1107
1403
  {
1108
1404
  key: 'stats',
1109
1405
  title: 'Stats',
1110
- lines: buildStatsLines(snapshot, compactWidth, flow),
1406
+ lines: buildStatsLines(snapshot, compactWidth, activeFlow),
1111
1407
  borderColor: 'magenta'
1112
1408
  },
1113
1409
  {
@@ -1131,42 +1427,56 @@ function createDashboardApp(deps) {
1131
1427
  {
1132
1428
  key: 'current-run',
1133
1429
  title: panelTitles.current,
1134
- lines: buildCurrentRunLines(snapshot, compactWidth, flow),
1430
+ lines: buildCurrentRunLines(snapshot, compactWidth, activeFlow),
1135
1431
  borderColor: 'green'
1136
1432
  },
1137
1433
  {
1138
1434
  key: 'run-files',
1139
1435
  title: panelTitles.files,
1140
- lines: buildRunFilesLines(snapshot, compactWidth, icons, flow),
1436
+ lines: runFileLines,
1141
1437
  borderColor: 'yellow'
1142
1438
  },
1439
+ previewOpen
1440
+ ? {
1441
+ key: 'preview',
1442
+ title: `Preview: ${selectedFile?.name || 'unknown'}`,
1443
+ lines: previewLines,
1444
+ borderColor: 'magenta'
1445
+ }
1446
+ : null,
1143
1447
  {
1144
1448
  key: 'pending',
1145
1449
  title: panelTitles.pending,
1146
- lines: buildPendingLines(snapshot, ui.runFilter, compactWidth, flow),
1450
+ lines: buildPendingLines(snapshot, compactWidth, activeFlow),
1147
1451
  borderColor: 'yellow'
1148
1452
  },
1149
1453
  {
1150
1454
  key: 'completed',
1151
1455
  title: panelTitles.completed,
1152
- lines: buildCompletedLines(snapshot, ui.runFilter, compactWidth, flow),
1456
+ lines: buildCompletedLines(snapshot, compactWidth, activeFlow),
1153
1457
  borderColor: 'blue'
1154
1458
  }
1155
1459
  ];
1156
1460
  }
1157
1461
 
1158
1462
  if (ultraCompact) {
1159
- panelCandidates = [panelCandidates[0]];
1463
+ if (previewOpen) {
1464
+ panelCandidates = panelCandidates.filter((panel) => panel && (panel.key === 'current-run' || panel.key === 'preview'));
1465
+ } else {
1466
+ panelCandidates = [panelCandidates[0]];
1467
+ }
1160
1468
  }
1161
1469
 
1162
1470
  const panels = allocateSingleColumnPanels(panelCandidates, contentRowsBudget);
1163
-
1164
- const helpText = 'q quit | r refresh | h/? help | ←/→ or tab switch views | 1 runs | 2 overview | 3 health | f run filter';
1471
+ const flowSwitchHint = availableFlowIds.length > 1 ? ' | [ or ] switch flow' : '';
1472
+ const previewHint = previewOpen ? ' | ↑/↓ scroll preview' : ' | ↑/↓ select file | v preview';
1473
+ const helpText = `q quit | r refresh | h/? help | ←/→ or tab switch views | 1 runs | 2 overview | 3 health${previewHint}${flowSwitchHint}`;
1165
1474
 
1166
1475
  return React.createElement(
1167
1476
  Box,
1168
1477
  { flexDirection: 'column', width: fullWidth },
1169
- React.createElement(Text, { color: 'cyan' }, buildHeaderLine(snapshot, flow, watchEnabled, watchStatus, lastRefreshAt, ui.view, ui.runFilter, fullWidth)),
1478
+ React.createElement(Text, { color: 'cyan' }, buildHeaderLine(snapshot, activeFlow, watchEnabled, watchStatus, lastRefreshAt, ui.view, fullWidth)),
1479
+ React.createElement(FlowBar, { activeFlow, width: fullWidth, flowIds: availableFlowIds }),
1170
1480
  React.createElement(TabsBar, { view: ui.view, width: fullWidth, icons }),
1171
1481
  showErrorInline
1172
1482
  ? React.createElement(Text, { color: 'red' }, truncate(buildErrorLines(error, fullWidth)[0] || 'Error', fullWidth))
@@ -30,7 +30,6 @@ function renderHeaderLines(params) {
30
30
  flow,
31
31
  workspacePath,
32
32
  view,
33
- runFilter,
34
33
  watchEnabled,
35
34
  watchStatus,
36
35
  lastRefreshAt,
@@ -43,8 +42,7 @@ function renderHeaderLines(params) {
43
42
  `path: ${workspacePath}`,
44
43
  `updated: ${formatTime(lastRefreshAt)}`,
45
44
  `watch: ${watchEnabled ? watchStatus : 'off'}`,
46
- `view: ${view}`,
47
- `filter: ${runFilter}`
45
+ `view: ${view}`
48
46
  ].join(' | ');
49
47
 
50
48
  const horizontal = '-'.repeat(Math.max(20, Math.min(width || 120, 120)));
@@ -6,7 +6,7 @@ function renderHelpLines(showHelp, width) {
6
6
  }
7
7
 
8
8
  return [
9
- truncate('Keys: q quit | r refresh | h/? toggle help | tab cycle view | 1 runs | 2 overview | f cycle run filter', width)
9
+ truncate('Keys: q quit | r refresh | h/? toggle help | tab cycle view | 1 runs | 2 overview', width)
10
10
  ];
11
11
  }
12
12
 
@@ -20,7 +20,6 @@ function buildDashboardLines(params) {
20
20
  flow,
21
21
  workspacePath,
22
22
  view,
23
- runFilter,
24
23
  watchEnabled,
25
24
  watchStatus,
26
25
  showHelp,
@@ -36,7 +35,6 @@ function buildDashboardLines(params) {
36
35
  flow,
37
36
  workspacePath,
38
37
  view,
39
- runFilter,
40
38
  watchEnabled,
41
39
  watchStatus,
42
40
  lastRefreshAt,
@@ -58,7 +56,7 @@ function buildDashboardLines(params) {
58
56
  } else if (view === 'overview') {
59
57
  lines.push(...renderOverviewViewLines(snapshot, safeWidth));
60
58
  } else {
61
- lines.push(...renderRunsViewLines(snapshot, runFilter, safeWidth));
59
+ lines.push(...renderRunsViewLines(snapshot, safeWidth));
62
60
  }
63
61
 
64
62
  lines.push('');
@@ -1,7 +1,6 @@
1
1
  function createInitialUIState() {
2
2
  return {
3
3
  view: 'runs',
4
- runFilter: 'all',
5
4
  showHelp: true
6
5
  };
7
6
  }
@@ -26,19 +25,8 @@ function cycleViewBackward(current) {
26
25
  return 'overview';
27
26
  }
28
27
 
29
- function cycleRunFilter(current) {
30
- if (current === 'all') {
31
- return 'active';
32
- }
33
- if (current === 'active') {
34
- return 'completed';
35
- }
36
- return 'all';
37
- }
38
-
39
28
  module.exports = {
40
29
  createInitialUIState,
41
30
  cycleView,
42
- cycleViewBackward,
43
- cycleRunFilter
31
+ cycleViewBackward
44
32
  };
@@ -70,7 +70,7 @@ function renderCompletedRunLines(completedRuns, width) {
70
70
  return lines.map((line) => truncate(line, width));
71
71
  }
72
72
 
73
- function renderRunsViewLines(snapshot, runFilter, width) {
73
+ function renderRunsViewLines(snapshot, width) {
74
74
  const lines = [];
75
75
 
76
76
  if (!snapshot?.initialized) {
@@ -79,16 +79,11 @@ function renderRunsViewLines(snapshot, runFilter, width) {
79
79
  return lines.map((line) => truncate(line, width));
80
80
  }
81
81
 
82
- if (runFilter !== 'completed') {
83
- lines.push(...renderActiveRunLines(snapshot.activeRuns, width));
84
- lines.push('');
85
- lines.push(...renderPendingQueueLines(snapshot.pendingItems, width));
86
- lines.push('');
87
- }
88
-
89
- if (runFilter !== 'active') {
90
- lines.push(...renderCompletedRunLines(snapshot.completedRuns, width));
91
- }
82
+ lines.push(...renderActiveRunLines(snapshot.activeRuns, width));
83
+ lines.push('');
84
+ lines.push(...renderPendingQueueLines(snapshot.pendingItems, width));
85
+ lines.push('');
86
+ lines.push(...renderCompletedRunLines(snapshot.completedRuns, width));
92
87
 
93
88
  return lines.map((line) => truncate(line, width));
94
89
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specsmd",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "Multi-agent orchestration system for AI-native software development. Delivers AI-DLC, Agile, and custom SDLC flows as markdown-based agent systems.",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {