specsmd 0.0.0-dev.86 → 0.0.0-dev.87

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.
Files changed (42) hide show
  1. package/README.md +15 -0
  2. package/bin/cli.js +15 -1
  3. package/flows/fire/agents/builder/agent.md +2 -2
  4. package/flows/fire/agents/builder/skills/code-review/SKILL.md +1 -1
  5. package/flows/fire/agents/builder/skills/run-execute/SKILL.md +16 -7
  6. package/flows/fire/agents/builder/skills/run-execute/scripts/complete-run.cjs +22 -3
  7. package/flows/fire/agents/builder/skills/run-execute/scripts/init-run.cjs +63 -20
  8. package/flows/fire/agents/builder/skills/run-execute/scripts/update-checkpoint.cjs +254 -0
  9. package/flows/fire/agents/builder/skills/run-execute/scripts/update-phase.cjs +17 -6
  10. package/flows/fire/agents/builder/skills/run-status/SKILL.md +1 -1
  11. package/flows/fire/agents/orchestrator/agent.md +1 -1
  12. package/flows/fire/agents/orchestrator/skills/status/SKILL.md +2 -2
  13. package/flows/fire/memory-bank.yaml +4 -4
  14. package/lib/dashboard/aidlc/parser.js +581 -0
  15. package/lib/dashboard/fire/model.js +382 -0
  16. package/lib/dashboard/fire/parser.js +470 -0
  17. package/lib/dashboard/flow-detect.js +86 -0
  18. package/lib/dashboard/git/changes.js +362 -0
  19. package/lib/dashboard/git/worktrees.js +248 -0
  20. package/lib/dashboard/index.js +709 -0
  21. package/lib/dashboard/runtime/watch-runtime.js +122 -0
  22. package/lib/dashboard/simple/parser.js +293 -0
  23. package/lib/dashboard/tui/app.js +1675 -0
  24. package/lib/dashboard/tui/components/error-banner.js +35 -0
  25. package/lib/dashboard/tui/components/header.js +60 -0
  26. package/lib/dashboard/tui/components/help-footer.js +15 -0
  27. package/lib/dashboard/tui/components/stats-strip.js +35 -0
  28. package/lib/dashboard/tui/file-entries.js +383 -0
  29. package/lib/dashboard/tui/flow-builders.js +991 -0
  30. package/lib/dashboard/tui/git-builders.js +218 -0
  31. package/lib/dashboard/tui/helpers.js +236 -0
  32. package/lib/dashboard/tui/overlays.js +242 -0
  33. package/lib/dashboard/tui/preview.js +220 -0
  34. package/lib/dashboard/tui/renderer.js +76 -0
  35. package/lib/dashboard/tui/row-builders.js +797 -0
  36. package/lib/dashboard/tui/sections.js +45 -0
  37. package/lib/dashboard/tui/store.js +44 -0
  38. package/lib/dashboard/tui/views/overview-view.js +61 -0
  39. package/lib/dashboard/tui/views/runs-view.js +93 -0
  40. package/lib/dashboard/tui/worktree-builders.js +229 -0
  41. package/lib/installers/CodexInstaller.js +72 -1
  42. package/package.json +7 -3
