specsmd 0.1.23 → 0.1.25
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/README.md +15 -0
- package/bin/cli.js +14 -1
- package/lib/dashboard/fire/model.js +333 -0
- package/lib/dashboard/fire/parser.js +387 -0
- package/lib/dashboard/flow-detect.js +86 -0
- package/lib/dashboard/index.js +134 -0
- package/lib/dashboard/runtime/watch-runtime.js +113 -0
- package/lib/dashboard/tui/app.js +567 -0
- package/lib/dashboard/tui/components/error-banner.js +35 -0
- package/lib/dashboard/tui/components/header.js +62 -0
- package/lib/dashboard/tui/components/help-footer.js +15 -0
- package/lib/dashboard/tui/components/stats-strip.js +35 -0
- package/lib/dashboard/tui/renderer.js +78 -0
- package/lib/dashboard/tui/store.js +30 -0
- package/lib/dashboard/tui/views/overview-view.js +61 -0
- package/lib/dashboard/tui/views/runs-view.js +98 -0
- package/lib/installers/CodexInstaller.js +0 -11
- package/package.json +6 -3
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
const { createWatchRuntime } = require('../runtime/watch-runtime');
|
|
2
|
+
const { createInitialUIState, cycleView, cycleRunFilter } = require('./store');
|
|
3
|
+
|
|
4
|
+
function toDashboardError(error, defaultCode = 'DASHBOARD_ERROR') {
|
|
5
|
+
if (!error) {
|
|
6
|
+
return {
|
|
7
|
+
code: defaultCode,
|
|
8
|
+
message: 'Unknown dashboard error.'
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (typeof error === 'string') {
|
|
13
|
+
return {
|
|
14
|
+
code: defaultCode,
|
|
15
|
+
message: error
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof error === 'object') {
|
|
20
|
+
return {
|
|
21
|
+
code: error.code || defaultCode,
|
|
22
|
+
message: error.message || 'Unknown dashboard error.',
|
|
23
|
+
details: error.details,
|
|
24
|
+
path: error.path,
|
|
25
|
+
hint: error.hint
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
code: defaultCode,
|
|
31
|
+
message: String(error)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function truncate(value, width) {
|
|
36
|
+
const text = String(value ?? '');
|
|
37
|
+
if (!Number.isFinite(width) || width <= 0 || text.length <= width) {
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (width <= 3) {
|
|
42
|
+
return text.slice(0, width);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return `${text.slice(0, width - 3)}...`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fitLines(lines, maxLines, width) {
|
|
49
|
+
const safeLines = (Array.isArray(lines) ? lines : [])
|
|
50
|
+
.map((line) => truncate(line, width));
|
|
51
|
+
|
|
52
|
+
if (safeLines.length <= maxLines) {
|
|
53
|
+
return safeLines;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const visible = safeLines.slice(0, Math.max(1, maxLines - 1));
|
|
57
|
+
visible.push(truncate(`... +${safeLines.length - visible.length} more`, width));
|
|
58
|
+
return visible;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatTime(value) {
|
|
62
|
+
if (!value) {
|
|
63
|
+
return 'n/a';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const date = new Date(value);
|
|
67
|
+
if (Number.isNaN(date.getTime())) {
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return date.toLocaleTimeString();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildHeaderLines(snapshot, flow, workspacePath, watchEnabled, watchStatus, lastRefreshAt, view, runFilter, width) {
|
|
75
|
+
const projectName = snapshot?.project?.name || 'Unnamed FIRE project';
|
|
76
|
+
|
|
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));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildErrorLines(error, width) {
|
|
85
|
+
if (!error) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const lines = [
|
|
90
|
+
`[${error.code || 'ERROR'}] ${error.message || 'Unknown error'}`
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
if (error.details) {
|
|
94
|
+
lines.push(`details: ${error.details}`);
|
|
95
|
+
}
|
|
96
|
+
if (error.path) {
|
|
97
|
+
lines.push(`path: ${error.path}`);
|
|
98
|
+
}
|
|
99
|
+
if (error.hint) {
|
|
100
|
+
lines.push(`hint: ${error.hint}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return lines.map((line) => truncate(line, width));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildActiveRunLines(snapshot, runFilter, width) {
|
|
107
|
+
if (runFilter === 'completed') {
|
|
108
|
+
return [truncate('Hidden by active filter: completed', width)];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const activeRuns = snapshot?.activeRuns || [];
|
|
112
|
+
if (activeRuns.length === 0) {
|
|
113
|
+
return [truncate('No active runs', width)];
|
|
114
|
+
}
|
|
115
|
+
|
|
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';
|
|
127
|
+
|
|
128
|
+
lines.push(`${run.id} [${run.scope}] current: ${currentItem}`);
|
|
129
|
+
lines.push(`progress ${completed}/${workItems.length} done, ${inProgress} active | artifacts: ${artifacts}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return lines.map((line) => truncate(line, width));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildPendingLines(snapshot, runFilter, width) {
|
|
136
|
+
if (runFilter === 'completed') {
|
|
137
|
+
return [truncate('Hidden by active filter: completed', width)];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const pending = snapshot?.pendingItems || [];
|
|
141
|
+
if (pending.length === 0) {
|
|
142
|
+
return [truncate('No pending work items', width)];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return pending.map((item) => {
|
|
146
|
+
const deps = item.dependencies && item.dependencies.length > 0
|
|
147
|
+
? ` deps:${item.dependencies.join(',')}`
|
|
148
|
+
: '';
|
|
149
|
+
return truncate(`${item.id} (${item.mode}/${item.complexity}) in ${item.intentTitle}${deps}`, width);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildCompletedLines(snapshot, runFilter, width) {
|
|
154
|
+
if (runFilter === 'active') {
|
|
155
|
+
return [truncate('Hidden by active filter: active', width)];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const completedRuns = snapshot?.completedRuns || [];
|
|
159
|
+
if (completedRuns.length === 0) {
|
|
160
|
+
return [truncate('No completed runs yet', width)];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return completedRuns.map((run) => {
|
|
164
|
+
const workItems = Array.isArray(run.workItems) ? run.workItems : [];
|
|
165
|
+
const completed = workItems.filter((item) => item.status === 'completed').length;
|
|
166
|
+
return truncate(`${run.id} [${run.scope}] ${completed}/${workItems.length} done at ${run.completedAt || 'unknown'}`, width);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildStatsLines(snapshot, width) {
|
|
171
|
+
if (!snapshot?.initialized) {
|
|
172
|
+
return [truncate('Waiting for .specs-fire/state.yaml initialization.', width)];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const stats = snapshot.stats;
|
|
176
|
+
return [
|
|
177
|
+
`intents: ${stats.completedIntents}/${stats.totalIntents} done | in_progress: ${stats.inProgressIntents} | blocked: ${stats.blockedIntents}`,
|
|
178
|
+
`work items: ${stats.completedWorkItems}/${stats.totalWorkItems} done | in_progress: ${stats.inProgressWorkItems} | pending: ${stats.pendingWorkItems} | blocked: ${stats.blockedWorkItems}`,
|
|
179
|
+
`runs: ${stats.activeRunsCount} active | ${stats.completedRuns} completed | ${stats.totalRuns} total`
|
|
180
|
+
].map((line) => truncate(line, width));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildOverviewProjectLines(snapshot, width) {
|
|
184
|
+
if (!snapshot?.initialized) {
|
|
185
|
+
return [
|
|
186
|
+
truncate('FIRE folder detected, but state.yaml is missing.', width),
|
|
187
|
+
truncate('Initialize project context and this view will populate.', width)
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const project = snapshot.project || {};
|
|
192
|
+
const workspace = snapshot.workspace || {};
|
|
193
|
+
|
|
194
|
+
return [
|
|
195
|
+
`project: ${project.name || 'unknown'} | fire_version: ${project.fireVersion || snapshot.version || '0.0.0'}`,
|
|
196
|
+
`workspace: ${workspace.type || 'unknown'} / ${workspace.structure || 'unknown'}`,
|
|
197
|
+
`autonomy: ${workspace.autonomyBias || 'unknown'} | run scope pref: ${workspace.runScopePreference || 'unknown'}`
|
|
198
|
+
].map((line) => truncate(line, width));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildOverviewIntentLines(snapshot, width) {
|
|
202
|
+
const intents = snapshot?.intents || [];
|
|
203
|
+
if (intents.length === 0) {
|
|
204
|
+
return [truncate('No intents found', width)];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return intents.map((intent) => {
|
|
208
|
+
const workItems = Array.isArray(intent.workItems) ? intent.workItems : [];
|
|
209
|
+
const done = workItems.filter((item) => item.status === 'completed').length;
|
|
210
|
+
return truncate(`${intent.id}: ${intent.status} (${done}/${workItems.length} work items)`, width);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildOverviewStandardsLines(snapshot, width) {
|
|
215
|
+
const expected = ['constitution', 'tech-stack', 'coding-standards', 'testing-standards', 'system-architecture'];
|
|
216
|
+
const actual = new Set((snapshot?.standards || []).map((item) => item.type));
|
|
217
|
+
|
|
218
|
+
return expected.map((name) => {
|
|
219
|
+
const marker = actual.has(name) ? '[x]' : '[ ]';
|
|
220
|
+
return truncate(`${marker} ${name}.md`, width);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function buildWarningsLines(snapshot, width) {
|
|
225
|
+
const warnings = snapshot?.warnings || [];
|
|
226
|
+
if (warnings.length === 0) {
|
|
227
|
+
return [truncate('No warnings', width)];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return warnings.map((warning) => truncate(warning, width));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function createDashboardApp(deps) {
|
|
234
|
+
const {
|
|
235
|
+
React,
|
|
236
|
+
ink,
|
|
237
|
+
parseSnapshot,
|
|
238
|
+
workspacePath,
|
|
239
|
+
rootPath,
|
|
240
|
+
flow,
|
|
241
|
+
refreshMs,
|
|
242
|
+
watchEnabled,
|
|
243
|
+
initialSnapshot,
|
|
244
|
+
initialError
|
|
245
|
+
} = deps;
|
|
246
|
+
|
|
247
|
+
const { Box, Text, useApp, useInput, useStdout } = ink;
|
|
248
|
+
const { useState, useEffect, useCallback } = React;
|
|
249
|
+
|
|
250
|
+
function SectionPanel(props) {
|
|
251
|
+
const {
|
|
252
|
+
title,
|
|
253
|
+
lines,
|
|
254
|
+
width,
|
|
255
|
+
maxLines,
|
|
256
|
+
borderColor,
|
|
257
|
+
marginRight,
|
|
258
|
+
marginBottom
|
|
259
|
+
} = props;
|
|
260
|
+
|
|
261
|
+
const contentWidth = Math.max(18, width - 4);
|
|
262
|
+
const visibleLines = fitLines(lines, maxLines, contentWidth);
|
|
263
|
+
|
|
264
|
+
return React.createElement(
|
|
265
|
+
Box,
|
|
266
|
+
{
|
|
267
|
+
flexDirection: 'column',
|
|
268
|
+
borderStyle: 'round',
|
|
269
|
+
borderColor: borderColor || 'gray',
|
|
270
|
+
paddingX: 1,
|
|
271
|
+
width,
|
|
272
|
+
marginRight: marginRight || 0,
|
|
273
|
+
marginBottom: marginBottom || 0
|
|
274
|
+
},
|
|
275
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, truncate(title, contentWidth)),
|
|
276
|
+
...visibleLines.map((line, index) => React.createElement(Text, { key: `${title}-${index}` }, line))
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function DashboardApp() {
|
|
281
|
+
const { exit } = useApp();
|
|
282
|
+
const { stdout } = useStdout();
|
|
283
|
+
|
|
284
|
+
const [snapshot, setSnapshot] = useState(initialSnapshot || null);
|
|
285
|
+
const [error, setError] = useState(initialError ? toDashboardError(initialError) : null);
|
|
286
|
+
const [ui, setUi] = useState(createInitialUIState());
|
|
287
|
+
const [lastRefreshAt, setLastRefreshAt] = useState(new Date().toISOString());
|
|
288
|
+
const [watchStatus, setWatchStatus] = useState(watchEnabled ? 'watching' : 'off');
|
|
289
|
+
const [terminalSize, setTerminalSize] = useState(() => ({
|
|
290
|
+
columns: stdout?.columns || process.stdout.columns || 120,
|
|
291
|
+
rows: stdout?.rows || process.stdout.rows || 40
|
|
292
|
+
}));
|
|
293
|
+
|
|
294
|
+
const refresh = useCallback(async () => {
|
|
295
|
+
try {
|
|
296
|
+
const result = await parseSnapshot();
|
|
297
|
+
|
|
298
|
+
if (result?.ok) {
|
|
299
|
+
setSnapshot(result.snapshot || null);
|
|
300
|
+
setError(null);
|
|
301
|
+
setWatchStatus(watchEnabled ? 'watching' : 'off');
|
|
302
|
+
} else {
|
|
303
|
+
setError(toDashboardError(result?.error, 'PARSE_ERROR'));
|
|
304
|
+
}
|
|
305
|
+
} catch (refreshError) {
|
|
306
|
+
setError(toDashboardError(refreshError, 'REFRESH_FAILED'));
|
|
307
|
+
} finally {
|
|
308
|
+
setLastRefreshAt(new Date().toISOString());
|
|
309
|
+
}
|
|
310
|
+
}, [parseSnapshot, watchEnabled]);
|
|
311
|
+
|
|
312
|
+
useInput((input, key) => {
|
|
313
|
+
if ((key.ctrl && input === 'c') || input === 'q') {
|
|
314
|
+
exit();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (input === 'r') {
|
|
319
|
+
void refresh();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (input === 'h' || input === '?') {
|
|
324
|
+
setUi((previous) => ({ ...previous, showHelp: !previous.showHelp }));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (input === '1') {
|
|
329
|
+
setUi((previous) => ({ ...previous, view: 'runs' }));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (input === '2') {
|
|
334
|
+
setUi((previous) => ({ ...previous, view: 'overview' }));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (key.tab) {
|
|
339
|
+
setUi((previous) => ({ ...previous, view: cycleView(previous.view) }));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (input === 'f') {
|
|
344
|
+
setUi((previous) => ({ ...previous, runFilter: cycleRunFilter(previous.runFilter) }));
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
useEffect(() => {
|
|
349
|
+
void refresh();
|
|
350
|
+
}, [refresh]);
|
|
351
|
+
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
if (!stdout || typeof stdout.on !== 'function') {
|
|
354
|
+
setTerminalSize({
|
|
355
|
+
columns: process.stdout.columns || 120,
|
|
356
|
+
rows: process.stdout.rows || 40
|
|
357
|
+
});
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const updateSize = () => {
|
|
362
|
+
setTerminalSize({
|
|
363
|
+
columns: stdout.columns || process.stdout.columns || 120,
|
|
364
|
+
rows: stdout.rows || process.stdout.rows || 40
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
updateSize();
|
|
369
|
+
stdout.on('resize', updateSize);
|
|
370
|
+
|
|
371
|
+
return () => {
|
|
372
|
+
if (typeof stdout.off === 'function') {
|
|
373
|
+
stdout.off('resize', updateSize);
|
|
374
|
+
} else if (typeof stdout.removeListener === 'function') {
|
|
375
|
+
stdout.removeListener('resize', updateSize);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}, [stdout]);
|
|
379
|
+
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
if (!watchEnabled) {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const runtime = createWatchRuntime({
|
|
386
|
+
rootPath: rootPath || `${workspacePath}/.specs-fire`,
|
|
387
|
+
debounceMs: 250,
|
|
388
|
+
onRefresh: () => {
|
|
389
|
+
void refresh();
|
|
390
|
+
},
|
|
391
|
+
onError: (watchError) => {
|
|
392
|
+
setWatchStatus('reconnecting');
|
|
393
|
+
setError(toDashboardError(watchError, 'WATCH_ERROR'));
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
runtime.start();
|
|
398
|
+
const interval = setInterval(() => {
|
|
399
|
+
void refresh();
|
|
400
|
+
}, refreshMs);
|
|
401
|
+
|
|
402
|
+
return () => {
|
|
403
|
+
clearInterval(interval);
|
|
404
|
+
void runtime.close();
|
|
405
|
+
};
|
|
406
|
+
}, [watchEnabled, refreshMs, refresh, rootPath, workspacePath]);
|
|
407
|
+
|
|
408
|
+
const cols = Number.isFinite(terminalSize.columns) ? terminalSize.columns : (process.stdout.columns || 120);
|
|
409
|
+
const rows = Number.isFinite(terminalSize.rows) ? terminalSize.rows : (process.stdout.rows || 40);
|
|
410
|
+
|
|
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
|
+
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'];
|
|
437
|
+
|
|
438
|
+
const errorLines = buildErrorLines(error, fullWidth - 4);
|
|
439
|
+
|
|
440
|
+
const leftPanels = [];
|
|
441
|
+
const rightPanels = [];
|
|
442
|
+
|
|
443
|
+
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
|
+
});
|
|
470
|
+
} 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
|
+
});
|
|
481
|
+
|
|
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
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return React.createElement(
|
|
500
|
+
Box,
|
|
501
|
+
{ 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,
|
|
513
|
+
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
|
+
})
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return DashboardApp;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
module.exports = {
|
|
563
|
+
createDashboardApp,
|
|
564
|
+
toDashboardError,
|
|
565
|
+
truncate,
|
|
566
|
+
fitLines
|
|
567
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const { truncate } = require('./header');
|
|
2
|
+
|
|
3
|
+
function renderErrorLines(error, width, watchEnabled = true) {
|
|
4
|
+
if (!error) {
|
|
5
|
+
return [];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const lines = [
|
|
9
|
+
`[error:${error.code || 'UNKNOWN'}] ${error.message || 'Unknown error'}`
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
if (error.details) {
|
|
13
|
+
lines.push(`details: ${error.details}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (error.path) {
|
|
17
|
+
lines.push(`path: ${error.path}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (error.hint) {
|
|
21
|
+
lines.push(`hint: ${error.hint}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (watchEnabled) {
|
|
25
|
+
lines.push('Dashboard keeps running and will recover after the next valid update.');
|
|
26
|
+
} else {
|
|
27
|
+
lines.push('Fix the error and rerun dashboard.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return lines.map((line) => truncate(line, width));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
renderErrorLines
|
|
35
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
function truncate(value, width) {
|
|
2
|
+
const text = String(value);
|
|
3
|
+
if (!Number.isFinite(width) || width <= 0 || text.length <= width) {
|
|
4
|
+
return text;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
if (width <= 3) {
|
|
8
|
+
return text.slice(0, width);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return `${text.slice(0, width - 3)}...`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatTime(value) {
|
|
15
|
+
if (!value) {
|
|
16
|
+
return 'n/a';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const date = new Date(value);
|
|
20
|
+
if (Number.isNaN(date.getTime())) {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return date.toLocaleTimeString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderHeaderLines(params) {
|
|
28
|
+
const {
|
|
29
|
+
snapshot,
|
|
30
|
+
flow,
|
|
31
|
+
workspacePath,
|
|
32
|
+
view,
|
|
33
|
+
runFilter,
|
|
34
|
+
watchEnabled,
|
|
35
|
+
watchStatus,
|
|
36
|
+
lastRefreshAt,
|
|
37
|
+
width
|
|
38
|
+
} = params;
|
|
39
|
+
|
|
40
|
+
const projectName = snapshot?.project?.name || 'Unnamed FIRE project';
|
|
41
|
+
const topLine = `specsmd dashboard | ${flow.toUpperCase()} | ${projectName}`;
|
|
42
|
+
const subLine = [
|
|
43
|
+
`path: ${workspacePath}`,
|
|
44
|
+
`updated: ${formatTime(lastRefreshAt)}`,
|
|
45
|
+
`watch: ${watchEnabled ? watchStatus : 'off'}`,
|
|
46
|
+
`view: ${view}`,
|
|
47
|
+
`filter: ${runFilter}`
|
|
48
|
+
].join(' | ');
|
|
49
|
+
|
|
50
|
+
const horizontal = '-'.repeat(Math.max(20, Math.min(width || 120, 120)));
|
|
51
|
+
|
|
52
|
+
return [
|
|
53
|
+
truncate(topLine, width),
|
|
54
|
+
truncate(subLine, width),
|
|
55
|
+
truncate(horizontal, width)
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
renderHeaderLines,
|
|
61
|
+
truncate
|
|
62
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const { truncate } = require('./header');
|
|
2
|
+
|
|
3
|
+
function renderHelpLines(showHelp, width) {
|
|
4
|
+
if (!showHelp) {
|
|
5
|
+
return [truncate('Press h to show keyboard shortcuts.', width)];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return [
|
|
9
|
+
truncate('Keys: q quit | r refresh | h/? toggle help | tab cycle view | 1 runs | 2 overview | f cycle run filter', width)
|
|
10
|
+
];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
renderHelpLines
|
|
15
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const { truncate } = require('./header');
|
|
2
|
+
|
|
3
|
+
function safePercent(part, total) {
|
|
4
|
+
if (!total || total <= 0) {
|
|
5
|
+
return 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return Math.round((part / total) * 100);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function renderStatsLines(snapshot, width) {
|
|
12
|
+
if (!snapshot?.initialized) {
|
|
13
|
+
return [truncate('stats: waiting for .specs-fire/state.yaml initialization', width)];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const stats = snapshot.stats;
|
|
17
|
+
const workItemProgress = `${stats.completedWorkItems}/${stats.totalWorkItems}`;
|
|
18
|
+
const workItemPct = safePercent(stats.completedWorkItems, stats.totalWorkItems);
|
|
19
|
+
|
|
20
|
+
const intentProgress = `${stats.completedIntents}/${stats.totalIntents}`;
|
|
21
|
+
const intentPct = safePercent(stats.completedIntents, stats.totalIntents);
|
|
22
|
+
|
|
23
|
+
const line = [
|
|
24
|
+
`Intents ${intentProgress} (${intentPct}%)`,
|
|
25
|
+
`Work items ${workItemProgress} (${workItemPct}%)`,
|
|
26
|
+
`Runs ${stats.activeRunsCount} active / ${stats.completedRuns} completed`,
|
|
27
|
+
`Blocked ${stats.blockedWorkItems}`
|
|
28
|
+
].join(' | ');
|
|
29
|
+
|
|
30
|
+
return [truncate(line, width)];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
renderStatsLines
|
|
35
|
+
};
|