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.
- package/lib/dashboard/tui/app.js +391 -192
- package/lib/dashboard/tui/store.js +14 -0
- package/package.json +1 -1
package/lib/dashboard/tui/app.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
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
|
|
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
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
227
|
-
return [
|
|
331
|
+
function allocateSingleColumnPanels(candidates, rowsBudget) {
|
|
332
|
+
const filtered = (candidates || []).filter(Boolean);
|
|
333
|
+
if (filtered.length === 0) {
|
|
334
|
+
return [];
|
|
228
335
|
}
|
|
229
336
|
|
|
230
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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(
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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:
|
|
593
|
+
debounceMs: 200,
|
|
388
594
|
onRefresh: () => {
|
|
389
595
|
void refresh();
|
|
390
596
|
},
|
|
391
597
|
onError: (watchError) => {
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
},
|
|
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
|
|
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
|
|
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
|
|
441
|
-
const
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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(
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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:
|
|
515
|
-
borderColor:
|
|
516
|
-
marginBottom: 1
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
{
|
|
521
|
-
|
|
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.
|
|
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": {
|