@@ -0,0 +1,1675 @@
1
+ const { createWatchRuntime } = require('../runtime/watch-runtime');
2
+ const { createInitialUIState } = require('./store');
3
+
4
+ const {
5
+ stringWidth,
6
+ toDashboardError,
7
+ safeJsonHash,
8
+ resolveIconSet,
9
+ truncate,
10
+ resolveFrameWidth,
11
+ fitLines,
12
+ clampIndex
13
+ } = require('./helpers');
14
+
15
+ const {
16
+ getEffectiveFlow,
17
+ detectDashboardApprovalGate,
18
+ detectFireRunApprovalGate,
19
+ detectAidlcBoltApprovalGate,
20
+ buildHeaderLine,
21
+ buildErrorLines,
22
+ buildStatsLines,
23
+ buildWarningsLines,
24
+ getPanelTitles
25
+ } = require('./flow-builders');
26
+
27
+ const {
28
+ getGitChangesSnapshot,
29
+ buildGitStatusPanelLines,
30
+ buildGitCommitRows,
31
+ buildGitChangeGroups
32
+ } = require('./git-builders');
33
+
34
+ const {
35
+ hasMultipleWorktrees,
36
+ getWorktreeItems,
37
+ getSelectedWorktree,
38
+ getWorktreeDisplayName,
39
+ buildWorktreeRows,
40
+ buildOtherWorktreeActiveGroups,
41
+ getOtherWorktreeEmptyMessage,
42
+ buildWorktreeOverlayLines
43
+ } = require('./worktree-builders');
44
+
45
+ const { getSectionOrderForView, cycleSection } = require('./sections');
46
+
47
+ const {
48
+ getNoCurrentMessage,
49
+ getNoFileMessage,
50
+ getNoCompletedMessage
51
+ } = require('./file-entries');
52
+
53
+ const {
54
+ buildCurrentGroups,
55
+ toExpandableRows,
56
+ buildRunFileEntityGroups,
57
+ buildOverviewIntentGroups,
58
+ buildCompletedGroups,
59
+ buildStandardsRows,
60
+ toInfoRows,
61
+ toLoadingRows,
62
+ buildInteractiveRowsLines,
63
+ getSelectedRow,
64
+ rowToFileEntry,
65
+ firstFileEntryFromRows,
66
+ rowToWorktreeId,
67
+ moveRowSelection,
68
+ openFileWithDefaultApp
69
+ } = require('./row-builders');
70
+
71
+ const {
72
+ buildQuickHelpText,
73
+ buildGitCommandStrip,
74
+ buildGitCommandLogLine,
75
+ buildHelpOverlayLines
76
+ } = require('./overlays');
77
+
78
+ const {
79
+ buildPreviewLines,
80
+ allocateSingleColumnPanels
81
+ } = require('./preview');
82
+
83
+ function createDashboardApp(deps) {
84
+ const {
85
+ React,
86
+ ink,
87
+ inkUi,
88
+ parseSnapshot,
89
+ parseSnapshotForFlow,
90
+ workspacePath,
91
+ rootPath,
92
+ flow,
93
+ availableFlows,
94
+ resolveRootPathForFlow,
95
+ resolveRootPathsForFlow,
96
+ refreshMs,
97
+ watchEnabled,
98
+ initialSnapshot,
99
+ initialError
100
+ } = deps;
101
+
102
+ const { Box, Text, useApp, useInput, useStdout } = ink;
103
+ const { useState, useEffect, useCallback, useRef } = React;
104
+ const Spinner = inkUi && typeof inkUi.Spinner === 'function'
105
+ ? inkUi.Spinner
106
+ : null;
107
+
108
+ function SectionPanel(props) {
109
+ const {
110
+ title,
111
+ lines,
112
+ width,
113
+ maxLines,
114
+ borderColor,
115
+ marginBottom,
116
+ dense,
117
+ focused
118
+ } = props;
119
+
120
+ const contentWidth = Math.max(1, width - (dense ? 2 : 4));
121
+ const visibleLines = fitLines(lines, maxLines, contentWidth);
122
+ const panelBorderColor = focused ? 'cyan' : (borderColor || 'gray');
123
+ const titleColor = focused ? 'black' : 'cyan';
124
+ const titleBackground = focused ? 'cyan' : undefined;
125
+
126
+ return React.createElement(
127
+ Box,
128
+ {
129
+ flexDirection: 'column',
130
+ borderStyle: dense ? 'single' : 'round',
131
+ borderColor: panelBorderColor,
132
+ paddingX: dense ? 0 : 1,
133
+ width,
134
+ marginBottom: marginBottom || 0
135
+ },
136
+ React.createElement(
137
+ Text,
138
+ { bold: true, color: titleColor, backgroundColor: titleBackground },
139
+ truncate(title, contentWidth)
140
+ ),
141
+ ...visibleLines.map((line, index) => {
142
+ if (line.loading && Spinner) {
143
+ return React.createElement(
144
+ Box,
145
+ { key: `${title}-${index}` },
146
+ React.createElement(Spinner, { label: truncate(line.text, contentWidth) })
147
+ );
148
+ }
149
+
150
+ return React.createElement(
151
+ Text,
152
+ {
153
+ key: `${title}-${index}`,
154
+ color: line.color,
155
+ bold: line.bold
156
+ },
157
+ line.text
158
+ );
159
+ })
160
+ );
161
+ }
162
+
163
+ function TabsBar(props) {
164
+ const { view, width, icons, flow: activeFlow } = props;
165
+ const effectiveFlow = String(activeFlow || '').toLowerCase();
166
+ const primaryLabel = effectiveFlow === 'aidlc' ? 'BOLTS' : (effectiveFlow === 'simple' ? 'SPECS' : 'RUNS');
167
+ const completedLabel = effectiveFlow === 'aidlc' ? 'COMPLETED BOLTS' : (effectiveFlow === 'simple' ? 'COMPLETED SPECS' : 'COMPLETED RUNS');
168
+ const tabs = [
169
+ { id: 'runs', label: `1 ${icons.runs} ${primaryLabel}` },
170
+ { id: 'intents', label: `2 ${icons.overview} INTENTS` },
171
+ { id: 'completed', label: `3 ${icons.runs} ${completedLabel}` },
172
+ { id: 'health', label: `4 ${icons.health} STANDARDS/HEALTH` },
173
+ { id: 'git', label: `5 ${icons.git} GIT CHANGES` }
174
+ ];
175
+ const maxWidth = Math.max(8, Math.floor(width));
176
+ const segments = [];
177
+ let consumed = 0;
178
+
179
+ for (const tab of tabs) {
180
+ const isActive = tab.id === view;
181
+ const segmentText = isActive ? `[${tab.label}]` : tab.label;
182
+ const separator = segments.length > 0 ? ' ' : '';
183
+ const segmentWidth = stringWidth(separator) + stringWidth(segmentText);
184
+ if (consumed + segmentWidth > maxWidth) {
185
+ break;
186
+ }
187
+
188
+ if (separator !== '') {
189
+ segments.push({
190
+ key: `${tab.id}:sep`,
191
+ text: separator,
192
+ active: false
193
+ });
194
+ }
195
+ segments.push({
196
+ key: tab.id,
197
+ text: segmentText,
198
+ active: isActive
199
+ });
200
+ consumed += segmentWidth;
201
+ }
202
+
203
+ if (segments.length === 0) {
204
+ const fallback = tabs.find((tab) => tab.id === view) || tabs[0];
205
+ const fallbackText = truncate(`[${fallback.label}]`, maxWidth);
206
+ return React.createElement(Text, { color: 'white', bold: true }, fallbackText);
207
+ }
208
+
209
+ return React.createElement(
210
+ Box,
211
+ { width: maxWidth, flexWrap: 'nowrap' },
212
+ ...segments.map((segment) => React.createElement(
213
+ Text,
214
+ {
215
+ key: segment.key,
216
+ bold: segment.active,
217
+ color: segment.active ? 'white' : 'gray',
218
+ backgroundColor: segment.active ? 'blue' : undefined
219
+ },
220
+ segment.text
221
+ ))
222
+ );
223
+ }
224
+
225
+ function FlowBar(props) {
226
+ const { activeFlow, width, flowIds } = props;
227
+ if (!Array.isArray(flowIds) || flowIds.length <= 1) {
228
+ return null;
229
+ }
230
+ const maxWidth = Math.max(8, Math.floor(width));
231
+ const segments = [];
232
+ let consumed = 0;
233
+
234
+ for (const flowId of flowIds) {
235
+ const isActive = flowId === activeFlow;
236
+ const segmentText = isActive ? `[${flowId.toUpperCase()}]` : flowId.toUpperCase();
237
+ const separator = segments.length > 0 ? ' ' : '';
238
+ const segmentWidth = stringWidth(separator) + stringWidth(segmentText);
239
+ if (consumed + segmentWidth > maxWidth) {
240
+ break;
241
+ }
242
+
243
+ if (separator !== '') {
244
+ segments.push({
245
+ key: `${flowId}:sep`,
246
+ text: separator,
247
+ active: false
248
+ });
249
+ }
250
+ segments.push({
251
+ key: flowId,
252
+ text: segmentText,
253
+ active: isActive
254
+ });
255
+ consumed += segmentWidth;
256
+ }
257
+
258
+ if (segments.length === 0) {
259
+ const fallback = (activeFlow || flowIds[0] || 'flow').toUpperCase();
260
+ return React.createElement(Text, { color: 'black', backgroundColor: 'green', bold: true }, truncate(`[${fallback}]`, maxWidth));
261
+ }
262
+
263
+ return React.createElement(
264
+ Box,
265
+ { width: maxWidth, flexWrap: 'nowrap' },
266
+ ...segments.map((segment) => React.createElement(
267
+ Text,
268
+ {
269
+ key: segment.key,
270
+ bold: segment.active,
271
+ color: segment.active ? 'black' : 'gray',
272
+ backgroundColor: segment.active ? 'green' : undefined
273
+ },
274
+ segment.text
275
+ ))
276
+ );
277
+ }
278
+
279
+ function DashboardApp() {
280
+ const { exit } = useApp();
281
+ const { stdout } = useStdout();
282
+
283
+ const fallbackFlow = (initialSnapshot?.flow || flow || 'fire').toLowerCase();
284
+ const availableFlowIds = Array.from(new Set(
285
+ (Array.isArray(availableFlows) && availableFlows.length > 0 ? availableFlows : [fallbackFlow])
286
+ .map((value) => String(value || '').toLowerCase().trim())
287
+ .filter(Boolean)
288
+ ));
289
+
290
+ const initialNormalizedError = initialError ? toDashboardError(initialError) : null;
291
+ const snapshotHashRef = useRef(safeJsonHash(initialSnapshot || null));
292
+ const errorHashRef = useRef(initialNormalizedError ? safeJsonHash(initialNormalizedError) : null);
293
+ const lastVPressRef = useRef(0);
294
+
295
+ const [activeFlow, setActiveFlow] = useState(fallbackFlow);
296
+ const [snapshot, setSnapshot] = useState(initialSnapshot || null);
297
+ const [error, setError] = useState(initialNormalizedError);
298
+ const [ui, setUi] = useState(createInitialUIState());
299
+ const [sectionFocus, setSectionFocus] = useState({
300
+ runs: 'current-run',
301
+ intents: 'intent-status',
302
+ completed: 'completed-runs',
303
+ health: 'standards',
304
+ git: 'git-status'
305
+ });
306
+ const [selectionBySection, setSelectionBySection] = useState({
307
+ worktrees: 0,
308
+ 'current-run': 0,
309
+ 'run-files': 0,
310
+ 'other-worktrees-active': 0,
311
+ 'intent-status': 0,
312
+ 'completed-runs': 0,
313
+ standards: 0,
314
+ stats: 0,
315
+ warnings: 0,
316
+ 'error-details': 0,
317
+ 'git-changes': 0,
318
+ 'git-commits': 0
319
+ });
320
+ const [expandedGroups, setExpandedGroups] = useState({});
321
+ const [previewTarget, setPreviewTarget] = useState(null);
322
+ const [overviewIntentFilter, setOverviewIntentFilter] = useState('next');
323
+ const [deferredTabsReady, setDeferredTabsReady] = useState(false);
324
+ const [previewOpen, setPreviewOpen] = useState(false);
325
+ const [paneFocus, setPaneFocus] = useState('main');
326
+ const [overlayPreviewOpen, setOverlayPreviewOpen] = useState(false);
327
+ const [worktreeOverlayOpen, setWorktreeOverlayOpen] = useState(false);
328
+ const [worktreeOverlayIndex, setWorktreeOverlayIndex] = useState(0);
329
+ const [selectedWorktreeId, setSelectedWorktreeId] = useState(
330
+ initialSnapshot?.dashboardWorktrees?.selectedWorktreeId || null
331
+ );
332
+ const [previewScroll, setPreviewScroll] = useState(0);
333
+ const [statusLine, setStatusLine] = useState('');
334
+ const [lastRefreshAt, setLastRefreshAt] = useState(new Date().toISOString());
335
+ const [watchStatus, setWatchStatus] = useState(watchEnabled ? 'watching' : 'off');
336
+ const [terminalSize, setTerminalSize] = useState(() => ({
337
+ columns: stdout?.columns || process.stdout.columns || 120,
338
+ rows: stdout?.rows || process.stdout.rows || 40
339
+ }));
340
+ const icons = resolveIconSet();
341
+ const parseSnapshotForActiveFlow = useCallback(async (flowId, context = {}) => {
342
+ if (typeof parseSnapshotForFlow === 'function') {
343
+ return parseSnapshotForFlow(flowId, context);
344
+ }
345
+ if (typeof parseSnapshot === 'function') {
346
+ return parseSnapshot();
347
+ }
348
+ return {
349
+ ok: false,
350
+ error: {
351
+ code: 'PARSE_CALLBACK_MISSING',
352
+ message: 'Dashboard parser callback is not configured.'
353
+ }
354
+ };
355
+ }, [parseSnapshotForFlow, parseSnapshot]);
356
+
357
+ const previewVisibleRows = Number.isFinite(terminalSize.rows) ? terminalSize.rows : (process.stdout.rows || 40);
358
+ const showErrorPanelForSections = Boolean(error) && previewVisibleRows >= 18;
359
+ const worktreeSectionEnabled = hasMultipleWorktrees(snapshot);
360
+ const otherWorktreesSectionEnabled = worktreeSectionEnabled;
361
+
362
+ const getAvailableSections = useCallback((viewId) => {
363
+ const base = getSectionOrderForView(viewId, {
364
+ includeWorktrees: worktreeSectionEnabled,
365
+ includeOtherWorktrees: otherWorktreesSectionEnabled
366
+ });
367
+ return base.filter((sectionKey) => sectionKey !== 'error-details' || showErrorPanelForSections);
368
+ }, [showErrorPanelForSections, worktreeSectionEnabled, otherWorktreesSectionEnabled]);
369
+
370
+ const effectiveFlow = getEffectiveFlow(activeFlow, snapshot);
371
+ const approvalGate = detectDashboardApprovalGate(snapshot, activeFlow);
372
+ const approvalGateLine = approvalGate
373
+ ? `[APPROVAL NEEDED] ${approvalGate.message}`
374
+ : '';
375
+ const currentGroups = buildCurrentGroups(snapshot, activeFlow);
376
+ const currentExpandedGroups = { ...expandedGroups };
377
+ for (const group of currentGroups) {
378
+ if (currentExpandedGroups[group.key] == null) {
379
+ currentExpandedGroups[group.key] = true;
380
+ }
381
+ }
382
+
383
+ const currentRunRowsBase = toExpandableRows(
384
+ currentGroups,
385
+ getNoCurrentMessage(effectiveFlow),
386
+ currentExpandedGroups
387
+ );
388
+ const currentRunRows = approvalGate
389
+ ? [
390
+ {
391
+ kind: 'info',
392
+ key: 'approval-gate',
393
+ label: approvalGateLine,
394
+ color: 'yellow',
395
+ bold: true,
396
+ selectable: false
397
+ },
398
+ ...currentRunRowsBase
399
+ ]
400
+ : currentRunRowsBase;
401
+ const worktreeRows = buildWorktreeRows(snapshot, activeFlow);
402
+
403
+ const shouldHydrateSecondaryTabs = deferredTabsReady || ui.view !== 'runs';
404
+ const runFileGroups = buildRunFileEntityGroups(snapshot, activeFlow, {
405
+ includeBacklog: shouldHydrateSecondaryTabs
406
+ });
407
+ const runFileExpandedGroups = { ...expandedGroups };
408
+ for (const group of runFileGroups) {
409
+ if (runFileExpandedGroups[group.key] == null) {
410
+ runFileExpandedGroups[group.key] = true;
411
+ }
412
+ }
413
+ const runFileRows = toExpandableRows(
414
+ runFileGroups,
415
+ getNoFileMessage(effectiveFlow),
416
+ runFileExpandedGroups
417
+ );
418
+ const otherWorktreeGroups = buildOtherWorktreeActiveGroups(snapshot, activeFlow);
419
+ const otherWorktreeRows = toExpandableRows(
420
+ otherWorktreeGroups,
421
+ getOtherWorktreeEmptyMessage(snapshot, activeFlow),
422
+ expandedGroups
423
+ );
424
+ const intentRows = shouldHydrateSecondaryTabs
425
+ ? [
426
+ {
427
+ kind: 'info',
428
+ key: 'intent-filter',
429
+ label: `filter ${overviewIntentFilter === 'completed' ? 'next | [COMPLETED]' : '[NEXT] | completed'} (n/x)`,
430
+ color: 'cyan',
431
+ bold: true,
432
+ selectable: false
433
+ },
434
+ ...toExpandableRows(
435
+ buildOverviewIntentGroups(snapshot, activeFlow, overviewIntentFilter),
436
+ overviewIntentFilter === 'completed' ? 'No completed intents yet' : 'No upcoming intents',
437
+ expandedGroups
438
+ )
439
+ ]
440
+ : toLoadingRows('Loading intents...', 'intent-loading');
441
+ const completedRows = shouldHydrateSecondaryTabs
442
+ ? toExpandableRows(
443
+ buildCompletedGroups(snapshot, activeFlow),
444
+ getNoCompletedMessage(effectiveFlow),
445
+ expandedGroups
446
+ )
447
+ : toLoadingRows('Loading completed items...', 'completed-loading');
448
+ const standardsRows = shouldHydrateSecondaryTabs
449
+ ? buildStandardsRows(snapshot, activeFlow)
450
+ : toLoadingRows('Loading standards...', 'standards-loading');
451
+ const statsRows = shouldHydrateSecondaryTabs
452
+ ? toInfoRows(
453
+ buildStatsLines(snapshot, 200, activeFlow),
454
+ 'stats',
455
+ 'No stats available'
456
+ )
457
+ : toLoadingRows('Loading stats...', 'stats-loading');
458
+ const warningsRows = shouldHydrateSecondaryTabs
459
+ ? toInfoRows(
460
+ buildWarningsLines(snapshot, 200),
461
+ 'warnings',
462
+ 'No warnings'
463
+ )
464
+ : toLoadingRows('Loading warnings...', 'warnings-loading');
465
+ const errorDetailsRows = shouldHydrateSecondaryTabs
466
+ ? toInfoRows(
467
+ buildErrorLines(error, 200),
468
+ 'error-details',
469
+ 'No error details'
470
+ )
471
+ : toLoadingRows('Loading error details...', 'error-loading');
472
+ const gitRows = shouldHydrateSecondaryTabs
473
+ ? (() => {
474
+ const git = getGitChangesSnapshot(snapshot);
475
+ return toExpandableRows(
476
+ buildGitChangeGroups(snapshot),
477
+ git.available ? 'Working tree clean' : 'No git changes',
478
+ expandedGroups
479
+ );
480
+ })()
481
+ : toLoadingRows('Loading git changes...', 'git-loading');
482
+ const gitCommitRows = shouldHydrateSecondaryTabs
483
+ ? buildGitCommitRows(snapshot)
484
+ : toLoadingRows('Loading commit history...', 'git-commits-loading');
485
+
486
+ const rowsBySection = {
487
+ worktrees: worktreeRows,
488
+ 'current-run': currentRunRows,
489
+ 'run-files': runFileRows,
490
+ 'other-worktrees-active': otherWorktreeRows,
491
+ 'intent-status': intentRows,
492
+ 'completed-runs': completedRows,
493
+ standards: standardsRows,
494
+ stats: statsRows,
495
+ warnings: warningsRows,
496
+ 'error-details': errorDetailsRows,
497
+ 'git-changes': gitRows,
498
+ 'git-commits': gitCommitRows
499
+ };
500
+ const worktreeItemsList = getWorktreeItems(snapshot);
501
+ const selectedWorktree = getSelectedWorktree(snapshot);
502
+ const selectedWorktreeLabel = selectedWorktree ? getWorktreeDisplayName(selectedWorktree) : null;
503
+ const worktreeWatchSignature = `${snapshot?.dashboardWorktrees?.selectedWorktreeId || ''}|${worktreeItemsList
504
+ .map((item) => `${item.id}:${item.status}:${item.activeCount}:${item.flowAvailable ? '1' : '0'}`)
505
+ .join(',')}`;
506
+ const rowLengthSignature = Object.entries(rowsBySection)
507
+ .map(([key, rowsForSection]) => `${key}:${Array.isArray(rowsForSection) ? rowsForSection.length : 0}`)
508
+ .join('|');
509
+
510
+ const currentSectionOrder = getAvailableSections(ui.view);
511
+ const focusedSection = currentSectionOrder.includes(sectionFocus[ui.view])
512
+ ? sectionFocus[ui.view]
513
+ : (currentSectionOrder[0] || 'current-run');
514
+
515
+ const focusedRows = rowsBySection[focusedSection] || [];
516
+ const focusedIndex = selectionBySection[focusedSection] || 0;
517
+ const selectedFocusedRow = getSelectedRow(focusedRows, focusedIndex);
518
+ const selectedFocusedFile = rowToFileEntry(selectedFocusedRow);
519
+ const selectedGitRow = getSelectedRow(gitRows, selectionBySection['git-changes'] || 0);
520
+ const selectedGitFile = rowToFileEntry(selectedGitRow);
521
+ const selectedGitCommitRow = getSelectedRow(gitCommitRows, selectionBySection['git-commits'] || 0);
522
+ const selectedGitCommit = rowToFileEntry(selectedGitCommitRow);
523
+ const firstGitFile = firstFileEntryFromRows(gitRows);
524
+
525
+ const refresh = useCallback(async (overrideSelectedWorktreeId = null) => {
526
+ const now = new Date().toISOString();
527
+ const requestedWorktreeId = typeof overrideSelectedWorktreeId === 'string' && overrideSelectedWorktreeId.trim() !== ''
528
+ ? overrideSelectedWorktreeId.trim()
529
+ : selectedWorktreeId;
530
+
531
+ try {
532
+ const result = await parseSnapshotForActiveFlow(activeFlow, {
533
+ selectedWorktreeId: requestedWorktreeId
534
+ });
535
+
536
+ if (result?.ok) {
537
+ const nextSnapshot = result.snapshot
538
+ ? { ...result.snapshot, flow: getEffectiveFlow(activeFlow, result.snapshot) }
539
+ : null;
540
+ const nextSnapshotHash = safeJsonHash(nextSnapshot);
541
+
542
+ if (nextSnapshotHash !== snapshotHashRef.current) {
543
+ snapshotHashRef.current = nextSnapshotHash;
544
+ setSnapshot(nextSnapshot);
545
+ setLastRefreshAt(now);
546
+ }
547
+
548
+ const nextSelectedWorktreeId = nextSnapshot?.dashboardWorktrees?.selectedWorktreeId;
549
+ if (typeof nextSelectedWorktreeId === 'string' && nextSelectedWorktreeId !== '' && nextSelectedWorktreeId !== selectedWorktreeId) {
550
+ setSelectedWorktreeId(nextSelectedWorktreeId);
551
+ }
552
+
553
+ if (errorHashRef.current !== null) {
554
+ errorHashRef.current = null;
555
+ setError(null);
556
+ setLastRefreshAt(now);
557
+ }
558
+
559
+ if (watchEnabled) {
560
+ setWatchStatus((previous) => (previous === 'watching' ? previous : 'watching'));
561
+ }
562
+ } else {
563
+ const nextError = toDashboardError(result?.error, 'PARSE_ERROR');
564
+ const nextErrorHash = safeJsonHash(nextError);
565
+
566
+ if (nextErrorHash !== errorHashRef.current) {
567
+ errorHashRef.current = nextErrorHash;
568
+ setError(nextError);
569
+ setLastRefreshAt(now);
570
+ }
571
+ }
572
+ } catch (refreshError) {
573
+ const nextError = toDashboardError(refreshError, 'REFRESH_FAILED');
574
+ const nextErrorHash = safeJsonHash(nextError);
575
+
576
+ if (nextErrorHash !== errorHashRef.current) {
577
+ errorHashRef.current = nextErrorHash;
578
+ setError(nextError);
579
+ setLastRefreshAt(now);
580
+ }
581
+ }
582
+ }, [activeFlow, parseSnapshotForActiveFlow, selectedWorktreeId, watchEnabled]);
583
+
584
+ const switchToWorktree = useCallback((nextWorktreeId, options = {}) => {
585
+ const normalizedNextId = typeof nextWorktreeId === 'string' ? nextWorktreeId.trim() : '';
586
+ if (normalizedNextId === '') {
587
+ setStatusLine('No worktree selected.');
588
+ return false;
589
+ }
590
+
591
+ const nextItem = worktreeItemsList.find((item) => item.id === normalizedNextId);
592
+ if (!nextItem) {
593
+ setStatusLine('Selected worktree is no longer available.');
594
+ return false;
595
+ }
596
+
597
+ if (!nextItem.flowAvailable) {
598
+ setStatusLine(`Flow is unavailable in worktree: ${getWorktreeDisplayName(nextItem)}`);
599
+ return false;
600
+ }
601
+
602
+ const changed = normalizedNextId !== selectedWorktreeId;
603
+ setSelectedWorktreeId(normalizedNextId);
604
+ setPreviewTarget(null);
605
+ setPreviewOpen(false);
606
+ setOverlayPreviewOpen(false);
607
+ setPreviewScroll(0);
608
+ setPaneFocus('main');
609
+
610
+ if (changed || options.forceRefresh) {
611
+ setStatusLine(`Switched to worktree: ${getWorktreeDisplayName(nextItem)}`);
612
+ void refresh(normalizedNextId);
613
+ }
614
+
615
+ return true;
616
+ }, [refresh, selectedWorktreeId, worktreeItemsList]);
617
+
618
+ useInput((input, key) => {
619
+ if ((key.ctrl && input === 'c') || input === 'q') {
620
+ exit();
621
+ return;
622
+ }
623
+
624
+ if (input === 'r') {
625
+ void refresh();
626
+ return;
627
+ }
628
+
629
+ if (input === 'h' || input === '?') {
630
+ setUi((previous) => ({ ...previous, showHelp: !previous.showHelp }));
631
+ return;
632
+ }
633
+
634
+ if (key.escape && ui.showHelp) {
635
+ setUi((previous) => ({ ...previous, showHelp: false }));
636
+ return;
637
+ }
638
+
639
+ if (ui.showHelp) {
640
+ return;
641
+ }
642
+
643
+ if (worktreeOverlayOpen) {
644
+ if (key.escape) {
645
+ setWorktreeOverlayOpen(false);
646
+ return;
647
+ }
648
+
649
+ if (key.upArrow || input === 'k') {
650
+ setWorktreeOverlayIndex((previous) => Math.max(0, previous - 1));
651
+ return;
652
+ }
653
+
654
+ if (key.downArrow || input === 'j') {
655
+ setWorktreeOverlayIndex((previous) => Math.min(Math.max(0, worktreeItemsList.length - 1), previous + 1));
656
+ return;
657
+ }
658
+
659
+ if (key.return || key.enter) {
660
+ const selectedOverlayItem = worktreeItemsList[clampIndex(worktreeOverlayIndex, worktreeItemsList.length || 1)];
661
+ switchToWorktree(selectedOverlayItem?.id || '', { forceRefresh: true });
662
+ setWorktreeOverlayOpen(false);
663
+ return;
664
+ }
665
+
666
+ return;
667
+ }
668
+
669
+ if (input === '1') {
670
+ setUi((previous) => ({ ...previous, view: 'runs' }));
671
+ setPaneFocus('main');
672
+ return;
673
+ }
674
+
675
+ if (input === '2') {
676
+ setUi((previous) => ({ ...previous, view: 'intents' }));
677
+ setPaneFocus('main');
678
+ return;
679
+ }
680
+
681
+ if (input === '3') {
682
+ setUi((previous) => ({ ...previous, view: 'completed' }));
683
+ setPaneFocus('main');
684
+ return;
685
+ }
686
+
687
+ if (input === '4') {
688
+ setUi((previous) => ({ ...previous, view: 'health' }));
689
+ setPaneFocus('main');
690
+ return;
691
+ }
692
+
693
+ if (input === '5') {
694
+ setUi((previous) => ({ ...previous, view: 'git' }));
695
+ setPaneFocus('main');
696
+ return;
697
+ }
698
+
699
+ if ((input === ']' || input === 'm') && availableFlowIds.length > 1) {
700
+ snapshotHashRef.current = safeJsonHash(null);
701
+ errorHashRef.current = null;
702
+ setSnapshot(null);
703
+ setError(null);
704
+ setActiveFlow((previous) => {
705
+ const index = availableFlowIds.indexOf(previous);
706
+ const nextIndex = index >= 0
707
+ ? ((index + 1) % availableFlowIds.length)
708
+ : 0;
709
+ return availableFlowIds[nextIndex];
710
+ });
711
+ setSelectionBySection({
712
+ worktrees: 0,
713
+ 'current-run': 0,
714
+ 'run-files': 0,
715
+ 'other-worktrees-active': 0,
716
+ 'intent-status': 0,
717
+ 'completed-runs': 0,
718
+ standards: 0,
719
+ stats: 0,
720
+ warnings: 0,
721
+ 'error-details': 0,
722
+ 'git-changes': 0,
723
+ 'git-commits': 0
724
+ });
725
+ setSectionFocus({
726
+ runs: 'current-run',
727
+ intents: 'intent-status',
728
+ completed: 'completed-runs',
729
+ health: 'standards',
730
+ git: 'git-status'
731
+ });
732
+ setOverviewIntentFilter('next');
733
+ setExpandedGroups({});
734
+ setPreviewTarget(null);
735
+ setPreviewOpen(false);
736
+ setOverlayPreviewOpen(false);
737
+ setWorktreeOverlayOpen(false);
738
+ setPreviewScroll(0);
739
+ setPaneFocus('main');
740
+ return;
741
+ }
742
+
743
+ if (input === '[' && availableFlowIds.length > 1) {
744
+ snapshotHashRef.current = safeJsonHash(null);
745
+ errorHashRef.current = null;
746
+ setSnapshot(null);
747
+ setError(null);
748
+ setActiveFlow((previous) => {
749
+ const index = availableFlowIds.indexOf(previous);
750
+ const nextIndex = index >= 0
751
+ ? ((index - 1 + availableFlowIds.length) % availableFlowIds.length)
752
+ : 0;
753
+ return availableFlowIds[nextIndex];
754
+ });
755
+ setSelectionBySection({
756
+ worktrees: 0,
757
+ 'current-run': 0,
758
+ 'run-files': 0,
759
+ 'other-worktrees-active': 0,
760
+ 'intent-status': 0,
761
+ 'completed-runs': 0,
762
+ standards: 0,
763
+ stats: 0,
764
+ warnings: 0,
765
+ 'error-details': 0,
766
+ 'git-changes': 0,
767
+ 'git-commits': 0
768
+ });
769
+ setSectionFocus({
770
+ runs: 'current-run',
771
+ intents: 'intent-status',
772
+ completed: 'completed-runs',
773
+ health: 'standards',
774
+ git: 'git-status'
775
+ });
776
+ setOverviewIntentFilter('next');
777
+ setExpandedGroups({});
778
+ setPreviewTarget(null);
779
+ setPreviewOpen(false);
780
+ setOverlayPreviewOpen(false);
781
+ setWorktreeOverlayOpen(false);
782
+ setPreviewScroll(0);
783
+ setPaneFocus('main');
784
+ return;
785
+ }
786
+
787
+ const availableSections = getAvailableSections(ui.view);
788
+ const activeSection = availableSections.includes(sectionFocus[ui.view])
789
+ ? sectionFocus[ui.view]
790
+ : (availableSections[0] || 'current-run');
791
+
792
+ if (key.tab && previewOpen) {
793
+ setPaneFocus((previous) => (previous === 'main' ? 'preview' : 'main'));
794
+ return;
795
+ }
796
+
797
+ if (ui.view === 'intents' && activeSection === 'intent-status') {
798
+ if (input === 'n') {
799
+ setOverviewIntentFilter('next');
800
+ return;
801
+ }
802
+ if (input === 'x') {
803
+ setOverviewIntentFilter('completed');
804
+ return;
805
+ }
806
+ if (key.rightArrow || key.leftArrow) {
807
+ setOverviewIntentFilter((previous) => (previous === 'completed' ? 'next' : 'completed'));
808
+ return;
809
+ }
810
+ }
811
+
812
+ if (input === 'g' || key.rightArrow) {
813
+ setSectionFocus((previous) => ({
814
+ ...previous,
815
+ [ui.view]: cycleSection(ui.view, activeSection, 1, availableSections)
816
+ }));
817
+ setPaneFocus('main');
818
+ return;
819
+ }
820
+
821
+ if (input === 'G' || key.leftArrow) {
822
+ setSectionFocus((previous) => ({
823
+ ...previous,
824
+ [ui.view]: cycleSection(ui.view, activeSection, -1, availableSections)
825
+ }));
826
+ setPaneFocus('main');
827
+ return;
828
+ }
829
+
830
+ if (ui.view === 'runs') {
831
+ if (input === 'b' && worktreeSectionEnabled) {
832
+ setSectionFocus((previous) => ({ ...previous, runs: 'worktrees' }));
833
+ setPaneFocus('main');
834
+ return;
835
+ }
836
+ if (input === 'a') {
837
+ setSectionFocus((previous) => ({ ...previous, runs: 'current-run' }));
838
+ setPaneFocus('main');
839
+ return;
840
+ }
841
+ if (input === 'f') {
842
+ setSectionFocus((previous) => ({ ...previous, runs: 'run-files' }));
843
+ setPaneFocus('main');
844
+ return;
845
+ }
846
+ if (input === 'u' && otherWorktreesSectionEnabled) {
847
+ setSectionFocus((previous) => ({ ...previous, runs: 'other-worktrees-active' }));
848
+ setPaneFocus('main');
849
+ return;
850
+ }
851
+ } else if (ui.view === 'intents') {
852
+ if (input === 'i') {
853
+ setSectionFocus((previous) => ({ ...previous, intents: 'intent-status' }));
854
+ return;
855
+ }
856
+ } else if (ui.view === 'completed') {
857
+ if (input === 'c') {
858
+ setSectionFocus((previous) => ({ ...previous, completed: 'completed-runs' }));
859
+ return;
860
+ }
861
+ } else if (ui.view === 'health') {
862
+ if (input === 's') {
863
+ setSectionFocus((previous) => ({ ...previous, health: 'standards' }));
864
+ return;
865
+ }
866
+ if (input === 't') {
867
+ setSectionFocus((previous) => ({ ...previous, health: 'stats' }));
868
+ return;
869
+ }
870
+ if (input === 'w') {
871
+ setSectionFocus((previous) => ({ ...previous, health: 'warnings' }));
872
+ return;
873
+ }
874
+ if (input === 'e' && showErrorPanelForSections) {
875
+ setSectionFocus((previous) => ({ ...previous, health: 'error-details' }));
876
+ return;
877
+ }
878
+ } else if (ui.view === 'git') {
879
+ if (input === '6') {
880
+ setSectionFocus((previous) => ({ ...previous, git: 'git-status' }));
881
+ setPaneFocus('main');
882
+ return;
883
+ }
884
+ if (input === '7') {
885
+ setSectionFocus((previous) => ({ ...previous, git: 'git-changes' }));
886
+ setPaneFocus('main');
887
+ return;
888
+ }
889
+ if (input === '8') {
890
+ setSectionFocus((previous) => ({ ...previous, git: 'git-commits' }));
891
+ setPaneFocus('main');
892
+ return;
893
+ }
894
+ if (input === '-') {
895
+ setSectionFocus((previous) => ({ ...previous, git: 'git-diff' }));
896
+ setPaneFocus('main');
897
+ return;
898
+ }
899
+ }
900
+
901
+ if (key.escape) {
902
+ if (overlayPreviewOpen) {
903
+ setOverlayPreviewOpen(false);
904
+ setPaneFocus('preview');
905
+ return;
906
+ }
907
+ if (previewOpen) {
908
+ setPreviewOpen(false);
909
+ setPreviewScroll(0);
910
+ setPaneFocus('main');
911
+ return;
912
+ }
913
+ }
914
+
915
+ if (key.upArrow || key.downArrow || input === 'j' || input === 'k') {
916
+ const moveDown = key.downArrow || input === 'j';
917
+ const moveUp = key.upArrow || input === 'k';
918
+
919
+ if (overlayPreviewOpen || (previewOpen && paneFocus === 'preview')) {
920
+ if (moveDown) {
921
+ setPreviewScroll((previous) => previous + 1);
922
+ } else if (moveUp) {
923
+ setPreviewScroll((previous) => Math.max(0, previous - 1));
924
+ }
925
+ return;
926
+ }
927
+
928
+ const targetSection = activeSection;
929
+ const targetRows = rowsBySection[targetSection] || [];
930
+ if (targetRows.length === 0) {
931
+ return;
932
+ }
933
+
934
+ const currentIndex = selectionBySection[targetSection] || 0;
935
+ const nextIndex = moveDown
936
+ ? moveRowSelection(targetRows, currentIndex, 1)
937
+ : moveRowSelection(targetRows, currentIndex, -1);
938
+
939
+ setSelectionBySection((previous) => ({
940
+ ...previous,
941
+ [targetSection]: nextIndex
942
+ }));
943
+
944
+ if (targetSection === 'worktrees' && worktreeSectionEnabled) {
945
+ const nextRow = getSelectedRow(targetRows, nextIndex);
946
+ const nextWorktreeId = rowToWorktreeId(nextRow);
947
+ if (nextWorktreeId && nextWorktreeId !== selectedWorktreeId) {
948
+ switchToWorktree(nextWorktreeId);
949
+ }
950
+ }
951
+
952
+ return;
953
+ }
954
+
955
+ if (key.return || key.enter) {
956
+ const rowsForSection = rowsBySection[activeSection] || [];
957
+ const selectedRow = getSelectedRow(rowsForSection, selectionBySection[activeSection] || 0);
958
+ if (selectedRow?.kind === 'group' && selectedRow.expandable) {
959
+ setExpandedGroups((previous) => ({
960
+ ...previous,
961
+ [selectedRow.key]: !previous[selectedRow.key]
962
+ }));
963
+ }
964
+ return;
965
+ }
966
+
967
+ if (input === 'v' || input === ' ' || key.space) {
968
+ const target = selectedFocusedFile || previewTarget;
969
+ if (!target) {
970
+ setStatusLine('Select a file row first.');
971
+ return;
972
+ }
973
+
974
+ const now = Date.now();
975
+ const isDoublePress = (now - lastVPressRef.current) <= 320;
976
+ lastVPressRef.current = now;
977
+
978
+ if (isDoublePress) {
979
+ setPreviewTarget(target);
980
+ setPreviewOpen(true);
981
+ setOverlayPreviewOpen(true);
982
+ setPreviewScroll(0);
983
+ setPaneFocus('preview');
984
+ return;
985
+ }
986
+
987
+ if (!previewOpen) {
988
+ setPreviewTarget(target);
989
+ setPreviewOpen(true);
990
+ setOverlayPreviewOpen(false);
991
+ setPreviewScroll(0);
992
+ setPaneFocus('main');
993
+ return;
994
+ }
995
+
996
+ if (overlayPreviewOpen) {
997
+ setOverlayPreviewOpen(false);
998
+ setPaneFocus('preview');
999
+ return;
1000
+ }
1001
+
1002
+ setPreviewOpen(false);
1003
+ setPreviewScroll(0);
1004
+ setPaneFocus('main');
1005
+ return;
1006
+ }
1007
+
1008
+ if (input === 'o') {
1009
+ const target = selectedFocusedFile || previewTarget;
1010
+ if (target?.previewType === 'git-commit-diff') {
1011
+ setStatusLine('Commit entries cannot be opened as files.');
1012
+ return;
1013
+ }
1014
+ const result = openFileWithDefaultApp(target?.path);
1015
+ setStatusLine(result.message);
1016
+ }
1017
+ });
1018
+
1019
+ useEffect(() => {
1020
+ void refresh();
1021
+ }, [refresh]);
1022
+
1023
+ useEffect(() => {
1024
+ const snapshotSelected = snapshot?.dashboardWorktrees?.selectedWorktreeId;
1025
+ if (typeof snapshotSelected !== 'string' || snapshotSelected === '') {
1026
+ return;
1027
+ }
1028
+ if (snapshotSelected !== selectedWorktreeId) {
1029
+ setSelectedWorktreeId(snapshotSelected);
1030
+ }
1031
+ }, [snapshot?.dashboardWorktrees?.selectedWorktreeId, selectedWorktreeId]);
1032
+
1033
+ useEffect(() => {
1034
+ if (!snapshot?.dashboardWorktrees?.hasPendingScans) {
1035
+ return undefined;
1036
+ }
1037
+ const timer = setTimeout(() => {
1038
+ void refresh();
1039
+ }, 350);
1040
+ return () => {
1041
+ clearTimeout(timer);
1042
+ };
1043
+ }, [snapshot?.dashboardWorktrees?.hasPendingScans, snapshot?.generatedAt, refresh]);
1044
+
1045
+ useEffect(() => {
1046
+ setSelectionBySection((previous) => {
1047
+ let changed = false;
1048
+ const next = { ...previous };
1049
+
1050
+ for (const [sectionKey, sectionRows] of Object.entries(rowsBySection)) {
1051
+ const previousValue = Number.isFinite(previous[sectionKey]) ? previous[sectionKey] : 0;
1052
+ const clampedValue = clampIndex(previousValue, sectionRows.length);
1053
+ if (previousValue !== clampedValue) {
1054
+ next[sectionKey] = clampedValue;
1055
+ changed = true;
1056
+ } else if (!(sectionKey in next)) {
1057
+ next[sectionKey] = clampedValue;
1058
+ changed = true;
1059
+ }
1060
+ }
1061
+
1062
+ return changed ? next : previous;
1063
+ });
1064
+ }, [activeFlow, rowLengthSignature, snapshot?.generatedAt]);
1065
+
1066
+ useEffect(() => {
1067
+ setDeferredTabsReady(false);
1068
+ const timer = setTimeout(() => {
1069
+ setDeferredTabsReady(true);
1070
+ }, 250);
1071
+ return () => {
1072
+ clearTimeout(timer);
1073
+ };
1074
+ }, [activeFlow]);
1075
+
1076
+ useEffect(() => {
1077
+ setPaneFocus('main');
1078
+ setWorktreeOverlayOpen(false);
1079
+ }, [ui.view]);
1080
+
1081
+ useEffect(() => {
1082
+ if (!previewOpen || overlayPreviewOpen || paneFocus !== 'main') {
1083
+ return;
1084
+ }
1085
+ if (!selectedFocusedFile?.path) {
1086
+ return;
1087
+ }
1088
+ if (previewTarget?.path === selectedFocusedFile.path) {
1089
+ return;
1090
+ }
1091
+ setPreviewTarget(selectedFocusedFile);
1092
+ setPreviewScroll(0);
1093
+ }, [previewOpen, overlayPreviewOpen, paneFocus, selectedFocusedFile?.path, previewTarget?.path]);
1094
+
1095
+ useEffect(() => {
1096
+ if (ui.view !== 'git') {
1097
+ return;
1098
+ }
1099
+ setPreviewScroll(0);
1100
+ }, [ui.view, focusedSection, selectedGitFile?.path, selectedGitCommit?.commitHash]);
1101
+
1102
+ useEffect(() => {
1103
+ if (statusLine === '') {
1104
+ return undefined;
1105
+ }
1106
+
1107
+ const timeout = setTimeout(() => {
1108
+ setStatusLine('');
1109
+ }, 3500);
1110
+
1111
+ return () => {
1112
+ clearTimeout(timeout);
1113
+ };
1114
+ }, [statusLine]);
1115
+
1116
+ useEffect(() => {
1117
+ if (!stdout || typeof stdout.on !== 'function') {
1118
+ setTerminalSize({
1119
+ columns: Math.max(1, process.stdout.columns || 120),
1120
+ rows: Math.max(1, process.stdout.rows || 40)
1121
+ });
1122
+ return undefined;
1123
+ }
1124
+
1125
+ const updateSize = () => {
1126
+ setTerminalSize({
1127
+ columns: Math.max(1, stdout.columns || process.stdout.columns || 120),
1128
+ rows: Math.max(1, stdout.rows || process.stdout.rows || 40)
1129
+ });
1130
+ };
1131
+
1132
+ updateSize();
1133
+ stdout.on('resize', updateSize);
1134
+ if (process.stdout !== stdout && typeof process.stdout.on === 'function') {
1135
+ process.stdout.on('resize', updateSize);
1136
+ }
1137
+
1138
+ return () => {
1139
+ if (typeof stdout.off === 'function') {
1140
+ stdout.off('resize', updateSize);
1141
+ } else if (typeof stdout.removeListener === 'function') {
1142
+ stdout.removeListener('resize', updateSize);
1143
+ }
1144
+ if (process.stdout !== stdout) {
1145
+ if (typeof process.stdout.off === 'function') {
1146
+ process.stdout.off('resize', updateSize);
1147
+ } else if (typeof process.stdout.removeListener === 'function') {
1148
+ process.stdout.removeListener('resize', updateSize);
1149
+ }
1150
+ }
1151
+ };
1152
+ }, [stdout]);
1153
+
1154
+ useEffect(() => {
1155
+ if (!watchEnabled) {
1156
+ return undefined;
1157
+ }
1158
+
1159
+ const resolvedRootCandidates = typeof resolveRootPathsForFlow === 'function'
1160
+ ? resolveRootPathsForFlow(activeFlow, snapshot?.dashboardWorktrees, selectedWorktreeId)
1161
+ : null;
1162
+ const candidateRoots = Array.isArray(resolvedRootCandidates) ? resolvedRootCandidates : [];
1163
+ const fallbackRoot = resolveRootPathForFlow
1164
+ ? resolveRootPathForFlow(activeFlow)
1165
+ : (rootPath || `${workspacePath}/.specs-fire`);
1166
+ const watchRoots = candidateRoots.length > 0 ? candidateRoots : [fallbackRoot];
1167
+
1168
+ const runtime = createWatchRuntime({
1169
+ rootPaths: watchRoots,
1170
+ debounceMs: 200,
1171
+ onRefresh: () => {
1172
+ void refresh();
1173
+ },
1174
+ onError: (watchError) => {
1175
+ const now = new Date().toISOString();
1176
+ setWatchStatus((previous) => (previous === 'reconnecting' ? previous : 'reconnecting'));
1177
+
1178
+ const nextError = toDashboardError(watchError, 'WATCH_ERROR');
1179
+ const nextErrorHash = safeJsonHash(nextError);
1180
+ if (nextErrorHash !== errorHashRef.current) {
1181
+ errorHashRef.current = nextErrorHash;
1182
+ setError(nextError);
1183
+ setLastRefreshAt(now);
1184
+ }
1185
+ }
1186
+ });
1187
+
1188
+ runtime.start();
1189
+ const fallbackIntervalMs = ui.view === 'git'
1190
+ ? Math.max(refreshMs, 1000)
1191
+ : Math.max(refreshMs, 5000);
1192
+ const interval = setInterval(() => {
1193
+ void refresh();
1194
+ }, fallbackIntervalMs);
1195
+
1196
+ return () => {
1197
+ clearInterval(interval);
1198
+ void runtime.close();
1199
+ };
1200
+ }, [watchEnabled, refreshMs, refresh, rootPath, workspacePath, resolveRootPathForFlow, resolveRootPathsForFlow, activeFlow, ui.view, worktreeWatchSignature, selectedWorktreeId]);
1201
+
1202
+ const cols = Number.isFinite(terminalSize.columns) ? terminalSize.columns : (process.stdout.columns || 120);
1203
+ const rows = Number.isFinite(terminalSize.rows) ? terminalSize.rows : (process.stdout.rows || 40);
1204
+
1205
+ const fullWidth = resolveFrameWidth(cols);
1206
+ const showFlowBar = availableFlowIds.length > 1;
1207
+ const showCommandLogLine = rows >= 8;
1208
+ const showCommandStrip = rows >= 9;
1209
+ const showErrorPanel = Boolean(error) && rows >= 18;
1210
+ const showGlobalErrorPanel = showErrorPanel && ui.view !== 'health' && !ui.showHelp && !worktreeOverlayOpen;
1211
+ const showErrorInline = Boolean(error) && !showErrorPanel && !worktreeOverlayOpen;
1212
+ const showApprovalBanner = approvalGateLine !== '' && !ui.showHelp && !worktreeOverlayOpen;
1213
+ const showLegacyStatusLine = statusLine !== '' && !showCommandLogLine;
1214
+ const densePanels = rows <= 28 || cols <= 120;
1215
+ const panelFrameRows = 3;
1216
+ const resolvePanelBodyRows = (rowBudget) => Math.max(1, Math.floor(Math.max(1, rowBudget) - panelFrameRows));
1217
+
1218
+ const reservedRows =
1219
+ 2 +
1220
+ (showFlowBar ? 1 : 0) +
1221
+ (showApprovalBanner ? 1 : 0) +
1222
+ (showCommandLogLine ? 1 : 0) +
1223
+ (showCommandStrip ? 1 : 0) +
1224
+ (showGlobalErrorPanel ? 5 : 0) +
1225
+ (showErrorInline ? 1 : 0) +
1226
+ (showLegacyStatusLine ? 1 : 0);
1227
+ const frameSafetyRows = 2;
1228
+ const contentRowsBudget = Math.max(4, rows - reservedRows - frameSafetyRows);
1229
+ const ultraCompact = rows <= 14;
1230
+ const panelTitles = getPanelTitles(activeFlow, snapshot);
1231
+ const compactWidth = Math.max(18, fullWidth - 4);
1232
+
1233
+ const sectionLines = Object.fromEntries(
1234
+ Object.entries(rowsBySection).map(([sectionKey, sectionRows]) => [
1235
+ sectionKey,
1236
+ buildInteractiveRowsLines(
1237
+ sectionRows,
1238
+ selectionBySection[sectionKey] || 0,
1239
+ icons,
1240
+ compactWidth,
1241
+ paneFocus === 'main' && focusedSection === sectionKey
1242
+ )
1243
+ ])
1244
+ );
1245
+ const effectivePreviewTarget = previewTarget || selectedFocusedFile;
1246
+ const useFullDocumentPreview = overlayPreviewOpen || (ui.view === 'runs' && previewOpen && !overlayPreviewOpen);
1247
+ const previewLines = previewOpen
1248
+ ? buildPreviewLines(effectivePreviewTarget, compactWidth, previewScroll, {
1249
+ fullDocument: useFullDocumentPreview
1250
+ })
1251
+ : [];
1252
+ const gitInlineDiffTarget = (
1253
+ focusedSection === 'git-commits'
1254
+ ? (selectedGitCommit || selectedGitFile || firstGitFile)
1255
+ : (selectedGitFile || firstGitFile)
1256
+ ) || null;
1257
+ const gitInlineDiffLines = ui.view === 'git'
1258
+ ? buildPreviewLines(gitInlineDiffTarget, compactWidth, previewOpen && paneFocus === 'preview' ? previewScroll : 0, {
1259
+ fullDocument: false
1260
+ })
1261
+ : [];
1262
+ const gitStatusPanelLines = ui.view === 'git' ? buildGitStatusPanelLines(snapshot) : [];
1263
+
1264
+ const shortcutsOverlayLines = buildHelpOverlayLines({
1265
+ view: ui.view,
1266
+ flow: activeFlow,
1267
+ previewOpen,
1268
+ paneFocus,
1269
+ availableFlowCount: availableFlowIds.length,
1270
+ showErrorSection: showErrorPanel,
1271
+ hasWorktrees: worktreeSectionEnabled
1272
+ });
1273
+ const quickHelpText = buildQuickHelpText(ui.view, {
1274
+ flow: activeFlow,
1275
+ previewOpen,
1276
+ paneFocus,
1277
+ availableFlowCount: availableFlowIds.length,
1278
+ hasWorktrees: worktreeSectionEnabled
1279
+ });
1280
+ const commandStripText = buildGitCommandStrip(ui.view, {
1281
+ hasWorktrees: worktreeSectionEnabled,
1282
+ previewOpen
1283
+ });
1284
+ const commandLogLine = buildGitCommandLogLine({
1285
+ statusLine,
1286
+ activeFlow,
1287
+ watchEnabled,
1288
+ watchStatus,
1289
+ selectedWorktreeLabel
1290
+ });
1291
+
1292
+ let panelCandidates;
1293
+ if (ui.showHelp) {
1294
+ panelCandidates = [
1295
+ {
1296
+ key: 'shortcuts-overlay',
1297
+ title: 'Keyboard Shortcuts',
1298
+ lines: shortcutsOverlayLines,
1299
+ borderColor: 'cyan'
1300
+ }
1301
+ ];
1302
+ } else if (worktreeOverlayOpen) {
1303
+ panelCandidates = [
1304
+ {
1305
+ key: 'worktree-overlay',
1306
+ title: 'Switch Worktree',
1307
+ lines: buildWorktreeOverlayLines(snapshot, worktreeOverlayIndex, Math.max(18, fullWidth - 4)),
1308
+ borderColor: 'yellow'
1309
+ }
1310
+ ];
1311
+ } else if (previewOpen && overlayPreviewOpen) {
1312
+ panelCandidates = [
1313
+ {
1314
+ key: 'preview-overlay',
1315
+ title: `Preview: ${effectivePreviewTarget?.label || 'unknown'}`,
1316
+ lines: previewLines,
1317
+ borderColor: 'magenta'
1318
+ }
1319
+ ];
1320
+ } else if (ui.view === 'intents') {
1321
+ panelCandidates = [
1322
+ {
1323
+ key: 'intent-status',
1324
+ title: 'Intents',
1325
+ lines: sectionLines['intent-status'],
1326
+ borderColor: 'yellow'
1327
+ }
1328
+ ];
1329
+ } else if (ui.view === 'completed') {
1330
+ panelCandidates = [
1331
+ {
1332
+ key: 'completed-runs',
1333
+ title: panelTitles.completed,
1334
+ lines: sectionLines['completed-runs'],
1335
+ borderColor: 'blue'
1336
+ }
1337
+ ];
1338
+ } else if (ui.view === 'health') {
1339
+ panelCandidates = [
1340
+ {
1341
+ key: 'standards',
1342
+ title: 'Standards',
1343
+ lines: sectionLines.standards,
1344
+ borderColor: 'blue'
1345
+ },
1346
+ {
1347
+ key: 'stats',
1348
+ title: 'Stats',
1349
+ lines: sectionLines.stats,
1350
+ borderColor: 'magenta'
1351
+ },
1352
+ {
1353
+ key: 'warnings',
1354
+ title: 'Warnings',
1355
+ lines: sectionLines.warnings,
1356
+ borderColor: 'red'
1357
+ }
1358
+ ];
1359
+
1360
+ if (error && showErrorPanel) {
1361
+ panelCandidates.push({
1362
+ key: 'error-details',
1363
+ title: 'Error Details',
1364
+ lines: sectionLines['error-details'],
1365
+ borderColor: 'red'
1366
+ });
1367
+ }
1368
+ } else if (ui.view === 'git') {
1369
+ panelCandidates = [
1370
+ {
1371
+ key: 'git-status',
1372
+ title: '[6]-Status',
1373
+ lines: gitStatusPanelLines,
1374
+ borderColor: 'green'
1375
+ },
1376
+ {
1377
+ key: 'git-changes',
1378
+ title: '[7]-Files',
1379
+ lines: sectionLines['git-changes'],
1380
+ borderColor: 'yellow'
1381
+ },
1382
+ {
1383
+ key: 'git-commits',
1384
+ title: '[8]-Commits',
1385
+ lines: sectionLines['git-commits'],
1386
+ borderColor: 'cyan'
1387
+ },
1388
+ {
1389
+ key: 'git-diff',
1390
+ title: focusedSection === 'git-commits' ? '[-]-Selected commit diff' : '[-]-Unstaged changes',
1391
+ lines: gitInlineDiffLines,
1392
+ borderColor: 'yellow'
1393
+ }
1394
+ ];
1395
+ } else {
1396
+ panelCandidates = [];
1397
+ if (worktreeSectionEnabled) {
1398
+ panelCandidates.push({
1399
+ key: 'worktrees',
1400
+ title: 'Worktrees',
1401
+ lines: sectionLines.worktrees,
1402
+ borderColor: 'magenta'
1403
+ });
1404
+ }
1405
+ panelCandidates.push(
1406
+ {
1407
+ key: 'current-run',
1408
+ title: panelTitles.current,
1409
+ lines: sectionLines['current-run'],
1410
+ borderColor: 'green'
1411
+ },
1412
+ {
1413
+ key: 'run-files',
1414
+ title: panelTitles.files,
1415
+ lines: sectionLines['run-files'],
1416
+ borderColor: 'yellow'
1417
+ }
1418
+ );
1419
+ if (otherWorktreesSectionEnabled) {
1420
+ panelCandidates.push({
1421
+ key: 'other-worktrees-active',
1422
+ title: panelTitles.otherWorktrees,
1423
+ lines: sectionLines['other-worktrees-active'],
1424
+ borderColor: 'blue'
1425
+ });
1426
+ }
1427
+ }
1428
+
1429
+ if (!ui.showHelp && previewOpen && !overlayPreviewOpen && ui.view !== 'git') {
1430
+ panelCandidates.push({
1431
+ key: 'preview',
1432
+ title: `Preview: ${effectivePreviewTarget?.label || 'unknown'}`,
1433
+ lines: previewLines,
1434
+ borderColor: 'magenta'
1435
+ });
1436
+ }
1437
+
1438
+ if (ultraCompact) {
1439
+ if (previewOpen) {
1440
+ panelCandidates = panelCandidates.filter((panel) =>
1441
+ panel && (
1442
+ panel.key === focusedSection
1443
+ || panel.key === 'preview'
1444
+ || (ui.view === 'git' && panel.key === 'git-diff')
1445
+ )
1446
+ );
1447
+ } else {
1448
+ const focusedPanel = panelCandidates.find((panel) => panel?.key === focusedSection);
1449
+ panelCandidates = [focusedPanel || panelCandidates[0]];
1450
+ }
1451
+ }
1452
+
1453
+ const panels = allocateSingleColumnPanels(panelCandidates, contentRowsBudget);
1454
+ const splitPreviewLayout = !ui.showHelp
1455
+ && !worktreeOverlayOpen
1456
+ && previewOpen
1457
+ && !overlayPreviewOpen
1458
+ && !ultraCompact
1459
+ && ui.view !== 'git'
1460
+ && fullWidth >= 96
1461
+ && panelCandidates.some((panel) => panel?.key === 'preview');
1462
+ const gitHierarchyLayout = ui.view === 'git'
1463
+ && !ui.showHelp
1464
+ && !worktreeOverlayOpen
1465
+ && !overlayPreviewOpen
1466
+ && !ultraCompact
1467
+ && fullWidth >= 96
1468
+ && panelCandidates.length > 1;
1469
+
1470
+ const renderPanel = (panel, index, width, isFocused) => React.createElement(SectionPanel, {
1471
+ key: panel.key,
1472
+ title: panel.title,
1473
+ lines: panel.lines,
1474
+ width,
1475
+ maxLines: panel.maxLines,
1476
+ borderColor: panel.borderColor,
1477
+ marginBottom: densePanels ? 0 : (index === panels.length - 1 ? 0 : 1),
1478
+ dense: densePanels,
1479
+ focused: isFocused
1480
+ });
1481
+
1482
+ let contentNode;
1483
+ if (gitHierarchyLayout) {
1484
+ const preferredRightPanel = panelCandidates.find((panel) => panel?.key === 'git-diff')
1485
+ || (previewOpen && !overlayPreviewOpen
1486
+ ? panelCandidates.find((panel) => panel?.key === 'preview')
1487
+ : null);
1488
+ const focusedPanel = panelCandidates.find((panel) => panel?.key === focusedSection);
1489
+ const rightPanelBase = preferredRightPanel
1490
+ || focusedPanel
1491
+ || panelCandidates[panelCandidates.length - 1];
1492
+ const leftCandidates = panelCandidates.filter((panel) => panel?.key !== rightPanelBase?.key);
1493
+ const leftWidth = Math.max(30, Math.min(Math.floor(fullWidth * 0.38), fullWidth - 36));
1494
+ const rightWidth = Math.max(34, fullWidth - leftWidth - 1);
1495
+ const leftPanels = allocateSingleColumnPanels(leftCandidates, contentRowsBudget);
1496
+ const rightPanel = {
1497
+ ...rightPanelBase,
1498
+ maxLines: resolvePanelBodyRows(contentRowsBudget)
1499
+ };
1500
+
1501
+ contentNode = React.createElement(
1502
+ Box,
1503
+ { width: fullWidth, flexDirection: 'row' },
1504
+ React.createElement(
1505
+ Box,
1506
+ { width: leftWidth, flexDirection: 'column' },
1507
+ ...leftPanels.map((panel, index) => React.createElement(SectionPanel, {
1508
+ key: panel.key,
1509
+ title: panel.title,
1510
+ lines: panel.lines,
1511
+ width: leftWidth,
1512
+ maxLines: panel.maxLines,
1513
+ borderColor: panel.borderColor,
1514
+ marginBottom: densePanels ? 0 : (index === leftPanels.length - 1 ? 0 : 1),
1515
+ dense: densePanels,
1516
+ focused: paneFocus === 'main' && panel.key === focusedSection
1517
+ }))
1518
+ ),
1519
+ React.createElement(
1520
+ Box,
1521
+ { width: 1, justifyContent: 'center' },
1522
+ React.createElement(Text, { color: 'gray' }, '│')
1523
+ ),
1524
+ React.createElement(
1525
+ Box,
1526
+ { width: rightWidth, flexDirection: 'column' },
1527
+ React.createElement(SectionPanel, {
1528
+ key: rightPanel.key,
1529
+ title: rightPanel.title,
1530
+ lines: rightPanel.lines,
1531
+ width: rightWidth,
1532
+ maxLines: rightPanel.maxLines,
1533
+ borderColor: rightPanel.borderColor,
1534
+ marginBottom: 0,
1535
+ dense: densePanels,
1536
+ focused: rightPanel.key === 'preview'
1537
+ ? paneFocus === 'preview'
1538
+ : (rightPanel.key === 'git-diff'
1539
+ ? true
1540
+ : (paneFocus === 'main' && rightPanel.key === focusedSection))
1541
+ })
1542
+ )
1543
+ );
1544
+ } else if (splitPreviewLayout) {
1545
+ const previewPanelBase = panelCandidates.find((panel) => panel?.key === 'preview');
1546
+ const mainCandidates = panelCandidates.filter((panel) => panel?.key !== 'preview');
1547
+ const mainWidth = Math.max(34, Math.floor(fullWidth * 0.56));
1548
+ const previewWidth = Math.max(30, fullWidth - mainWidth - 1);
1549
+ const mainPanels = allocateSingleColumnPanels(mainCandidates, contentRowsBudget);
1550
+ const previewPanel = {
1551
+ ...previewPanelBase,
1552
+ maxLines: resolvePanelBodyRows(contentRowsBudget)
1553
+ };
1554
+
1555
+ contentNode = React.createElement(
1556
+ Box,
1557
+ { width: fullWidth, flexDirection: 'row' },
1558
+ React.createElement(
1559
+ Box,
1560
+ { width: mainWidth, flexDirection: 'column' },
1561
+ ...mainPanels.map((panel, index) => React.createElement(SectionPanel, {
1562
+ key: panel.key,
1563
+ title: panel.title,
1564
+ lines: panel.lines,
1565
+ width: mainWidth,
1566
+ maxLines: panel.maxLines,
1567
+ borderColor: panel.borderColor,
1568
+ marginBottom: densePanels ? 0 : (index === mainPanels.length - 1 ? 0 : 1),
1569
+ dense: densePanels,
1570
+ focused: paneFocus === 'main' && panel.key === focusedSection
1571
+ }))
1572
+ ),
1573
+ React.createElement(
1574
+ Box,
1575
+ { width: 1, justifyContent: 'center' },
1576
+ React.createElement(Text, { color: 'gray' }, '│')
1577
+ ),
1578
+ React.createElement(
1579
+ Box,
1580
+ { width: previewWidth, flexDirection: 'column' },
1581
+ React.createElement(SectionPanel, {
1582
+ key: previewPanel.key,
1583
+ title: previewPanel.title,
1584
+ lines: previewPanel.lines,
1585
+ width: previewWidth,
1586
+ maxLines: previewPanel.maxLines,
1587
+ borderColor: previewPanel.borderColor,
1588
+ marginBottom: 0,
1589
+ dense: densePanels,
1590
+ focused: paneFocus === 'preview'
1591
+ })
1592
+ )
1593
+ );
1594
+ } else {
1595
+ contentNode = panels.map((panel, index) => renderPanel(
1596
+ panel,
1597
+ index,
1598
+ fullWidth,
1599
+ (ui.showHelp || worktreeOverlayOpen)
1600
+ ? true
1601
+ : ((panel.key === 'preview' || panel.key === 'preview-overlay')
1602
+ ? paneFocus === 'preview'
1603
+ : (paneFocus === 'main' && panel.key === focusedSection))
1604
+ ));
1605
+ }
1606
+
1607
+ return React.createElement(
1608
+ Box,
1609
+ { flexDirection: 'column', width: fullWidth },
1610
+ React.createElement(
1611
+ Text,
1612
+ { color: 'cyan' },
1613
+ buildHeaderLine(snapshot, activeFlow, watchEnabled, watchStatus, lastRefreshAt, ui.view, fullWidth, selectedWorktreeLabel)
1614
+ ),
1615
+ React.createElement(FlowBar, { activeFlow, width: fullWidth, flowIds: availableFlowIds }),
1616
+ React.createElement(TabsBar, { view: ui.view, width: fullWidth, icons, flow: activeFlow }),
1617
+ showApprovalBanner
1618
+ ? React.createElement(
1619
+ Text,
1620
+ { color: 'black', backgroundColor: 'yellow', bold: true },
1621
+ truncate(approvalGateLine, fullWidth)
1622
+ )
1623
+ : null,
1624
+ showErrorInline
1625
+ ? React.createElement(Text, { color: 'red' }, truncate(buildErrorLines(error, fullWidth)[0] || 'Error', fullWidth))
1626
+ : null,
1627
+ showGlobalErrorPanel
1628
+ ? React.createElement(SectionPanel, {
1629
+ title: 'Errors',
1630
+ lines: buildErrorLines(error, Math.max(18, fullWidth - 4)),
1631
+ width: fullWidth,
1632
+ maxLines: 2,
1633
+ borderColor: 'red',
1634
+ marginBottom: densePanels ? 0 : 1,
1635
+ dense: densePanels,
1636
+ focused: paneFocus === 'main' && focusedSection === 'error-details'
1637
+ })
1638
+ : null,
1639
+ ...(Array.isArray(contentNode) ? contentNode : [contentNode]),
1640
+ showLegacyStatusLine
1641
+ ? React.createElement(Text, { color: 'yellow' }, truncate(statusLine, fullWidth))
1642
+ : null,
1643
+ showCommandLogLine
1644
+ ? React.createElement(
1645
+ Text,
1646
+ { color: 'white', backgroundColor: 'gray', bold: true },
1647
+ truncate(commandLogLine, fullWidth)
1648
+ )
1649
+ : null,
1650
+ showCommandStrip
1651
+ ? React.createElement(
1652
+ Text,
1653
+ { color: 'white', backgroundColor: 'blue', bold: true },
1654
+ truncate(commandStripText, fullWidth)
1655
+ )
1656
+ : (rows >= 10
1657
+ ? React.createElement(Text, { color: 'gray' }, truncate(quickHelpText, fullWidth))
1658
+ : null)
1659
+ );
1660
+ }
1661
+
1662
+ return DashboardApp;
1663
+ }
1664
+
1665
+ module.exports = {
1666
+ createDashboardApp,
1667
+ toDashboardError,
1668
+ truncate,
1669
+ fitLines,
1670
+ safeJsonHash,
1671
+ allocateSingleColumnPanels,
1672
+ detectDashboardApprovalGate,
1673
+ detectFireRunApprovalGate,
1674
+ detectAidlcBoltApprovalGate
1675
+ };