specsmd 0.1.25 → 0.1.27

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.
@@ -1,5 +1,5 @@
1
1
  const { createWatchRuntime } = require('../runtime/watch-runtime');
2
- const { createInitialUIState, cycleView, cycleRunFilter } = require('./store');
2
+ const { createInitialUIState, cycleView, cycleViewBackward, cycleRunFilter } = require('./store');
3
3
 
4
4
  function toDashboardError(error, defaultCode = 'DASHBOARD_ERROR') {
5
5
  if (!error) {
@@ -32,6 +32,50 @@ function toDashboardError(error, defaultCode = 'DASHBOARD_ERROR') {
32
32
  };
33
33
  }
34
34
 
35
+ function safeJsonHash(value) {
36
+ try {
37
+ return JSON.stringify(value, (key, nestedValue) => {
38
+ if (key === 'generatedAt') {
39
+ return undefined;
40
+ }
41
+ return nestedValue;
42
+ });
43
+ } catch {
44
+ return String(value);
45
+ }
46
+ }
47
+
48
+ function resolveIconSet() {
49
+ const mode = (process.env.SPECSMD_ICON_SET || 'auto').toLowerCase();
50
+
51
+ const ascii = {
52
+ runs: '[R]',
53
+ overview: '[O]',
54
+ health: '[H]',
55
+ runFile: '*'
56
+ };
57
+
58
+ const nerd = {
59
+ runs: '󰑮',
60
+ overview: '󰍉',
61
+ health: '󰓦',
62
+ runFile: '󰈔'
63
+ };
64
+
65
+ if (mode === 'ascii') {
66
+ return ascii;
67
+ }
68
+ if (mode === 'nerd') {
69
+ return nerd;
70
+ }
71
+
72
+ const locale = `${process.env.LC_ALL || ''}${process.env.LC_CTYPE || ''}${process.env.LANG || ''}`;
73
+ const isUtf8 = /utf-?8/i.test(locale);
74
+ const looksLikeVsCodeTerminal = (process.env.TERM_PROGRAM || '').toLowerCase().includes('vscode');
75
+
76
+ return isUtf8 && looksLikeVsCodeTerminal ? nerd : ascii;
77
+ }
78
+
35
79
  function truncate(value, width) {
36
80
  const text = String(value ?? '');
37
81
  if (!Number.isFinite(width) || width <= 0 || text.length <= width) {
@@ -46,8 +90,7 @@ function truncate(value, width) {
46
90
  }
47
91
 
48
92
  function fitLines(lines, maxLines, width) {
49
- const safeLines = (Array.isArray(lines) ? lines : [])
50
- .map((line) => truncate(line, width));
93
+ const safeLines = (Array.isArray(lines) ? lines : []).map((line) => truncate(line, width));
51
94
 
52
95
  if (safeLines.length <= maxLines) {
53
96
  return safeLines;
@@ -71,14 +114,22 @@ function formatTime(value) {
71
114
  return date.toLocaleTimeString();
72
115
  }
73
116
 
74
- function buildHeaderLines(snapshot, flow, workspacePath, watchEnabled, watchStatus, lastRefreshAt, view, runFilter, width) {
117
+ function buildShortStats(snapshot) {
118
+ if (!snapshot?.initialized) {
119
+ return 'init: waiting for state.yaml';
120
+ }
121
+
122
+ const stats = snapshot.stats;
123
+ return `runs ${stats.activeRunsCount}/${stats.completedRuns} | intents ${stats.completedIntents}/${stats.totalIntents} | work ${stats.completedWorkItems}/${stats.totalWorkItems}`;
124
+ }
125
+
126
+ function buildHeaderLine(snapshot, flow, watchEnabled, watchStatus, lastRefreshAt, view, runFilter, width) {
75
127
  const projectName = snapshot?.project?.name || 'Unnamed FIRE project';
128
+ const shortStats = buildShortStats(snapshot);
76
129
 
77
- return [
78
- `specsmd dashboard | ${flow.toUpperCase()} | ${projectName}`,
79
- `path: ${workspacePath}`,
80
- `updated: ${formatTime(lastRefreshAt)} | watch: ${watchEnabled ? watchStatus : 'off'} | view: ${view} | filter: ${runFilter}`
81
- ].map((line) => truncate(line, width));
130
+ const line = `${flow.toUpperCase()} | ${projectName} | ${shortStats} | watch:${watchEnabled ? watchStatus : 'off'} | ${view}/${runFilter} | ${formatTime(lastRefreshAt)}`;
131
+
132
+ return truncate(line, width);
82
133
  }
83
134
 
84
135
  function buildErrorLines(error, width) {
@@ -86,9 +137,7 @@ function buildErrorLines(error, width) {
86
137
  return [];
87
138
  }
88
139
 
89
- const lines = [
90
- `[${error.code || 'ERROR'}] ${error.message || 'Unknown error'}`
91
- ];
140
+ const lines = [`[${error.code || 'ERROR'}] ${error.message || 'Unknown error'}`];
92
141
 
93
142
  if (error.details) {
94
143
  lines.push(`details: ${error.details}`);
@@ -103,38 +152,89 @@ function buildErrorLines(error, width) {
103
152
  return lines.map((line) => truncate(line, width));
104
153
  }
105
154
 
106
- function buildActiveRunLines(snapshot, runFilter, width) {
107
- if (runFilter === 'completed') {
108
- return [truncate('Hidden by active filter: completed', width)];
155
+ function getCurrentRun(snapshot) {
156
+ const activeRuns = Array.isArray(snapshot?.activeRuns) ? [...snapshot.activeRuns] : [];
157
+ if (activeRuns.length === 0) {
158
+ return null;
109
159
  }
110
160
 
111
- const activeRuns = snapshot?.activeRuns || [];
112
- if (activeRuns.length === 0) {
113
- return [truncate('No active runs', width)];
161
+ activeRuns.sort((a, b) => {
162
+ const aTime = a?.startedAt ? Date.parse(a.startedAt) : 0;
163
+ const bTime = b?.startedAt ? Date.parse(b.startedAt) : 0;
164
+ if (bTime !== aTime) {
165
+ return bTime - aTime;
166
+ }
167
+ return String(a?.id || '').localeCompare(String(b?.id || ''));
168
+ });
169
+
170
+ return activeRuns[0] || null;
171
+ }
172
+
173
+ function getCurrentPhaseLabel(run, currentWorkItem) {
174
+ const phase = currentWorkItem?.currentPhase || '';
175
+ if (typeof phase === 'string' && phase !== '') {
176
+ return phase.toLowerCase();
114
177
  }
115
178
 
116
- const lines = [];
117
- for (const run of activeRuns) {
118
- const currentItem = run.currentItem || 'n/a';
119
- const workItems = Array.isArray(run.workItems) ? run.workItems : [];
120
- const completed = workItems.filter((item) => item.status === 'completed').length;
121
- const inProgress = workItems.filter((item) => item.status === 'in_progress').length;
122
- const artifacts = [
123
- run.hasPlan ? 'plan' : null,
124
- run.hasWalkthrough ? 'walkthrough' : null,
125
- run.hasTestReport ? 'test-report' : null
126
- ].filter(Boolean).join(', ') || 'none';
179
+ if (run?.hasTestReport) {
180
+ return 'review';
181
+ }
182
+ if (run?.hasPlan) {
183
+ return 'execute';
184
+ }
185
+ return 'plan';
186
+ }
127
187
 
128
- lines.push(`${run.id} [${run.scope}] current: ${currentItem}`);
129
- lines.push(`progress ${completed}/${workItems.length} done, ${inProgress} active | artifacts: ${artifacts}`);
188
+ function buildPhaseTrack(currentPhase) {
189
+ const order = ['plan', 'execute', 'test', 'review'];
190
+ const labels = ['P', 'E', 'T', 'R'];
191
+ const currentIndex = Math.max(0, order.indexOf(currentPhase));
192
+ return labels.map((label, index) => (index === currentIndex ? `[${label}]` : ` ${label} `)).join(' - ');
193
+ }
194
+
195
+ function buildCurrentRunLines(snapshot, width) {
196
+ const run = getCurrentRun(snapshot);
197
+ if (!run) {
198
+ return [truncate('No active run', width)];
130
199
  }
131
200
 
201
+ const workItems = Array.isArray(run.workItems) ? run.workItems : [];
202
+ const completed = workItems.filter((item) => item.status === 'completed').length;
203
+ const currentWorkItem = workItems.find((item) => item.id === run.currentItem) || workItems.find((item) => item.status === 'in_progress') || workItems[0];
204
+
205
+ const itemId = currentWorkItem?.id || run.currentItem || 'n/a';
206
+ const mode = String(currentWorkItem?.mode || 'confirm').toUpperCase();
207
+ const status = currentWorkItem?.status || 'pending';
208
+ const currentPhase = getCurrentPhaseLabel(run, currentWorkItem);
209
+ const phaseTrack = buildPhaseTrack(currentPhase);
210
+
211
+ const lines = [
212
+ `${run.id} [${run.scope}] ${completed}/${workItems.length} items done`,
213
+ `work item: ${itemId}`,
214
+ `mode: ${mode} | status: ${status}`,
215
+ `phase: ${phaseTrack}`
216
+ ];
217
+
132
218
  return lines.map((line) => truncate(line, width));
133
219
  }
134
220
 
221
+ function buildRunFilesLines(snapshot, width, icons) {
222
+ const run = getCurrentRun(snapshot);
223
+ if (!run) {
224
+ return [truncate('No run files (no active run)', width)];
225
+ }
226
+
227
+ const files = ['run.md'];
228
+ if (run.hasPlan) files.push('plan.md');
229
+ if (run.hasTestReport) files.push('test-report.md');
230
+ if (run.hasWalkthrough) files.push('walkthrough.md');
231
+
232
+ return files.map((file) => truncate(`${icons.runFile} ${file}`, width));
233
+ }
234
+
135
235
  function buildPendingLines(snapshot, runFilter, width) {
136
236
  if (runFilter === 'completed') {
137
- return [truncate('Hidden by active filter: completed', width)];
237
+ return [truncate('Hidden by run filter: completed', width)];
138
238
  }
139
239
 
140
240
  const pending = snapshot?.pendingItems || [];
@@ -143,16 +243,14 @@ function buildPendingLines(snapshot, runFilter, width) {
143
243
  }
144
244
 
145
245
  return pending.map((item) => {
146
- const deps = item.dependencies && item.dependencies.length > 0
147
- ? ` deps:${item.dependencies.join(',')}`
148
- : '';
246
+ const deps = item.dependencies && item.dependencies.length > 0 ? ` deps:${item.dependencies.join(',')}` : '';
149
247
  return truncate(`${item.id} (${item.mode}/${item.complexity}) in ${item.intentTitle}${deps}`, width);
150
248
  });
151
249
  }
152
250
 
153
251
  function buildCompletedLines(snapshot, runFilter, width) {
154
252
  if (runFilter === 'active') {
155
- return [truncate('Hidden by active filter: active', width)];
253
+ return [truncate('Hidden by run filter: active', width)];
156
254
  }
157
255
 
158
256
  const completedRuns = snapshot?.completedRuns || [];
@@ -180,6 +278,15 @@ function buildStatsLines(snapshot, width) {
180
278
  ].map((line) => truncate(line, width));
181
279
  }
182
280
 
281
+ function buildWarningsLines(snapshot, width) {
282
+ const warnings = snapshot?.warnings || [];
283
+ if (warnings.length === 0) {
284
+ return [truncate('No warnings', width)];
285
+ }
286
+
287
+ return warnings.map((warning) => truncate(warning, width));
288
+ }
289
+
183
290
  function buildOverviewProjectLines(snapshot, width) {
184
291
  if (!snapshot?.initialized) {
185
292
  return [
@@ -221,13 +328,37 @@ function buildOverviewStandardsLines(snapshot, width) {
221
328
  });
222
329
  }
223
330
 
224
- function buildWarningsLines(snapshot, width) {
225
- const warnings = snapshot?.warnings || [];
226
- if (warnings.length === 0) {
227
- return [truncate('No warnings', width)];
331
+ function allocateSingleColumnPanels(candidates, rowsBudget) {
332
+ const filtered = (candidates || []).filter(Boolean);
333
+ if (filtered.length === 0) {
334
+ return [];
228
335
  }
229
336
 
230
- return warnings.map((warning) => truncate(warning, width));
337
+ const selected = [];
338
+ let remaining = Math.max(4, rowsBudget);
339
+
340
+ for (const panel of filtered) {
341
+ const margin = selected.length > 0 ? 1 : 0;
342
+ const minimumRows = 4 + margin;
343
+
344
+ if (remaining >= minimumRows || selected.length === 0) {
345
+ selected.push({
346
+ ...panel,
347
+ maxLines: 1
348
+ });
349
+ remaining -= minimumRows;
350
+ }
351
+ }
352
+
353
+ let index = 0;
354
+ while (remaining > 0 && selected.length > 0) {
355
+ const panelIndex = index % selected.length;
356
+ selected[panelIndex].maxLines += 1;
357
+ remaining -= 1;
358
+ index += 1;
359
+ }
360
+
361
+ return selected;
231
362
  }
232
363
 
233
364
  function createDashboardApp(deps) {
@@ -245,7 +376,7 @@ function createDashboardApp(deps) {
245
376
  } = deps;
246
377
 
247
378
  const { Box, Text, useApp, useInput, useStdout } = ink;
248
- const { useState, useEffect, useCallback } = React;
379
+ const { useState, useEffect, useCallback, useRef } = React;
249
380
 
250
381
  function SectionPanel(props) {
251
382
  const {
@@ -254,8 +385,8 @@ function createDashboardApp(deps) {
254
385
  width,
255
386
  maxLines,
256
387
  borderColor,
257
- marginRight,
258
- marginBottom
388
+ marginBottom,
389
+ dense
259
390
  } = props;
260
391
 
261
392
  const contentWidth = Math.max(18, width - 4);
@@ -265,11 +396,10 @@ function createDashboardApp(deps) {
265
396
  Box,
266
397
  {
267
398
  flexDirection: 'column',
268
- borderStyle: 'round',
399
+ borderStyle: dense ? 'single' : 'round',
269
400
  borderColor: borderColor || 'gray',
270
- paddingX: 1,
401
+ paddingX: dense ? 0 : 1,
271
402
  width,
272
- marginRight: marginRight || 0,
273
403
  marginBottom: marginBottom || 0
274
404
  },
275
405
  React.createElement(Text, { bold: true, color: 'cyan' }, truncate(title, contentWidth)),
@@ -277,12 +407,43 @@ function createDashboardApp(deps) {
277
407
  );
278
408
  }
279
409
 
410
+ function TabsBar(props) {
411
+ const { view, width, icons } = props;
412
+ const tabs = [
413
+ { id: 'runs', label: ` 1 ${icons.runs} RUNS ` },
414
+ { id: 'overview', label: ` 2 ${icons.overview} OVERVIEW ` },
415
+ { id: 'health', label: ` 3 ${icons.health} HEALTH ` }
416
+ ];
417
+
418
+ return React.createElement(
419
+ Box,
420
+ { width, flexWrap: 'nowrap' },
421
+ ...tabs.map((tab) => {
422
+ const isActive = tab.id === view;
423
+ return React.createElement(
424
+ Text,
425
+ {
426
+ key: tab.id,
427
+ bold: isActive,
428
+ color: isActive ? 'black' : 'gray',
429
+ backgroundColor: isActive ? 'cyan' : undefined
430
+ },
431
+ tab.label
432
+ );
433
+ })
434
+ );
435
+ }
436
+
280
437
  function DashboardApp() {
281
438
  const { exit } = useApp();
282
439
  const { stdout } = useStdout();
283
440
 
441
+ const initialNormalizedError = initialError ? toDashboardError(initialError) : null;
442
+ const snapshotHashRef = useRef(safeJsonHash(initialSnapshot || null));
443
+ const errorHashRef = useRef(initialNormalizedError ? safeJsonHash(initialNormalizedError) : null);
444
+
284
445
  const [snapshot, setSnapshot] = useState(initialSnapshot || null);
285
- const [error, setError] = useState(initialError ? toDashboardError(initialError) : null);
446
+ const [error, setError] = useState(initialNormalizedError);
286
447
  const [ui, setUi] = useState(createInitialUIState());
287
448
  const [lastRefreshAt, setLastRefreshAt] = useState(new Date().toISOString());
288
449
  const [watchStatus, setWatchStatus] = useState(watchEnabled ? 'watching' : 'off');
@@ -290,22 +451,52 @@ function createDashboardApp(deps) {
290
451
  columns: stdout?.columns || process.stdout.columns || 120,
291
452
  rows: stdout?.rows || process.stdout.rows || 40
292
453
  }));
454
+ const icons = resolveIconSet();
293
455
 
294
456
  const refresh = useCallback(async () => {
457
+ const now = new Date().toISOString();
458
+
295
459
  try {
296
460
  const result = await parseSnapshot();
297
461
 
298
462
  if (result?.ok) {
299
- setSnapshot(result.snapshot || null);
300
- setError(null);
301
- setWatchStatus(watchEnabled ? 'watching' : 'off');
463
+ const nextSnapshot = result.snapshot || null;
464
+ const nextSnapshotHash = safeJsonHash(nextSnapshot);
465
+
466
+ if (nextSnapshotHash !== snapshotHashRef.current) {
467
+ snapshotHashRef.current = nextSnapshotHash;
468
+ setSnapshot(nextSnapshot);
469
+ setLastRefreshAt(now);
470
+ }
471
+
472
+ if (errorHashRef.current !== null) {
473
+ errorHashRef.current = null;
474
+ setError(null);
475
+ setLastRefreshAt(now);
476
+ }
477
+
478
+ if (watchEnabled) {
479
+ setWatchStatus((previous) => (previous === 'watching' ? previous : 'watching'));
480
+ }
302
481
  } else {
303
- setError(toDashboardError(result?.error, 'PARSE_ERROR'));
482
+ const nextError = toDashboardError(result?.error, 'PARSE_ERROR');
483
+ const nextErrorHash = safeJsonHash(nextError);
484
+
485
+ if (nextErrorHash !== errorHashRef.current) {
486
+ errorHashRef.current = nextErrorHash;
487
+ setError(nextError);
488
+ setLastRefreshAt(now);
489
+ }
304
490
  }
305
491
  } catch (refreshError) {
306
- setError(toDashboardError(refreshError, 'REFRESH_FAILED'));
307
- } finally {
308
- setLastRefreshAt(new Date().toISOString());
492
+ const nextError = toDashboardError(refreshError, 'REFRESH_FAILED');
493
+ const nextErrorHash = safeJsonHash(nextError);
494
+
495
+ if (nextErrorHash !== errorHashRef.current) {
496
+ errorHashRef.current = nextErrorHash;
497
+ setError(nextError);
498
+ setLastRefreshAt(now);
499
+ }
309
500
  }
310
501
  }, [parseSnapshot, watchEnabled]);
311
502
 
@@ -335,11 +526,26 @@ function createDashboardApp(deps) {
335
526
  return;
336
527
  }
337
528
 
529
+ if (input === '3') {
530
+ setUi((previous) => ({ ...previous, view: 'health' }));
531
+ return;
532
+ }
533
+
338
534
  if (key.tab) {
339
535
  setUi((previous) => ({ ...previous, view: cycleView(previous.view) }));
340
536
  return;
341
537
  }
342
538
 
539
+ if (key.rightArrow) {
540
+ setUi((previous) => ({ ...previous, view: cycleView(previous.view) }));
541
+ return;
542
+ }
543
+
544
+ if (key.leftArrow) {
545
+ setUi((previous) => ({ ...previous, view: cycleViewBackward(previous.view) }));
546
+ return;
547
+ }
548
+
343
549
  if (input === 'f') {
344
550
  setUi((previous) => ({ ...previous, runFilter: cycleRunFilter(previous.runFilter) }));
345
551
  }
@@ -384,20 +590,29 @@ function createDashboardApp(deps) {
384
590
 
385
591
  const runtime = createWatchRuntime({
386
592
  rootPath: rootPath || `${workspacePath}/.specs-fire`,
387
- debounceMs: 250,
593
+ debounceMs: 200,
388
594
  onRefresh: () => {
389
595
  void refresh();
390
596
  },
391
597
  onError: (watchError) => {
392
- setWatchStatus('reconnecting');
393
- setError(toDashboardError(watchError, 'WATCH_ERROR'));
598
+ const now = new Date().toISOString();
599
+ setWatchStatus((previous) => (previous === 'reconnecting' ? previous : 'reconnecting'));
600
+
601
+ const nextError = toDashboardError(watchError, 'WATCH_ERROR');
602
+ const nextErrorHash = safeJsonHash(nextError);
603
+ if (nextErrorHash !== errorHashRef.current) {
604
+ errorHashRef.current = nextErrorHash;
605
+ setError(nextError);
606
+ setLastRefreshAt(now);
607
+ }
394
608
  }
395
609
  });
396
610
 
397
611
  runtime.start();
612
+ const fallbackIntervalMs = Math.max(refreshMs, 5000);
398
613
  const interval = setInterval(() => {
399
614
  void refresh();
400
- }, refreshMs);
615
+ }, fallbackIntervalMs);
401
616
 
402
617
  return () => {
403
618
  clearInterval(interval);
@@ -408,151 +623,133 @@ function createDashboardApp(deps) {
408
623
  const cols = Number.isFinite(terminalSize.columns) ? terminalSize.columns : (process.stdout.columns || 120);
409
624
  const rows = Number.isFinite(terminalSize.rows) ? terminalSize.rows : (process.stdout.rows || 40);
410
625
 
411
- const compact = cols < 110;
412
- const veryCompact = cols < 80 || rows < 22;
413
- const contentAreaHeight = Math.max(12, rows - (ui.showHelp ? 7 : 5));
414
- const sectionLineLimit = compact
415
- ? Math.max(2, Math.floor(contentAreaHeight / 5))
416
- : Math.max(3, Math.floor(contentAreaHeight / 4));
417
-
418
626
  const fullWidth = Math.max(40, cols - 1);
419
- const leftWidth = compact ? fullWidth : Math.max(28, Math.floor((fullWidth - 1) / 2));
420
- const rightWidth = compact ? fullWidth : Math.max(28, fullWidth - leftWidth - 1);
421
-
422
- const headerLines = buildHeaderLines(
423
- snapshot,
424
- flow,
425
- workspacePath,
426
- watchEnabled,
427
- watchStatus,
428
- lastRefreshAt,
429
- ui.view,
430
- ui.runFilter,
431
- fullWidth - 4
432
- );
433
-
434
- const helpLines = ui.showHelp
435
- ? ['q quit | r refresh | h/? help | tab switch view | 1 runs | 2 overview | f run filter']
436
- : ['press h to show shortcuts'];
627
+ const compactWidth = Math.max(18, fullWidth - 4);
437
628
 
438
- const errorLines = buildErrorLines(error, fullWidth - 4);
629
+ const showHelpLine = ui.showHelp && rows >= 14;
630
+ const showErrorPanel = Boolean(error) && rows >= 18;
631
+ const showErrorInline = Boolean(error) && !showErrorPanel;
632
+ const densePanels = rows <= 28 || cols <= 120;
439
633
 
440
- const leftPanels = [];
441
- const rightPanels = [];
634
+ const reservedRows = 2 + (showHelpLine ? 1 : 0) + (showErrorPanel ? 5 : 0) + (showErrorInline ? 1 : 0);
635
+ const contentRowsBudget = Math.max(4, rows - reservedRows);
636
+ const ultraCompact = rows <= 14;
442
637
 
638
+ let panelCandidates;
443
639
  if (ui.view === 'overview') {
444
- leftPanels.push({
445
- title: 'Project + Workspace',
446
- lines: buildOverviewProjectLines(snapshot, leftWidth - 4),
447
- borderColor: 'green'
448
- });
449
- leftPanels.push({
450
- title: 'Intent Status',
451
- lines: buildOverviewIntentLines(snapshot, leftWidth - 4),
452
- borderColor: 'yellow'
453
- });
454
-
455
- rightPanels.push({
456
- title: 'Stats',
457
- lines: buildStatsLines(snapshot, rightWidth - 4),
458
- borderColor: 'magenta'
459
- });
460
- rightPanels.push({
461
- title: 'Standards',
462
- lines: buildOverviewStandardsLines(snapshot, rightWidth - 4),
463
- borderColor: 'blue'
464
- });
465
- rightPanels.push({
466
- title: 'Warnings',
467
- lines: buildWarningsLines(snapshot, rightWidth - 4),
468
- borderColor: 'red'
469
- });
640
+ panelCandidates = [
641
+ {
642
+ key: 'project',
643
+ title: 'Project + Workspace',
644
+ lines: buildOverviewProjectLines(snapshot, compactWidth),
645
+ borderColor: 'green'
646
+ },
647
+ {
648
+ key: 'intent-status',
649
+ title: 'Intent Status',
650
+ lines: buildOverviewIntentLines(snapshot, compactWidth),
651
+ borderColor: 'yellow'
652
+ },
653
+ {
654
+ key: 'standards',
655
+ title: 'Standards',
656
+ lines: buildOverviewStandardsLines(snapshot, compactWidth),
657
+ borderColor: 'blue'
658
+ }
659
+ ];
660
+ } else if (ui.view === 'health') {
661
+ panelCandidates = [
662
+ {
663
+ key: 'stats',
664
+ title: 'Stats',
665
+ lines: buildStatsLines(snapshot, compactWidth),
666
+ borderColor: 'magenta'
667
+ },
668
+ {
669
+ key: 'warnings',
670
+ title: 'Warnings',
671
+ lines: buildWarningsLines(snapshot, compactWidth),
672
+ borderColor: 'red'
673
+ }
674
+ ];
675
+
676
+ if (error && showErrorPanel) {
677
+ panelCandidates.push({
678
+ key: 'error-details',
679
+ title: 'Error Details',
680
+ lines: buildErrorLines(error, compactWidth),
681
+ borderColor: 'red'
682
+ });
683
+ }
470
684
  } else {
471
- leftPanels.push({
472
- title: 'Active Runs',
473
- lines: buildActiveRunLines(snapshot, ui.runFilter, leftWidth - 4),
474
- borderColor: 'green'
475
- });
476
- leftPanels.push({
477
- title: 'Pending Queue',
478
- lines: buildPendingLines(snapshot, ui.runFilter, leftWidth - 4),
479
- borderColor: 'yellow'
480
- });
685
+ panelCandidates = [
686
+ {
687
+ key: 'current-run',
688
+ title: 'Current Run',
689
+ lines: buildCurrentRunLines(snapshot, compactWidth),
690
+ borderColor: 'green'
691
+ },
692
+ {
693
+ key: 'run-files',
694
+ title: 'Run Files',
695
+ lines: buildRunFilesLines(snapshot, compactWidth, icons),
696
+ borderColor: 'yellow'
697
+ },
698
+ {
699
+ key: 'pending',
700
+ title: 'Pending Queue',
701
+ lines: buildPendingLines(snapshot, ui.runFilter, compactWidth),
702
+ borderColor: 'yellow'
703
+ },
704
+ {
705
+ key: 'completed',
706
+ title: 'Recent Completed Runs',
707
+ lines: buildCompletedLines(snapshot, ui.runFilter, compactWidth),
708
+ borderColor: 'blue'
709
+ }
710
+ ];
711
+ }
481
712
 
482
- rightPanels.push({
483
- title: 'Recent Completed Runs',
484
- lines: buildCompletedLines(snapshot, ui.runFilter, rightWidth - 4),
485
- borderColor: 'blue'
486
- });
487
- rightPanels.push({
488
- title: 'Stats',
489
- lines: buildStatsLines(snapshot, rightWidth - 4),
490
- borderColor: 'magenta'
491
- });
492
- rightPanels.push({
493
- title: 'Warnings',
494
- lines: buildWarningsLines(snapshot, rightWidth - 4),
495
- borderColor: 'red'
496
- });
713
+ if (ultraCompact) {
714
+ panelCandidates = [panelCandidates[0]];
497
715
  }
498
716
 
717
+ const panels = allocateSingleColumnPanels(panelCandidates, contentRowsBudget);
718
+
719
+ const helpText = 'q quit | r refresh | h/? help | ←/→ or tab switch views | 1 runs | 2 overview | 3 health | f run filter';
720
+
499
721
  return React.createElement(
500
722
  Box,
501
723
  { flexDirection: 'column', width: fullWidth },
502
- React.createElement(SectionPanel, {
503
- title: veryCompact ? 'specsmd dashboard' : 'specsmd dashboard / FIRE',
504
- lines: headerLines,
505
- width: fullWidth,
506
- maxLines: veryCompact ? 2 : 3,
507
- borderColor: 'cyan',
508
- marginBottom: 1
509
- }),
510
- error ? React.createElement(SectionPanel, {
511
- title: 'Errors',
512
- lines: errorLines,
724
+ React.createElement(Text, { color: 'cyan' }, buildHeaderLine(snapshot, flow, watchEnabled, watchStatus, lastRefreshAt, ui.view, ui.runFilter, fullWidth)),
725
+ React.createElement(TabsBar, { view: ui.view, width: fullWidth, icons }),
726
+ showErrorInline
727
+ ? React.createElement(Text, { color: 'red' }, truncate(buildErrorLines(error, fullWidth)[0] || 'Error', fullWidth))
728
+ : null,
729
+ showErrorPanel
730
+ ? React.createElement(SectionPanel, {
731
+ title: 'Errors',
732
+ lines: buildErrorLines(error, compactWidth),
733
+ width: fullWidth,
734
+ maxLines: 2,
735
+ borderColor: 'red',
736
+ marginBottom: densePanels ? 0 : 1,
737
+ dense: densePanels
738
+ })
739
+ : null,
740
+ ...panels.map((panel, index) => React.createElement(SectionPanel, {
741
+ key: panel.key,
742
+ title: panel.title,
743
+ lines: panel.lines,
513
744
  width: fullWidth,
514
- maxLines: Math.max(2, sectionLineLimit),
515
- borderColor: 'red',
516
- marginBottom: 1
517
- }) : null,
518
- React.createElement(
519
- Box,
520
- { flexDirection: compact ? 'column' : 'row', width: fullWidth },
521
- React.createElement(
522
- Box,
523
- { flexDirection: 'column', width: leftWidth, marginRight: compact ? 0 : 1 },
524
- ...leftPanels.map((panel, index) => React.createElement(SectionPanel, {
525
- key: `left-${panel.title}`,
526
- title: panel.title,
527
- lines: panel.lines,
528
- width: leftWidth,
529
- maxLines: sectionLineLimit,
530
- borderColor: panel.borderColor,
531
- marginBottom: index === leftPanels.length - 1 ? 0 : 1
532
- }))
533
- ),
534
- React.createElement(
535
- Box,
536
- { flexDirection: 'column', width: rightWidth },
537
- ...rightPanels.map((panel, index) => React.createElement(SectionPanel, {
538
- key: `right-${panel.title}`,
539
- title: panel.title,
540
- lines: panel.lines,
541
- width: rightWidth,
542
- maxLines: sectionLineLimit,
543
- borderColor: panel.borderColor,
544
- marginBottom: index === rightPanels.length - 1 ? 0 : 1
545
- }))
546
- )
547
- ),
548
- React.createElement(SectionPanel, {
549
- title: 'Help',
550
- lines: helpLines,
551
- width: fullWidth,
552
- maxLines: 2,
553
- borderColor: 'gray',
554
- marginTop: 1
555
- })
745
+ maxLines: panel.maxLines,
746
+ borderColor: panel.borderColor,
747
+ marginBottom: densePanels ? 0 : (index === panels.length - 1 ? 0 : 1),
748
+ dense: densePanels
749
+ })),
750
+ showHelpLine
751
+ ? React.createElement(Text, { color: 'gray' }, truncate(helpText, fullWidth))
752
+ : null
556
753
  );
557
754
  }
558
755
 
@@ -563,5 +760,7 @@ module.exports = {
563
760
  createDashboardApp,
564
761
  toDashboardError,
565
762
  truncate,
566
- fitLines
763
+ fitLines,
764
+ safeJsonHash,
765
+ allocateSingleColumnPanels
567
766
  };
@@ -10,9 +10,22 @@ function cycleView(current) {
10
10
  if (current === 'runs') {
11
11
  return 'overview';
12
12
  }
13
+ if (current === 'overview') {
14
+ return 'health';
15
+ }
13
16
  return 'runs';
14
17
  }
15
18
 
19
+ function cycleViewBackward(current) {
20
+ if (current === 'runs') {
21
+ return 'health';
22
+ }
23
+ if (current === 'overview') {
24
+ return 'runs';
25
+ }
26
+ return 'overview';
27
+ }
28
+
16
29
  function cycleRunFilter(current) {
17
30
  if (current === 'all') {
18
31
  return 'active';
@@ -26,5 +39,6 @@ function cycleRunFilter(current) {
26
39
  module.exports = {
27
40
  createInitialUIState,
28
41
  cycleView,
42
+ cycleViewBackward,
29
43
  cycleRunFilter
30
44
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specsmd",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
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": {