specsmd 0.1.56 → 0.1.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3062 +1,84 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { spawnSync } = require('child_process');
4
- const stringWidthModule = require('string-width');
5
- const sliceAnsiModule = require('slice-ansi');
6
1
  const { createWatchRuntime } = require('../runtime/watch-runtime');
7
2
  const { createInitialUIState } = require('./store');
8
3
 
9
- const stringWidth = typeof stringWidthModule === 'function'
10
- ? stringWidthModule
11
- : stringWidthModule.default;
12
- const sliceAnsi = typeof sliceAnsiModule === 'function'
13
- ? sliceAnsiModule
14
- : sliceAnsiModule.default;
15
4
  const {
16
- loadGitDiffPreview,
17
- loadGitCommitPreview
18
- } = require('../git/changes');
19
-
20
- function toDashboardError(error, defaultCode = 'DASHBOARD_ERROR') {
21
- if (!error) {
22
- return {
23
- code: defaultCode,
24
- message: 'Unknown dashboard error.'
25
- };
26
- }
27
-
28
- if (typeof error === 'string') {
29
- return {
30
- code: defaultCode,
31
- message: error
32
- };
33
- }
34
-
35
- if (typeof error === 'object') {
36
- return {
37
- code: error.code || defaultCode,
38
- message: error.message || 'Unknown dashboard error.',
39
- details: error.details,
40
- path: error.path,
41
- hint: error.hint
42
- };
43
- }
44
-
45
- return {
46
- code: defaultCode,
47
- message: String(error)
48
- };
49
- }
50
-
51
- function safeJsonHash(value) {
52
- try {
53
- return JSON.stringify(value, (key, nestedValue) => {
54
- if (key === 'generatedAt') {
55
- return undefined;
56
- }
57
- return nestedValue;
58
- });
59
- } catch {
60
- return String(value);
61
- }
62
- }
63
-
64
- function resolveIconSet() {
65
- const mode = (process.env.SPECSMD_ICON_SET || 'auto').toLowerCase();
66
-
67
- const ascii = {
68
- runs: '[R]',
69
- overview: '[O]',
70
- health: '[H]',
71
- git: '[G]',
72
- runFile: '*',
73
- activeFile: '>',
74
- groupCollapsed: '>',
75
- groupExpanded: 'v'
76
- };
77
-
78
- const nerd = {
79
- runs: '󰑮',
80
- overview: '󰍉',
81
- health: '󰓦',
82
- git: '󰊢',
83
- runFile: '󰈔',
84
- activeFile: '󰜴',
85
- groupCollapsed: '󰐕',
86
- groupExpanded: '󰐗'
87
- };
88
-
89
- if (mode === 'ascii') {
90
- return ascii;
91
- }
92
- if (mode === 'nerd') {
93
- return nerd;
94
- }
95
-
96
- const locale = `${process.env.LC_ALL || ''}${process.env.LC_CTYPE || ''}${process.env.LANG || ''}`;
97
- const isUtf8 = /utf-?8/i.test(locale);
98
- const looksLikeVsCodeTerminal = (process.env.TERM_PROGRAM || '').toLowerCase().includes('vscode');
99
-
100
- return isUtf8 && looksLikeVsCodeTerminal ? nerd : ascii;
101
- }
102
-
103
- function truncate(value, width) {
104
- const text = String(value ?? '');
105
- if (!Number.isFinite(width)) {
106
- return text;
107
- }
108
-
109
- const safeWidth = Math.max(0, Math.floor(width));
110
- if (safeWidth === 0) {
111
- return '';
112
- }
113
-
114
- if (stringWidth(text) <= safeWidth) {
115
- return text;
116
- }
117
-
118
- if (safeWidth <= 3) {
119
- return sliceAnsi(text, 0, safeWidth);
120
- }
121
-
122
- const ellipsis = '...';
123
- const bodyWidth = Math.max(0, safeWidth - stringWidth(ellipsis));
124
- return `${sliceAnsi(text, 0, bodyWidth)}${ellipsis}`;
125
- }
126
-
127
- function resolveFrameWidth(columns) {
128
- const safeColumns = Number.isFinite(columns) ? Math.max(1, Math.floor(columns)) : 120;
129
- return safeColumns > 24 ? safeColumns - 1 : safeColumns;
130
- }
131
-
132
- function normalizePanelLine(line) {
133
- if (line && typeof line === 'object' && !Array.isArray(line)) {
134
- return {
135
- text: typeof line.text === 'string' ? line.text : String(line.text ?? ''),
136
- color: line.color,
137
- bold: Boolean(line.bold),
138
- selected: Boolean(line.selected),
139
- loading: Boolean(line.loading)
140
- };
141
- }
142
-
143
- return {
144
- text: String(line ?? ''),
145
- color: undefined,
146
- bold: false,
147
- selected: false,
148
- loading: false
149
- };
150
- }
151
-
152
- function fitLines(lines, maxLines, width) {
153
- const safeLines = (Array.isArray(lines) ? lines : []).map((line) => {
154
- const normalized = normalizePanelLine(line);
155
- return {
156
- ...normalized,
157
- text: truncate(normalized.text, width)
158
- };
159
- });
160
-
161
- if (safeLines.length <= maxLines) {
162
- return safeLines;
163
- }
164
-
165
- const selectedIndex = safeLines.findIndex((line) => line.selected);
166
- if (selectedIndex >= 0) {
167
- const windowSize = Math.max(1, maxLines);
168
- let start = selectedIndex - Math.floor(windowSize / 2);
169
- start = Math.max(0, start);
170
- start = Math.min(start, Math.max(0, safeLines.length - windowSize));
171
- return safeLines.slice(start, start + windowSize);
172
- }
173
-
174
- const visible = safeLines.slice(0, Math.max(1, maxLines - 1));
175
- visible.push({
176
- text: truncate(`... +${safeLines.length - visible.length} more`, width),
177
- color: 'gray',
178
- bold: false
179
- });
180
- return visible;
181
- }
182
-
183
- function formatTime(value) {
184
- if (!value) {
185
- return 'n/a';
186
- }
187
-
188
- const date = new Date(value);
189
- if (Number.isNaN(date.getTime())) {
190
- return value;
191
- }
192
-
193
- return date.toLocaleTimeString();
194
- }
195
-
196
- function buildShortStats(snapshot, flow) {
197
- if (!snapshot?.initialized) {
198
- if (flow === 'aidlc') {
199
- return 'init: waiting for memory-bank scan';
200
- }
201
- if (flow === 'simple') {
202
- return 'init: waiting for specs scan';
203
- }
204
- return 'init: waiting for state.yaml';
205
- }
206
-
207
- const stats = snapshot?.stats || {};
208
-
209
- if (flow === 'aidlc') {
210
- return `bolts ${stats.activeBoltsCount || 0}/${stats.completedBolts || 0} | intents ${stats.completedIntents || 0}/${stats.totalIntents || 0} | stories ${stats.completedStories || 0}/${stats.totalStories || 0}`;
211
- }
212
-
213
- if (flow === 'simple') {
214
- return `specs ${stats.completedSpecs || 0}/${stats.totalSpecs || 0} | tasks ${stats.completedTasks || 0}/${stats.totalTasks || 0} | active ${stats.activeSpecsCount || 0}`;
215
- }
216
-
217
- return `runs ${stats.activeRunsCount || 0}/${stats.completedRuns || 0} | intents ${stats.completedIntents || 0}/${stats.totalIntents || 0} | work ${stats.completedWorkItems || 0}/${stats.totalWorkItems || 0}`;
218
- }
219
-
220
- function buildHeaderLine(snapshot, flow, watchEnabled, watchStatus, lastRefreshAt, view, width, worktreeLabel = null) {
221
- const projectName = snapshot?.project?.name || 'Unnamed project';
222
- const shortStats = buildShortStats(snapshot, flow);
223
- const worktreeSegment = worktreeLabel ? ` | wt:${worktreeLabel}` : '';
224
- const line = `${flow.toUpperCase()} | ${projectName} | ${shortStats} | watch:${watchEnabled ? watchStatus : 'off'}${worktreeSegment} | ${view} | ${formatTime(lastRefreshAt)}`;
225
-
226
- return truncate(line, width);
227
- }
228
-
229
- function buildErrorLines(error, width) {
230
- if (!error) {
231
- return [];
232
- }
233
-
234
- const lines = [`[${error.code || 'ERROR'}] ${error.message || 'Unknown error'}`];
235
-
236
- if (error.details) {
237
- lines.push(`details: ${error.details}`);
238
- }
239
- if (error.path) {
240
- lines.push(`path: ${error.path}`);
241
- }
242
- if (error.hint) {
243
- lines.push(`hint: ${error.hint}`);
244
- }
245
-
246
- return lines.map((line) => truncate(line, width));
247
- }
248
-
249
- function getCurrentRun(snapshot) {
250
- const activeRuns = Array.isArray(snapshot?.activeRuns) ? [...snapshot.activeRuns] : [];
251
- if (activeRuns.length === 0) {
252
- return null;
253
- }
254
-
255
- activeRuns.sort((a, b) => {
256
- const aTime = a?.startedAt ? Date.parse(a.startedAt) : 0;
257
- const bTime = b?.startedAt ? Date.parse(b.startedAt) : 0;
258
- if (bTime !== aTime) {
259
- return bTime - aTime;
260
- }
261
- return String(a?.id || '').localeCompare(String(b?.id || ''));
262
- });
263
-
264
- return activeRuns[0] || null;
265
- }
266
-
267
- function normalizeToken(value) {
268
- if (typeof value !== 'string') {
269
- return '';
270
- }
271
- return value.toLowerCase().trim().replace(/[\s-]+/g, '_');
272
- }
273
-
274
- function getCurrentFireWorkItem(run) {
275
- const workItems = Array.isArray(run?.workItems) ? run.workItems : [];
276
- if (workItems.length === 0) {
277
- return null;
278
- }
279
- return workItems.find((item) => item.id === run.currentItem)
280
- || workItems.find((item) => normalizeToken(item?.status) === 'in_progress')
281
- || workItems[0]
282
- || null;
283
- }
284
-
285
- function readFileTextSafe(filePath) {
286
- try {
287
- return fs.readFileSync(filePath, 'utf8');
288
- } catch {
289
- return null;
290
- }
291
- }
292
-
293
- function extractFrontmatterBlock(content) {
294
- if (typeof content !== 'string') {
295
- return null;
296
- }
297
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
298
- return match ? match[1] : null;
299
- }
300
-
301
- function extractFrontmatterValue(frontmatterBlock, key) {
302
- if (typeof frontmatterBlock !== 'string' || typeof key !== 'string' || key === '') {
303
- return null;
304
- }
305
-
306
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
307
- const expression = new RegExp(`^${escapedKey}\\s*:\\s*(.+)$`, 'mi');
308
- const match = frontmatterBlock.match(expression);
309
- if (!match) {
310
- return null;
311
- }
312
-
313
- const raw = String(match[1] || '').trim();
314
- if (raw === '') {
315
- return '';
316
- }
317
-
318
- return raw
319
- .replace(/^["']/, '')
320
- .replace(/["']$/, '')
321
- .trim();
322
- }
323
-
324
- const FIRE_AWAITING_APPROVAL_STATES = new Set([
325
- 'awaiting_approval',
326
- 'waiting',
327
- 'pending_approval',
328
- 'approval_needed',
329
- 'approval_required',
330
- 'checkpoint_pending'
331
- ]);
332
-
333
- const FIRE_APPROVED_STATES = new Set([
334
- 'approved',
335
- 'confirmed',
336
- 'accepted',
337
- 'resumed',
338
- 'done',
339
- 'completed',
340
- 'cleared',
341
- 'none',
342
- 'not_required',
343
- 'skipped'
344
- ]);
345
-
346
- function parseFirePlanCheckpointMetadata(run) {
347
- if (!run || typeof run.folderPath !== 'string' || run.folderPath.trim() === '') {
348
- return { hasPlan: false, checkpointState: null, checkpoint: null };
349
- }
350
-
351
- const planPath = path.join(run.folderPath, 'plan.md');
352
- if (!fileExists(planPath)) {
353
- return { hasPlan: false, checkpointState: null, checkpoint: null };
354
- }
355
-
356
- const content = readFileTextSafe(planPath);
357
- const frontmatter = extractFrontmatterBlock(content);
358
- if (!frontmatter) {
359
- return { hasPlan: true, checkpointState: null, checkpoint: null };
360
- }
361
-
362
- const checkpointState = normalizeToken(
363
- extractFrontmatterValue(frontmatter, 'checkpoint_state')
364
- || extractFrontmatterValue(frontmatter, 'checkpointState')
365
- || extractFrontmatterValue(frontmatter, 'approval_state')
366
- || extractFrontmatterValue(frontmatter, 'approvalState')
367
- || ''
368
- ) || null;
369
- const checkpoint = extractFrontmatterValue(frontmatter, 'current_checkpoint')
370
- || extractFrontmatterValue(frontmatter, 'currentCheckpoint')
371
- || extractFrontmatterValue(frontmatter, 'checkpoint')
372
- || null;
373
-
374
- return {
375
- hasPlan: true,
376
- checkpointState,
377
- checkpoint
378
- };
379
- }
380
-
381
- function resolveFireApprovalState(run, currentWorkItem) {
382
- const itemState = normalizeToken(
383
- currentWorkItem?.checkpointState
384
- || currentWorkItem?.checkpoint_state
385
- || currentWorkItem?.approvalState
386
- || currentWorkItem?.approval_state
387
- || ''
388
- );
389
- const runState = normalizeToken(
390
- run?.checkpointState
391
- || run?.checkpoint_state
392
- || run?.approvalState
393
- || run?.approval_state
394
- || ''
395
- );
396
- const planState = parseFirePlanCheckpointMetadata(run);
397
- const state = itemState || runState || planState.checkpointState || null;
398
- const checkpoint = currentWorkItem?.currentCheckpoint
399
- || currentWorkItem?.current_checkpoint
400
- || run?.currentCheckpoint
401
- || run?.current_checkpoint
402
- || planState.checkpoint
403
- || null;
404
-
405
- return {
406
- state,
407
- checkpoint,
408
- source: itemState
409
- ? 'item-state'
410
- : (runState
411
- ? 'run-state'
412
- : (planState.checkpointState ? 'plan-frontmatter' : null))
413
- };
414
- }
415
-
416
- function getFireRunApprovalGate(run, currentWorkItem) {
417
- const mode = normalizeToken(currentWorkItem?.mode);
418
- const status = normalizeToken(currentWorkItem?.status);
419
- if (!['confirm', 'validate'].includes(mode) || status !== 'in_progress') {
420
- return null;
421
- }
422
-
423
- const phase = normalizeToken(getCurrentPhaseLabel(run, currentWorkItem));
424
- if (phase !== 'plan') {
425
- return null;
426
- }
427
-
428
- const resolvedApproval = resolveFireApprovalState(run, currentWorkItem);
429
- if (!resolvedApproval.state) {
430
- return null;
431
- }
432
-
433
- if (FIRE_APPROVED_STATES.has(resolvedApproval.state)) {
434
- return null;
435
- }
436
-
437
- if (!FIRE_AWAITING_APPROVAL_STATES.has(resolvedApproval.state)) {
438
- return null;
439
- }
440
-
441
- const modeLabel = String(currentWorkItem?.mode || 'confirm').toUpperCase();
442
- const itemId = String(currentWorkItem?.id || run.currentItem || 'unknown-item');
443
- const checkpointLabel = String(resolvedApproval.checkpoint || 'plan').replace(/[_\s]+/g, '-');
444
-
445
- return {
446
- flow: 'fire',
447
- title: 'Approval Needed',
448
- message: `${run.id}: ${itemId} (${modeLabel}) is waiting at ${checkpointLabel} checkpoint`,
449
- checkpoint: checkpointLabel,
450
- source: resolvedApproval.source
451
- };
452
- }
453
-
454
- function isFireRunAwaitingApproval(run, currentWorkItem) {
455
- return Boolean(getFireRunApprovalGate(run, currentWorkItem));
456
- }
457
-
458
- function detectFireRunApprovalGate(snapshot) {
459
- const run = getCurrentRun(snapshot);
460
- if (!run) {
461
- return null;
462
- }
463
-
464
- const currentWorkItem = getCurrentFireWorkItem(run);
465
- if (!currentWorkItem) {
466
- return null;
467
- }
468
-
469
- return getFireRunApprovalGate(run, currentWorkItem);
470
- }
471
-
472
- function normalizeStageName(stage) {
473
- return normalizeToken(stage).replace(/_/g, '-');
474
- }
475
-
476
- function getAidlcCheckpointSignalFiles(boltType, stageName) {
477
- const normalizedType = normalizeToken(boltType).replace(/_/g, '-');
478
- const normalizedStage = normalizeStageName(stageName);
479
-
480
- if (normalizedType === 'simple-construction-bolt') {
481
- if (normalizedStage === 'plan') return ['implementation-plan.md'];
482
- if (normalizedStage === 'implement') return ['implementation-walkthrough.md'];
483
- if (normalizedStage === 'test') return ['test-walkthrough.md'];
484
- return [];
485
- }
486
-
487
- if (normalizedType === 'ddd-construction-bolt') {
488
- if (normalizedStage === 'model') return ['ddd-01-domain-model.md'];
489
- if (normalizedStage === 'design') return ['ddd-02-technical-design.md'];
490
- if (normalizedStage === 'implement') return ['implementation-walkthrough.md'];
491
- if (normalizedStage === 'test') return ['ddd-03-test-report.md'];
492
- return [];
493
- }
494
-
495
- if (normalizedType === 'spike-bolt') {
496
- if (normalizedStage === 'explore') return ['spike-exploration.md'];
497
- if (normalizedStage === 'document') return ['spike-report.md'];
498
- return [];
499
- }
500
-
501
- return [];
502
- }
503
-
504
- function hasAidlcCheckpointSignal(bolt, stageName) {
505
- const fileNames = Array.isArray(bolt?.files) ? bolt.files : [];
506
- const lowerNames = new Set(fileNames.map((name) => String(name || '').toLowerCase()));
507
- const expectedFiles = getAidlcCheckpointSignalFiles(bolt?.type, stageName)
508
- .map((name) => String(name).toLowerCase());
509
-
510
- for (const expectedFile of expectedFiles) {
511
- if (lowerNames.has(expectedFile)) {
512
- return true;
513
- }
514
- }
515
-
516
- if (normalizeStageName(stageName) === 'adr') {
517
- for (const name of lowerNames) {
518
- if (/^adr-[\w-]+\.md$/.test(name)) {
519
- return true;
520
- }
521
- }
522
- }
523
-
524
- return false;
525
- }
526
-
527
- function isAidlcBoltAwaitingApproval(bolt) {
528
- if (!bolt || normalizeToken(bolt.status) !== 'in_progress') {
529
- return false;
530
- }
531
-
532
- const currentStage = normalizeStageName(bolt.currentStage);
533
- if (!currentStage) {
534
- return false;
535
- }
536
-
537
- const stages = Array.isArray(bolt.stages) ? bolt.stages : [];
538
- const stageMeta = stages.find((stage) => normalizeStageName(stage?.name) === currentStage);
539
- if (normalizeToken(stageMeta?.status) === 'completed') {
540
- return false;
541
- }
542
-
543
- return hasAidlcCheckpointSignal(bolt, currentStage);
544
- }
545
-
546
- function detectAidlcBoltApprovalGate(snapshot) {
547
- const bolt = getCurrentBolt(snapshot);
548
- if (!bolt) {
549
- return null;
550
- }
551
-
552
- if (!isAidlcBoltAwaitingApproval(bolt)) {
553
- return null;
554
- }
555
-
556
- return {
557
- flow: 'aidlc',
558
- title: 'Approval Needed',
559
- message: `${bolt.id}: ${bolt.currentStage || 'current'} stage is waiting for confirmation`
560
- };
561
- }
562
-
563
- function detectDashboardApprovalGate(snapshot, flow) {
564
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
565
- if (effectiveFlow === 'fire') {
566
- return detectFireRunApprovalGate(snapshot);
567
- }
568
- if (effectiveFlow === 'aidlc') {
569
- return detectAidlcBoltApprovalGate(snapshot);
570
- }
571
- return null;
572
- }
573
-
574
- function getCurrentPhaseLabel(run, currentWorkItem) {
575
- const phase = currentWorkItem?.currentPhase || '';
576
- if (typeof phase === 'string' && phase !== '') {
577
- return phase.toLowerCase();
578
- }
579
-
580
- if (run?.hasTestReport) {
581
- return 'review';
582
- }
583
- if (run?.hasPlan) {
584
- return 'execute';
585
- }
586
- return 'plan';
587
- }
588
-
589
- function buildPhaseTrack(currentPhase) {
590
- const order = ['plan', 'execute', 'test', 'review'];
591
- const labels = ['P', 'E', 'T', 'R'];
592
- const currentIndex = Math.max(0, order.indexOf(currentPhase));
593
- return labels.map((label, index) => (index === currentIndex ? `[${label}]` : ` ${label} `)).join(' - ');
594
- }
595
-
596
- function buildFireCurrentRunLines(snapshot, width) {
597
- const run = getCurrentRun(snapshot);
598
- if (!run) {
599
- return [truncate('No active run', width)];
600
- }
601
-
602
- const workItems = Array.isArray(run.workItems) ? run.workItems : [];
603
- const completed = workItems.filter((item) => item.status === 'completed').length;
604
- const currentWorkItem = workItems.find((item) => item.id === run.currentItem) || workItems.find((item) => item.status === 'in_progress') || workItems[0];
605
-
606
- const itemId = currentWorkItem?.id || run.currentItem || 'n/a';
607
- const mode = String(currentWorkItem?.mode || 'confirm').toUpperCase();
608
- const status = currentWorkItem?.status || 'pending';
609
- const currentPhase = getCurrentPhaseLabel(run, currentWorkItem);
610
- const phaseTrack = buildPhaseTrack(currentPhase);
611
-
612
- const lines = [
613
- `${run.id} [${run.scope}] ${completed}/${workItems.length} items done`,
614
- `work item: ${itemId}`,
615
- `mode: ${mode} | status: ${status}`,
616
- `phase: ${phaseTrack}`
617
- ];
618
-
619
- return lines.map((line) => truncate(line, width));
620
- }
621
-
622
- function buildFirePendingLines(snapshot, width) {
623
- const pending = snapshot?.pendingItems || [];
624
- if (pending.length === 0) {
625
- return [truncate('No pending work items', width)];
626
- }
627
-
628
- return pending.map((item) => {
629
- const deps = item.dependencies && item.dependencies.length > 0 ? ` deps:${item.dependencies.join(',')}` : '';
630
- return truncate(`${item.id} (${item.mode}/${item.complexity}) in ${item.intentTitle}${deps}`, width);
631
- });
632
- }
633
-
634
- function buildFireCompletedLines(snapshot, width) {
635
- const completedRuns = snapshot?.completedRuns || [];
636
- if (completedRuns.length === 0) {
637
- return [truncate('No completed runs yet', width)];
638
- }
639
-
640
- return completedRuns.map((run) => {
641
- const workItems = Array.isArray(run.workItems) ? run.workItems : [];
642
- const completed = workItems.filter((item) => item.status === 'completed').length;
643
- return truncate(`${run.id} [${run.scope}] ${completed}/${workItems.length} done at ${run.completedAt || 'unknown'}`, width);
644
- });
645
- }
646
-
647
- function buildFireStatsLines(snapshot, width) {
648
- if (!snapshot?.initialized) {
649
- return [truncate('Waiting for .specs-fire/state.yaml initialization.', width)];
650
- }
651
-
652
- const stats = snapshot.stats;
653
- return [
654
- `intents: ${stats.completedIntents}/${stats.totalIntents} done | in_progress: ${stats.inProgressIntents} | blocked: ${stats.blockedIntents}`,
655
- `work items: ${stats.completedWorkItems}/${stats.totalWorkItems} done | in_progress: ${stats.inProgressWorkItems} | pending: ${stats.pendingWorkItems} | blocked: ${stats.blockedWorkItems}`,
656
- `runs: ${stats.activeRunsCount} active | ${stats.completedRuns} completed | ${stats.totalRuns} total`
657
- ].map((line) => truncate(line, width));
658
- }
659
-
660
- function buildWarningsLines(snapshot, width) {
661
- const warnings = snapshot?.warnings || [];
662
- if (warnings.length === 0) {
663
- return [truncate('No warnings', width)];
664
- }
665
-
666
- return warnings.map((warning) => truncate(warning, width));
667
- }
668
-
669
- function buildFireOverviewProjectLines(snapshot, width) {
670
- if (!snapshot?.initialized) {
671
- return [
672
- truncate('FIRE folder detected, but state.yaml is missing.', width),
673
- truncate('Initialize project context and this view will populate.', width)
674
- ];
675
- }
676
-
677
- const project = snapshot.project || {};
678
- const workspace = snapshot.workspace || {};
679
-
680
- return [
681
- `project: ${project.name || 'unknown'} | fire_version: ${project.fireVersion || snapshot.version || '0.0.0'}`,
682
- `workspace: ${workspace.type || 'unknown'} / ${workspace.structure || 'unknown'}`,
683
- `autonomy: ${workspace.autonomyBias || 'unknown'} | run scope pref: ${workspace.runScopePreference || 'unknown'}`
684
- ].map((line) => truncate(line, width));
685
- }
686
-
687
- function buildFireOverviewIntentLines(snapshot, width) {
688
- const intents = snapshot?.intents || [];
689
- if (intents.length === 0) {
690
- return [truncate('No intents found', width)];
691
- }
692
-
693
- return intents.map((intent) => {
694
- const workItems = Array.isArray(intent.workItems) ? intent.workItems : [];
695
- const done = workItems.filter((item) => item.status === 'completed').length;
696
- return truncate(`${intent.id}: ${intent.status} (${done}/${workItems.length} work items)`, width);
697
- });
698
- }
699
-
700
- function buildFireOverviewStandardsLines(snapshot, width) {
701
- const expected = ['constitution', 'tech-stack', 'coding-standards', 'testing-standards', 'system-architecture'];
702
- const actual = new Set((snapshot?.standards || []).map((item) => item.type));
703
-
704
- return expected.map((name) => {
705
- const marker = actual.has(name) ? '[x]' : '[ ]';
706
- return truncate(`${marker} ${name}.md`, width);
707
- });
708
- }
709
-
710
- function getEffectiveFlow(flow, snapshot) {
711
- const explicitFlow = typeof flow === 'string' && flow !== '' ? flow : null;
712
- const snapshotFlow = typeof snapshot?.flow === 'string' && snapshot.flow !== '' ? snapshot.flow : null;
713
- return (snapshotFlow || explicitFlow || 'fire').toLowerCase();
714
- }
715
-
716
- function getCurrentBolt(snapshot) {
717
- const activeBolts = Array.isArray(snapshot?.activeBolts) ? [...snapshot.activeBolts] : [];
718
- if (activeBolts.length === 0) {
719
- return null;
720
- }
721
-
722
- activeBolts.sort((a, b) => {
723
- const aTime = a?.startedAt ? Date.parse(a.startedAt) : 0;
724
- const bTime = b?.startedAt ? Date.parse(b.startedAt) : 0;
725
- if (bTime !== aTime) {
726
- return bTime - aTime;
727
- }
728
- return String(a?.id || '').localeCompare(String(b?.id || ''));
729
- });
730
-
731
- return activeBolts[0] || null;
732
- }
733
-
734
- function buildAidlcStageTrack(bolt) {
735
- const stages = Array.isArray(bolt?.stages) ? bolt.stages : [];
736
- if (stages.length === 0) {
737
- return 'n/a';
738
- }
739
-
740
- return stages.map((stage) => {
741
- const label = String(stage?.name || '?').charAt(0).toUpperCase();
742
- if (stage?.status === 'completed') {
743
- return `[${label}]`;
744
- }
745
- if (stage?.status === 'in_progress') {
746
- return `<${label}>`;
747
- }
748
- return ` ${label} `;
749
- }).join('-');
750
- }
751
-
752
- function buildAidlcCurrentRunLines(snapshot, width) {
753
- const bolt = getCurrentBolt(snapshot);
754
- if (!bolt) {
755
- return [truncate('No active bolt', width)];
756
- }
757
-
758
- const stages = Array.isArray(bolt.stages) ? bolt.stages : [];
759
- const completedStages = stages.filter((stage) => stage.status === 'completed').length;
760
- const phaseTrack = buildAidlcStageTrack(bolt);
761
- const location = `${bolt.intent || 'unknown-intent'} / ${bolt.unit || 'unknown-unit'}`;
762
-
763
- const lines = [
764
- `${bolt.id} [${bolt.type}] ${completedStages}/${stages.length} stages done`,
765
- `scope: ${location}`,
766
- `stage: ${bolt.currentStage || 'n/a'} | status: ${bolt.status}`,
767
- `phase: ${phaseTrack}`
768
- ];
769
-
770
- return lines.map((line) => truncate(line, width));
771
- }
772
-
773
- function buildAidlcPendingLines(snapshot, width) {
774
- const pendingBolts = Array.isArray(snapshot?.pendingBolts) ? snapshot.pendingBolts : [];
775
- if (pendingBolts.length === 0) {
776
- return [truncate('No queued bolts', width)];
777
- }
778
-
779
- return pendingBolts.map((bolt) => {
780
- const deps = Array.isArray(bolt.blockedBy) && bolt.blockedBy.length > 0
781
- ? ` blocked_by:${bolt.blockedBy.join(',')}`
782
- : '';
783
- const location = `${bolt.intent || 'unknown'}/${bolt.unit || 'unknown'}`;
784
- return truncate(`${bolt.id} (${bolt.status}) in ${location}${deps}`, width);
785
- });
786
- }
787
-
788
- function buildAidlcCompletedLines(snapshot, width) {
789
- const completedBolts = Array.isArray(snapshot?.completedBolts) ? snapshot.completedBolts : [];
790
- if (completedBolts.length === 0) {
791
- return [truncate('No completed bolts yet', width)];
792
- }
793
-
794
- return completedBolts.map((bolt) =>
795
- truncate(`${bolt.id} [${bolt.type}] done at ${bolt.completedAt || 'unknown'}`, width)
796
- );
797
- }
798
-
799
- function buildAidlcStatsLines(snapshot, width) {
800
- const stats = snapshot?.stats || {};
801
-
802
- return [
803
- `intents: ${stats.completedIntents || 0}/${stats.totalIntents || 0} done | in_progress: ${stats.inProgressIntents || 0} | blocked: ${stats.blockedIntents || 0}`,
804
- `stories: ${stats.completedStories || 0}/${stats.totalStories || 0} done | in_progress: ${stats.inProgressStories || 0} | pending: ${stats.pendingStories || 0} | blocked: ${stats.blockedStories || 0}`,
805
- `bolts: ${stats.activeBoltsCount || 0} active | ${stats.queuedBolts || 0} queued | ${stats.blockedBolts || 0} blocked | ${stats.completedBolts || 0} done`
806
- ].map((line) => truncate(line, width));
807
- }
808
-
809
- function buildAidlcOverviewProjectLines(snapshot, width) {
810
- const project = snapshot?.project || {};
811
- const stats = snapshot?.stats || {};
812
-
813
- return [
814
- `project: ${project.name || 'unknown'} | project_type: ${project.projectType || 'unknown'}`,
815
- `memory-bank: intents ${stats.totalIntents || 0} | units ${stats.totalUnits || 0} | stories ${stats.totalStories || 0}`,
816
- `progress: ${stats.progressPercent || 0}% stories complete | standards: ${(snapshot?.standards || []).length}`
817
- ].map((line) => truncate(line, width));
818
- }
819
-
820
- function buildAidlcOverviewIntentLines(snapshot, width) {
821
- const intents = Array.isArray(snapshot?.intents) ? snapshot.intents : [];
822
- if (intents.length === 0) {
823
- return [truncate('No intents found', width)];
824
- }
825
-
826
- return intents.map((intent) => {
827
- return truncate(
828
- `${intent.id}: ${intent.status} (${intent.completedStories || 0}/${intent.storyCount || 0} stories, ${intent.completedUnits || 0}/${intent.unitCount || 0} units)`,
829
- width
830
- );
831
- });
832
- }
833
-
834
- function buildAidlcOverviewStandardsLines(snapshot, width) {
835
- const standards = Array.isArray(snapshot?.standards) ? snapshot.standards : [];
836
- if (standards.length === 0) {
837
- return [truncate('No standards found under memory-bank/standards', width)];
838
- }
839
-
840
- return standards.map((standard) =>
841
- truncate(`[x] ${standard.name || standard.type || 'unknown'}.md`, width)
842
- );
843
- }
844
-
845
- function getCurrentSpec(snapshot) {
846
- const specs = Array.isArray(snapshot?.activeSpecs) ? snapshot.activeSpecs : [];
847
- if (specs.length === 0) {
848
- return null;
849
- }
850
- return specs[0] || null;
851
- }
852
-
853
- function simplePhaseIndex(state) {
854
- if (state === 'requirements_pending') {
855
- return 0;
856
- }
857
- if (state === 'design_pending') {
858
- return 1;
859
- }
860
- return 2;
861
- }
862
-
863
- function buildSimplePhaseTrack(spec) {
864
- if (spec?.state === 'completed') {
865
- return '[R] - [D] - [T]';
866
- }
867
-
868
- const labels = ['R', 'D', 'T'];
869
- const current = simplePhaseIndex(spec?.state);
870
- return labels.map((label, index) => (index === current ? `[${label}]` : ` ${label} `)).join(' - ');
871
- }
872
-
873
- function buildSimpleCurrentRunLines(snapshot, width) {
874
- const spec = getCurrentSpec(snapshot);
875
- if (!spec) {
876
- return [truncate('No active spec', width)];
877
- }
878
-
879
- const files = [
880
- spec.hasRequirements ? 'req' : '-',
881
- spec.hasDesign ? 'design' : '-',
882
- spec.hasTasks ? 'tasks' : '-'
883
- ].join('/');
884
-
885
- const lines = [
886
- `${spec.name} [${spec.state}] ${spec.tasksCompleted}/${spec.tasksTotal} tasks done`,
887
- `phase: ${spec.phase}`,
888
- `files: ${files}`,
889
- `track: ${buildSimplePhaseTrack(spec)}`
890
- ];
891
-
892
- return lines.map((line) => truncate(line, width));
893
- }
894
-
895
- function buildSimplePendingLines(snapshot, width) {
896
- const pendingSpecs = Array.isArray(snapshot?.pendingSpecs) ? snapshot.pendingSpecs : [];
897
- if (pendingSpecs.length === 0) {
898
- return [truncate('No pending specs', width)];
899
- }
900
-
901
- return pendingSpecs.map((spec) =>
902
- truncate(`${spec.name} (${spec.state}) ${spec.tasksCompleted}/${spec.tasksTotal} tasks`, width)
903
- );
904
- }
905
-
906
- function buildSimpleCompletedLines(snapshot, width) {
907
- const completedSpecs = Array.isArray(snapshot?.completedSpecs) ? snapshot.completedSpecs : [];
908
- if (completedSpecs.length === 0) {
909
- return [truncate('No completed specs yet', width)];
910
- }
911
-
912
- return completedSpecs.map((spec) =>
913
- truncate(`${spec.name} done at ${spec.updatedAt || 'unknown'} (${spec.tasksCompleted}/${spec.tasksTotal})`, width)
914
- );
915
- }
916
-
917
- function buildSimpleStatsLines(snapshot, width) {
918
- const stats = snapshot?.stats || {};
919
-
920
- return [
921
- `specs: ${stats.completedSpecs || 0}/${stats.totalSpecs || 0} complete | in_progress: ${stats.inProgressSpecs || 0} | pending: ${stats.pendingSpecs || 0}`,
922
- `pipeline: ready ${stats.readySpecs || 0} | design_pending ${stats.designPendingSpecs || 0} | tasks_pending ${stats.tasksPendingSpecs || 0}`,
923
- `tasks: ${stats.completedTasks || 0}/${stats.totalTasks || 0} complete | pending: ${stats.pendingTasks || 0} | optional: ${stats.optionalTasks || 0}`
924
- ].map((line) => truncate(line, width));
925
- }
926
-
927
- function buildSimpleOverviewProjectLines(snapshot, width) {
928
- const project = snapshot?.project || {};
929
- const stats = snapshot?.stats || {};
930
-
931
- return [
932
- `project: ${project.name || 'unknown'} | simple flow`,
933
- `specs: ${stats.totalSpecs || 0} total | active: ${stats.activeSpecsCount || 0} | completed: ${stats.completedSpecs || 0}`,
934
- `tasks: ${stats.completedTasks || 0}/${stats.totalTasks || 0} complete (${stats.progressPercent || 0}%)`
935
- ].map((line) => truncate(line, width));
936
- }
937
-
938
- function buildSimpleOverviewIntentLines(snapshot, width) {
939
- const specs = Array.isArray(snapshot?.specs) ? snapshot.specs : [];
940
- if (specs.length === 0) {
941
- return [truncate('No specs found', width)];
942
- }
943
-
944
- return specs.map((spec) =>
945
- truncate(`${spec.name}: ${spec.state} (${spec.tasksCompleted}/${spec.tasksTotal} tasks)`, width)
946
- );
947
- }
948
-
949
- function buildSimpleOverviewStandardsLines(snapshot, width) {
950
- const specs = Array.isArray(snapshot?.specs) ? snapshot.specs : [];
951
- if (specs.length === 0) {
952
- return [truncate('No spec artifacts found', width)];
953
- }
954
-
955
- const reqCount = specs.filter((spec) => spec.hasRequirements).length;
956
- const designCount = specs.filter((spec) => spec.hasDesign).length;
957
- const tasksCount = specs.filter((spec) => spec.hasTasks).length;
958
- const total = specs.length;
959
-
960
- return [
961
- `[x] requirements.md coverage ${reqCount}/${total}`,
962
- `[x] design.md coverage ${designCount}/${total}`,
963
- `[x] tasks.md coverage ${tasksCount}/${total}`
964
- ].map((line) => truncate(line, width));
965
- }
966
-
967
- function buildCurrentRunLines(snapshot, width, flow) {
968
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
969
- if (effectiveFlow === 'aidlc') {
970
- return buildAidlcCurrentRunLines(snapshot, width);
971
- }
972
- if (effectiveFlow === 'simple') {
973
- return buildSimpleCurrentRunLines(snapshot, width);
974
- }
975
- return buildFireCurrentRunLines(snapshot, width);
976
- }
977
-
978
- function buildPendingLines(snapshot, width, flow) {
979
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
980
- if (effectiveFlow === 'aidlc') {
981
- return buildAidlcPendingLines(snapshot, width);
982
- }
983
- if (effectiveFlow === 'simple') {
984
- return buildSimplePendingLines(snapshot, width);
985
- }
986
- return buildFirePendingLines(snapshot, width);
987
- }
988
-
989
- function buildCompletedLines(snapshot, width, flow) {
990
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
991
- if (effectiveFlow === 'aidlc') {
992
- return buildAidlcCompletedLines(snapshot, width);
993
- }
994
- if (effectiveFlow === 'simple') {
995
- return buildSimpleCompletedLines(snapshot, width);
996
- }
997
- return buildFireCompletedLines(snapshot, width);
998
- }
999
-
1000
- function buildStatsLines(snapshot, width, flow) {
1001
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
1002
- if (!snapshot?.initialized) {
1003
- if (effectiveFlow === 'aidlc') {
1004
- return [truncate('Waiting for memory-bank initialization.', width)];
1005
- }
1006
- if (effectiveFlow === 'simple') {
1007
- return [truncate('Waiting for specs/ initialization.', width)];
1008
- }
1009
- return [truncate('Waiting for .specs-fire/state.yaml initialization.', width)];
1010
- }
1011
-
1012
- if (effectiveFlow === 'aidlc') {
1013
- return buildAidlcStatsLines(snapshot, width);
1014
- }
1015
- if (effectiveFlow === 'simple') {
1016
- return buildSimpleStatsLines(snapshot, width);
1017
- }
1018
- return buildFireStatsLines(snapshot, width);
1019
- }
1020
-
1021
- function buildOverviewProjectLines(snapshot, width, flow) {
1022
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
1023
- if (effectiveFlow === 'aidlc') {
1024
- return buildAidlcOverviewProjectLines(snapshot, width);
1025
- }
1026
- if (effectiveFlow === 'simple') {
1027
- return buildSimpleOverviewProjectLines(snapshot, width);
1028
- }
1029
- return buildFireOverviewProjectLines(snapshot, width);
1030
- }
1031
-
1032
- function listOverviewIntentEntries(snapshot, flow) {
1033
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
1034
- if (effectiveFlow === 'aidlc') {
1035
- const intents = Array.isArray(snapshot?.intents) ? snapshot.intents : [];
1036
- return intents.map((intent) => ({
1037
- id: intent?.id || 'unknown',
1038
- status: intent?.status || 'pending',
1039
- line: `${intent?.id || 'unknown'}: ${intent?.status || 'pending'} (${intent?.completedStories || 0}/${intent?.storyCount || 0} stories, ${intent?.completedUnits || 0}/${intent?.unitCount || 0} units)`
1040
- }));
1041
- }
1042
- if (effectiveFlow === 'simple') {
1043
- const specs = Array.isArray(snapshot?.specs) ? snapshot.specs : [];
1044
- return specs.map((spec) => ({
1045
- id: spec?.name || 'unknown',
1046
- status: spec?.state || 'pending',
1047
- line: `${spec?.name || 'unknown'}: ${spec?.state || 'pending'} (${spec?.tasksCompleted || 0}/${spec?.tasksTotal || 0} tasks)`
1048
- }));
1049
- }
1050
- const intents = Array.isArray(snapshot?.intents) ? snapshot.intents : [];
1051
- return intents.map((intent) => {
1052
- const workItems = Array.isArray(intent?.workItems) ? intent.workItems : [];
1053
- const done = workItems.filter((item) => item.status === 'completed').length;
1054
- return {
1055
- id: intent?.id || 'unknown',
1056
- status: intent?.status || 'pending',
1057
- line: `${intent?.id || 'unknown'}: ${intent?.status || 'pending'} (${done}/${workItems.length} work items)`
1058
- };
1059
- });
1060
- }
1061
-
1062
- function buildOverviewIntentLines(snapshot, width, flow, filter = 'next') {
1063
- const entries = listOverviewIntentEntries(snapshot, flow);
1064
- const normalizedFilter = filter === 'completed' ? 'completed' : 'next';
1065
- const isNextFilter = normalizedFilter === 'next';
1066
- const nextLabel = isNextFilter ? '[NEXT]' : ' next ';
1067
- const completedLabel = !isNextFilter ? '[COMPLETED]' : ' completed ';
1068
-
1069
- const filtered = entries.filter((entry) => {
1070
- if (normalizedFilter === 'completed') {
1071
- return entry.status === 'completed';
1072
- }
1073
- return entry.status !== 'completed';
1074
- });
1075
-
1076
- const lines = [{
1077
- text: truncate(`filter ${nextLabel} | ${completedLabel} (←/→ or n/x)`, width),
1078
- color: 'cyan',
1079
- bold: true
1080
- }];
1081
-
1082
- if (filtered.length === 0) {
1083
- lines.push({
1084
- text: truncate(
1085
- normalizedFilter === 'completed' ? 'No completed intents yet' : 'No upcoming intents',
1086
- width
1087
- ),
1088
- color: 'gray',
1089
- bold: false
1090
- });
1091
- return lines;
1092
- }
1093
-
1094
- lines.push(...filtered.map((entry) => truncate(entry.line, width)));
1095
- return lines;
1096
- }
1097
-
1098
- function buildOverviewStandardsLines(snapshot, width, flow) {
1099
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
1100
- if (effectiveFlow === 'aidlc') {
1101
- return buildAidlcOverviewStandardsLines(snapshot, width);
1102
- }
1103
- if (effectiveFlow === 'simple') {
1104
- return buildSimpleOverviewStandardsLines(snapshot, width);
1105
- }
1106
- return buildFireOverviewStandardsLines(snapshot, width);
1107
- }
1108
-
1109
- function getPanelTitles(flow, snapshot) {
1110
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
1111
- if (effectiveFlow === 'aidlc') {
1112
- return {
1113
- current: 'Current Bolt',
1114
- files: 'Bolt Files',
1115
- pending: 'Queued Bolts',
1116
- completed: 'Recent Completed Bolts',
1117
- otherWorktrees: 'Other Worktrees: Active Bolts'
1118
- };
1119
- }
1120
- if (effectiveFlow === 'simple') {
1121
- return {
1122
- current: 'Current Spec',
1123
- files: 'Spec Files',
1124
- pending: 'Pending Specs',
1125
- completed: 'Recent Completed Specs',
1126
- otherWorktrees: 'Other Worktrees: Active Specs'
1127
- };
1128
- }
1129
- return {
1130
- current: 'Current Run',
1131
- files: 'Run Files',
1132
- pending: 'Pending Queue',
1133
- completed: 'Recent Completed Runs',
1134
- otherWorktrees: 'Other Worktrees: Active Runs',
1135
- git: 'Git Changes'
1136
- };
1137
- }
1138
-
1139
- function getGitChangesSnapshot(snapshot) {
1140
- const gitChanges = snapshot?.gitChanges;
1141
- if (!gitChanges || typeof gitChanges !== 'object') {
1142
- return {
1143
- available: false,
1144
- branch: '(unavailable)',
1145
- upstream: null,
1146
- ahead: 0,
1147
- behind: 0,
1148
- counts: {
1149
- total: 0,
1150
- staged: 0,
1151
- unstaged: 0,
1152
- untracked: 0,
1153
- conflicted: 0
1154
- },
1155
- staged: [],
1156
- unstaged: [],
1157
- untracked: [],
1158
- conflicted: [],
1159
- clean: true
1160
- };
1161
- }
1162
- return {
1163
- ...gitChanges,
1164
- counts: gitChanges.counts || {
1165
- total: 0,
1166
- staged: 0,
1167
- unstaged: 0,
1168
- untracked: 0,
1169
- conflicted: 0
1170
- },
1171
- staged: Array.isArray(gitChanges.staged) ? gitChanges.staged : [],
1172
- unstaged: Array.isArray(gitChanges.unstaged) ? gitChanges.unstaged : [],
1173
- untracked: Array.isArray(gitChanges.untracked) ? gitChanges.untracked : [],
1174
- conflicted: Array.isArray(gitChanges.conflicted) ? gitChanges.conflicted : []
1175
- };
1176
- }
1177
-
1178
- function readGitCommandLines(repoRoot, args, options = {}) {
1179
- if (typeof repoRoot !== 'string' || repoRoot.trim() === '' || !Array.isArray(args) || args.length === 0) {
1180
- return [];
1181
- }
1182
-
1183
- const acceptedStatuses = Array.isArray(options.acceptedStatuses) && options.acceptedStatuses.length > 0
1184
- ? options.acceptedStatuses
1185
- : [0];
1186
-
1187
- const result = spawnSync('git', args, {
1188
- cwd: repoRoot,
1189
- encoding: 'utf8',
1190
- maxBuffer: 8 * 1024 * 1024
1191
- });
1192
-
1193
- if (result.error) {
1194
- return [];
1195
- }
1196
-
1197
- if (typeof result.status === 'number' && !acceptedStatuses.includes(result.status)) {
1198
- return [];
1199
- }
1200
-
1201
- const lines = String(result.stdout || '')
1202
- .split(/\r?\n/)
1203
- .map((line) => line.trim())
1204
- .filter(Boolean);
1205
-
1206
- const limit = Number.isFinite(options.limit) ? Math.max(1, Math.floor(options.limit)) : null;
1207
- if (limit == null || lines.length <= limit) {
1208
- return lines;
1209
- }
1210
- return lines.slice(0, limit);
1211
- }
1212
-
1213
- function buildGitStatusPanelLines(snapshot) {
1214
- const git = getGitChangesSnapshot(snapshot);
1215
- if (!git.available) {
1216
- return [{
1217
- text: 'Repository unavailable in selected worktree',
1218
- color: 'red',
1219
- bold: true
1220
- }];
1221
- }
1222
-
1223
- const tracking = git.upstream
1224
- ? `${git.upstream} (${git.ahead > 0 ? `ahead ${git.ahead}` : 'ahead 0'}, ${git.behind > 0 ? `behind ${git.behind}` : 'behind 0'})`
1225
- : 'no upstream';
1226
-
1227
- return [
1228
- {
1229
- text: `branch: ${git.branch}${git.detached ? ' [detached]' : ''}`,
1230
- color: 'green',
1231
- bold: true
1232
- },
1233
- {
1234
- text: `tracking: ${tracking}`,
1235
- color: 'gray',
1236
- bold: false
1237
- },
1238
- {
1239
- text: `changes: ${git.counts.total || 0} total`,
1240
- color: 'gray',
1241
- bold: false
1242
- },
1243
- {
1244
- text: `staged ${git.counts.staged || 0} | unstaged ${git.counts.unstaged || 0}`,
1245
- color: 'yellow',
1246
- bold: false
1247
- },
1248
- {
1249
- text: `untracked ${git.counts.untracked || 0} | conflicts ${git.counts.conflicted || 0}`,
1250
- color: 'yellow',
1251
- bold: false
1252
- }
1253
- ];
1254
- }
1255
-
1256
- function buildGitCommitRows(snapshot) {
1257
- const git = getGitChangesSnapshot(snapshot);
1258
- if (!git.available) {
1259
- return [{
1260
- kind: 'info',
1261
- key: 'git:commits:unavailable',
1262
- label: 'No commit history (git unavailable)',
1263
- selectable: false
1264
- }];
1265
- }
1266
-
1267
- const commitLines = readGitCommandLines(git.rootPath, [
1268
- '-c',
1269
- 'color.ui=false',
1270
- 'log',
1271
- '--date=relative',
1272
- '--pretty=format:%h %s',
1273
- '--max-count=30'
1274
- ], { limit: 30 });
1275
-
1276
- if (commitLines.length === 0) {
1277
- return [{
1278
- kind: 'info',
1279
- key: 'git:commits:empty',
1280
- label: 'No commits found',
1281
- selectable: false
1282
- }];
1283
- }
1284
-
1285
- return commitLines.map((line, index) => {
1286
- const firstSpace = line.indexOf(' ');
1287
- const commitHash = firstSpace > 0 ? line.slice(0, firstSpace) : '';
1288
- const message = firstSpace > 0 ? line.slice(firstSpace + 1) : line;
1289
- const label = commitHash ? `${commitHash} ${message}` : message;
1290
-
1291
- return {
1292
- kind: 'git-commit',
1293
- key: `git:commit:${commitHash || index}:${index}`,
1294
- label,
1295
- commitHash,
1296
- repoRoot: git.rootPath,
1297
- previewType: 'git-commit-diff',
1298
- selectable: true
1299
- };
1300
- });
1301
- }
1302
-
1303
- function getDashboardWorktreeMeta(snapshot) {
1304
- if (!snapshot || typeof snapshot !== 'object') {
1305
- return null;
1306
- }
1307
- const meta = snapshot.dashboardWorktrees;
1308
- if (!meta || typeof meta !== 'object') {
1309
- return null;
1310
- }
1311
- const items = Array.isArray(meta.items) ? meta.items : [];
1312
- if (items.length === 0) {
1313
- return null;
1314
- }
1315
- return {
1316
- ...meta,
1317
- items
1318
- };
1319
- }
1320
-
1321
- function getWorktreeItems(snapshot) {
1322
- return getDashboardWorktreeMeta(snapshot)?.items || [];
1323
- }
1324
-
1325
- function getSelectedWorktree(snapshot) {
1326
- const meta = getDashboardWorktreeMeta(snapshot);
1327
- if (!meta) {
1328
- return null;
1329
- }
1330
- return meta.items.find((item) => item.id === meta.selectedWorktreeId) || null;
1331
- }
1332
-
1333
- function hasMultipleWorktrees(snapshot) {
1334
- return getWorktreeItems(snapshot).length > 1;
1335
- }
1336
-
1337
- function isSelectedWorktreeMain(snapshot) {
1338
- const selected = getSelectedWorktree(snapshot);
1339
- return Boolean(selected?.isMainBranch);
1340
- }
1341
-
1342
- function getWorktreeDisplayName(worktree) {
1343
- if (!worktree || typeof worktree !== 'object') {
1344
- return 'unknown';
1345
- }
1346
- if (typeof worktree.displayBranch === 'string' && worktree.displayBranch.trim() !== '') {
1347
- return worktree.displayBranch;
1348
- }
1349
- if (typeof worktree.branch === 'string' && worktree.branch.trim() !== '') {
1350
- return worktree.branch;
1351
- }
1352
- if (typeof worktree.name === 'string' && worktree.name.trim() !== '') {
1353
- return worktree.name;
1354
- }
1355
- return path.basename(worktree.path || '') || 'unknown';
1356
- }
1357
-
1358
- function buildWorktreeRows(snapshot, flow) {
1359
- const meta = getDashboardWorktreeMeta(snapshot);
1360
- if (!meta) {
1361
- return [{
1362
- kind: 'info',
1363
- key: 'worktrees:none',
1364
- label: 'No git worktrees detected',
1365
- selectable: false
1366
- }];
1367
- }
1368
-
1369
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
1370
- const entityLabel = effectiveFlow === 'aidlc'
1371
- ? 'active bolts'
1372
- : (effectiveFlow === 'simple' ? 'active specs' : 'active runs');
1373
-
1374
- const rows = [];
1375
- for (const item of meta.items) {
1376
- const currentLabel = item.isSelected ? '[CURRENT] ' : '';
1377
- const mainLabel = item.isMainBranch && !item.detached ? '[MAIN] ' : '';
1378
- const availabilityLabel = item.flowAvailable ? '' : ' (flow unavailable)';
1379
- const statusLabel = item.status === 'loading'
1380
- ? ' loading...'
1381
- : (item.status === 'error' ? ' error' : ` ${item.activeCount || 0} ${entityLabel}`);
1382
- const scopeLabel = item.name ? ` (${item.name})` : '';
1383
-
1384
- rows.push({
1385
- kind: 'info',
1386
- key: `worktree:item:${item.id}`,
1387
- label: `${currentLabel}${mainLabel}${getWorktreeDisplayName(item)}${scopeLabel}${availabilityLabel}${statusLabel}`,
1388
- color: item.isSelected ? 'green' : (item.flowAvailable ? 'gray' : 'red'),
1389
- bold: item.isSelected,
1390
- selectable: true
1391
- });
1392
- }
1393
-
1394
- return rows;
1395
- }
1396
-
1397
- function buildOtherWorktreeActiveGroups(snapshot, flow) {
1398
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
1399
- if (effectiveFlow === 'simple') {
1400
- return [];
1401
- }
1402
-
1403
- const meta = getDashboardWorktreeMeta(snapshot);
1404
- if (!meta) {
1405
- return [];
1406
- }
1407
-
1408
- const selectedWorktree = getSelectedWorktree(snapshot);
1409
- if (!selectedWorktree || !selectedWorktree.isMainBranch) {
1410
- return [];
1411
- }
1412
-
1413
- const groups = [];
1414
- const otherItems = meta.items.filter((item) => item.id !== meta.selectedWorktreeId);
1415
- for (const item of otherItems) {
1416
- if (!item.flowAvailable || item.status === 'unavailable' || item.status === 'error') {
1417
- continue;
1418
- }
1419
-
1420
- if (effectiveFlow === 'aidlc') {
1421
- const activeBolts = Array.isArray(item.activity?.activeBolts) ? item.activity.activeBolts : [];
1422
- for (const bolt of activeBolts) {
1423
- const stages = Array.isArray(bolt?.stages) ? bolt.stages : [];
1424
- const completedStages = stages.filter((stage) => stage?.status === 'completed').length;
1425
- groups.push({
1426
- key: `other:wt:${item.id}:bolt:${bolt.id}`,
1427
- label: `[WT ${getWorktreeDisplayName(item)}] ${bolt.id} [${bolt.type || 'bolt'}] ${completedStages}/${stages.length || 0} stages`,
1428
- files: collectAidlcBoltFiles(bolt).map((file) => ({ ...file, scope: 'active' }))
1429
- });
1430
- }
1431
- continue;
1432
- }
1433
-
1434
- const activeRuns = Array.isArray(item.activity?.activeRuns) ? item.activity.activeRuns : [];
1435
- for (const run of activeRuns) {
1436
- const workItems = Array.isArray(run?.workItems) ? run.workItems : [];
1437
- const completed = workItems.filter((workItem) => workItem?.status === 'completed').length;
1438
- groups.push({
1439
- key: `other:wt:${item.id}:run:${run.id}`,
1440
- label: `[WT ${getWorktreeDisplayName(item)}] ${run.id} [${run.scope || 'single'}] ${completed}/${workItems.length} items`,
1441
- files: collectFireRunFiles(run).map((file) => ({ ...file, scope: 'active' }))
1442
- });
1443
- }
1444
- }
1445
-
1446
- return groups;
1447
- }
1448
-
1449
- function getOtherWorktreeEmptyMessage(snapshot, flow) {
1450
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
1451
- if (!hasMultipleWorktrees(snapshot)) {
1452
- return 'No additional worktrees';
1453
- }
1454
- if (!isSelectedWorktreeMain(snapshot)) {
1455
- return 'Switch to main worktree to view active items from other worktrees';
1456
- }
1457
- if (effectiveFlow === 'aidlc') {
1458
- return 'No active bolts in other worktrees';
1459
- }
1460
- if (effectiveFlow === 'simple') {
1461
- return 'No active specs in other worktrees';
1462
- }
1463
- return 'No active runs in other worktrees';
1464
- }
1465
-
1466
- function getSectionOrderForView(view, options = {}) {
1467
- const includeWorktrees = options.includeWorktrees === true;
1468
- const includeOtherWorktrees = options.includeOtherWorktrees === true;
1469
-
1470
- if (view === 'intents') {
1471
- return ['intent-status'];
1472
- }
1473
- if (view === 'completed') {
1474
- return ['completed-runs'];
1475
- }
1476
- if (view === 'health') {
1477
- return ['standards', 'stats', 'warnings', 'error-details'];
1478
- }
1479
- if (view === 'git') {
1480
- return ['git-status', 'git-changes', 'git-commits', 'git-diff'];
1481
- }
1482
- const sections = [];
1483
- if (includeWorktrees) {
1484
- sections.push('worktrees');
1485
- }
1486
- sections.push('current-run', 'run-files');
1487
- if (includeOtherWorktrees) {
1488
- sections.push('other-worktrees-active');
1489
- }
1490
- return sections;
1491
- }
1492
-
1493
- function cycleSection(view, currentSectionKey, direction = 1, availableSections = null) {
1494
- const order = Array.isArray(availableSections) && availableSections.length > 0
1495
- ? availableSections
1496
- : getSectionOrderForView(view);
1497
- if (order.length === 0) {
1498
- return currentSectionKey;
1499
- }
1500
-
1501
- const currentIndex = order.indexOf(currentSectionKey);
1502
- const safeIndex = currentIndex >= 0 ? currentIndex : 0;
1503
- const nextIndex = (safeIndex + direction + order.length) % order.length;
1504
- return order[nextIndex];
1505
- }
1506
-
1507
- function fileExists(filePath) {
1508
- try {
1509
- return fs.statSync(filePath).isFile();
1510
- } catch {
1511
- return false;
1512
- }
1513
- }
1514
-
1515
- function listMarkdownFiles(dirPath) {
1516
- try {
1517
- return fs.readdirSync(dirPath, { withFileTypes: true })
1518
- .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
1519
- .map((entry) => entry.name)
1520
- .sort((a, b) => a.localeCompare(b));
1521
- } catch {
1522
- return [];
1523
- }
1524
- }
1525
-
1526
- function pushFileEntry(entries, seenPaths, candidate) {
1527
- if (!candidate || typeof candidate.path !== 'string' || typeof candidate.label !== 'string') {
1528
- return;
1529
- }
1530
-
1531
- if (!fileExists(candidate.path)) {
1532
- return;
1533
- }
1534
-
1535
- if (seenPaths.has(candidate.path)) {
1536
- return;
1537
- }
1538
-
1539
- seenPaths.add(candidate.path);
1540
- entries.push({
1541
- path: candidate.path,
1542
- label: candidate.label,
1543
- scope: candidate.scope || 'other'
1544
- });
1545
- }
1546
-
1547
- function buildIntentScopedLabel(snapshot, intentId, filePath, fallbackName = 'file.md') {
1548
- const safeIntentId = typeof intentId === 'string' && intentId.trim() !== ''
1549
- ? intentId
1550
- : '';
1551
- const safeFallback = typeof fallbackName === 'string' && fallbackName.trim() !== ''
1552
- ? fallbackName
1553
- : 'file.md';
1554
-
1555
- if (typeof filePath === 'string' && filePath.trim() !== '') {
1556
- if (safeIntentId && typeof snapshot?.rootPath === 'string' && snapshot.rootPath.trim() !== '') {
1557
- const intentPath = path.join(snapshot.rootPath, 'intents', safeIntentId);
1558
- const relativePath = path.relative(intentPath, filePath);
1559
- if (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
1560
- return `${safeIntentId}/${relativePath.split(path.sep).join('/')}`;
1561
- }
1562
- }
1563
-
1564
- const basename = path.basename(filePath);
1565
- return safeIntentId ? `${safeIntentId}/${basename}` : basename;
1566
- }
1567
-
1568
- return safeIntentId ? `${safeIntentId}/${safeFallback}` : safeFallback;
1569
- }
1570
-
1571
- function findIntentIdForWorkItem(snapshot, workItemId) {
1572
- if (typeof workItemId !== 'string' || workItemId.trim() === '') {
1573
- return '';
1574
- }
1575
-
1576
- const intents = Array.isArray(snapshot?.intents) ? snapshot.intents : [];
1577
- for (const intent of intents) {
1578
- const items = Array.isArray(intent?.workItems) ? intent.workItems : [];
1579
- if (items.some((item) => item?.id === workItemId)) {
1580
- return intent?.id || '';
1581
- }
1582
- }
1583
-
1584
- return '';
1585
- }
1586
-
1587
- function resolveFireWorkItemPath(snapshot, intentId, workItemId, explicitPath) {
1588
- if (typeof explicitPath === 'string' && explicitPath.trim() !== '') {
1589
- return explicitPath;
1590
- }
1591
-
1592
- if (typeof snapshot?.rootPath !== 'string' || snapshot.rootPath.trim() === '') {
1593
- return null;
1594
- }
1595
-
1596
- if (typeof workItemId !== 'string' || workItemId.trim() === '') {
1597
- return null;
1598
- }
1599
-
1600
- const safeIntentId = typeof intentId === 'string' && intentId.trim() !== ''
1601
- ? intentId
1602
- : findIntentIdForWorkItem(snapshot, workItemId);
1603
-
1604
- if (!safeIntentId) {
1605
- return null;
1606
- }
1607
-
1608
- return path.join(snapshot.rootPath, 'intents', safeIntentId, 'work-items', `${workItemId}.md`);
1609
- }
1610
-
1611
- function collectFireRunFiles(run) {
1612
- if (!run || typeof run.folderPath !== 'string') {
1613
- return [];
1614
- }
1615
-
1616
- const names = ['run.md'];
1617
- if (run.hasPlan) names.push('plan.md');
1618
- if (run.hasTestReport) names.push('test-report.md');
1619
- if (run.hasWalkthrough) names.push('walkthrough.md');
1620
-
1621
- return names.map((fileName) => ({
1622
- label: `${run.id}/${fileName}`,
1623
- path: path.join(run.folderPath, fileName)
1624
- }));
1625
- }
1626
-
1627
- function collectAidlcBoltFiles(bolt) {
1628
- if (!bolt || typeof bolt.path !== 'string') {
1629
- return [];
1630
- }
1631
-
1632
- const fileNames = Array.isArray(bolt.files) && bolt.files.length > 0
1633
- ? bolt.files
1634
- : listMarkdownFiles(bolt.path);
1635
-
1636
- return fileNames.map((fileName) => ({
1637
- label: `${bolt.id}/${fileName}`,
1638
- path: path.join(bolt.path, fileName)
1639
- }));
1640
- }
1641
-
1642
- function collectSimpleSpecFiles(spec) {
1643
- if (!spec || typeof spec.path !== 'string') {
1644
- return [];
1645
- }
1646
-
1647
- const names = [];
1648
- if (spec.hasRequirements) names.push('requirements.md');
1649
- if (spec.hasDesign) names.push('design.md');
1650
- if (spec.hasTasks) names.push('tasks.md');
1651
-
1652
- return names.map((fileName) => ({
1653
- label: `${spec.name}/${fileName}`,
1654
- path: path.join(spec.path, fileName)
1655
- }));
1656
- }
1657
-
1658
- function getRunFileEntries(snapshot, flow, options = {}) {
1659
- const includeBacklog = options.includeBacklog !== false;
1660
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
1661
- const entries = [];
1662
- const seenPaths = new Set();
1663
-
1664
- if (effectiveFlow === 'aidlc') {
1665
- const bolt = getCurrentBolt(snapshot);
1666
- for (const file of collectAidlcBoltFiles(bolt)) {
1667
- pushFileEntry(entries, seenPaths, { ...file, scope: 'active' });
1668
- }
1669
-
1670
- if (!includeBacklog) {
1671
- return entries;
1672
- }
1673
-
1674
- const pendingBolts = Array.isArray(snapshot?.pendingBolts) ? snapshot.pendingBolts : [];
1675
- for (const pendingBolt of pendingBolts) {
1676
- for (const file of collectAidlcBoltFiles(pendingBolt)) {
1677
- pushFileEntry(entries, seenPaths, { ...file, scope: 'upcoming' });
1678
- }
1679
- }
1680
-
1681
- const completedBolts = Array.isArray(snapshot?.completedBolts) ? snapshot.completedBolts : [];
1682
- for (const completedBolt of completedBolts) {
1683
- for (const file of collectAidlcBoltFiles(completedBolt)) {
1684
- pushFileEntry(entries, seenPaths, { ...file, scope: 'completed' });
1685
- }
1686
- }
1687
-
1688
- const intentIds = new Set([
1689
- ...pendingBolts.map((item) => item?.intent).filter(Boolean),
1690
- ...completedBolts.map((item) => item?.intent).filter(Boolean)
1691
- ]);
1692
-
1693
- for (const intentId of intentIds) {
1694
- const intentPath = path.join(snapshot?.rootPath || '', 'intents', intentId);
1695
- pushFileEntry(entries, seenPaths, {
1696
- label: `${intentId}/requirements.md`,
1697
- path: path.join(intentPath, 'requirements.md'),
1698
- scope: 'intent'
1699
- });
1700
- pushFileEntry(entries, seenPaths, {
1701
- label: `${intentId}/system-context.md`,
1702
- path: path.join(intentPath, 'system-context.md'),
1703
- scope: 'intent'
1704
- });
1705
- pushFileEntry(entries, seenPaths, {
1706
- label: `${intentId}/units.md`,
1707
- path: path.join(intentPath, 'units.md'),
1708
- scope: 'intent'
1709
- });
1710
- }
1711
- return entries;
1712
- }
1713
-
1714
- if (effectiveFlow === 'simple') {
1715
- const spec = getCurrentSpec(snapshot);
1716
- for (const file of collectSimpleSpecFiles(spec)) {
1717
- pushFileEntry(entries, seenPaths, { ...file, scope: 'active' });
1718
- }
1719
-
1720
- if (!includeBacklog) {
1721
- return entries;
1722
- }
1723
-
1724
- const pendingSpecs = Array.isArray(snapshot?.pendingSpecs) ? snapshot.pendingSpecs : [];
1725
- for (const pendingSpec of pendingSpecs) {
1726
- for (const file of collectSimpleSpecFiles(pendingSpec)) {
1727
- pushFileEntry(entries, seenPaths, { ...file, scope: 'upcoming' });
1728
- }
1729
- }
1730
-
1731
- const completedSpecs = Array.isArray(snapshot?.completedSpecs) ? snapshot.completedSpecs : [];
1732
- for (const completedSpec of completedSpecs) {
1733
- for (const file of collectSimpleSpecFiles(completedSpec)) {
1734
- pushFileEntry(entries, seenPaths, { ...file, scope: 'completed' });
1735
- }
1736
- }
1737
-
1738
- return entries;
1739
- }
1740
-
1741
- const run = getCurrentRun(snapshot);
1742
- for (const file of collectFireRunFiles(run)) {
1743
- pushFileEntry(entries, seenPaths, { ...file, scope: 'active' });
1744
- }
1745
-
1746
- if (!includeBacklog) {
1747
- return entries;
1748
- }
1749
-
1750
- const pendingItems = Array.isArray(snapshot?.pendingItems) ? snapshot.pendingItems : [];
1751
- for (const pendingItem of pendingItems) {
1752
- pushFileEntry(entries, seenPaths, {
1753
- label: buildIntentScopedLabel(
1754
- snapshot,
1755
- pendingItem?.intentId,
1756
- pendingItem?.filePath,
1757
- `${pendingItem?.id || 'work-item'}.md`
1758
- ),
1759
- path: pendingItem?.filePath,
1760
- scope: 'upcoming'
1761
- });
1762
-
1763
- if (pendingItem?.intentId) {
1764
- pushFileEntry(entries, seenPaths, {
1765
- label: buildIntentScopedLabel(
1766
- snapshot,
1767
- pendingItem.intentId,
1768
- path.join(snapshot?.rootPath || '', 'intents', pendingItem.intentId, 'brief.md'),
1769
- 'brief.md'
1770
- ),
1771
- path: path.join(snapshot?.rootPath || '', 'intents', pendingItem.intentId, 'brief.md'),
1772
- scope: 'intent'
1773
- });
1774
- }
1775
- }
1776
-
1777
- const completedRuns = Array.isArray(snapshot?.completedRuns) ? snapshot.completedRuns : [];
1778
- for (const completedRun of completedRuns) {
1779
- for (const file of collectFireRunFiles(completedRun)) {
1780
- pushFileEntry(entries, seenPaths, { ...file, scope: 'completed' });
1781
- }
1782
- }
1783
-
1784
- const completedIntents = Array.isArray(snapshot?.intents)
1785
- ? snapshot.intents.filter((intent) => intent?.status === 'completed')
1786
- : [];
1787
- for (const intent of completedIntents) {
1788
- pushFileEntry(entries, seenPaths, {
1789
- label: buildIntentScopedLabel(
1790
- snapshot,
1791
- intent?.id,
1792
- path.join(snapshot?.rootPath || '', 'intents', intent?.id || '', 'brief.md'),
1793
- 'brief.md'
1794
- ),
1795
- path: path.join(snapshot?.rootPath || '', 'intents', intent.id, 'brief.md'),
1796
- scope: 'intent'
1797
- });
1798
- }
1799
-
1800
- return entries;
1801
- }
1802
-
1803
- function clampIndex(value, length) {
1804
- if (!Number.isFinite(value)) {
1805
- return 0;
1806
- }
1807
- if (!Number.isFinite(length) || length <= 0) {
1808
- return 0;
1809
- }
1810
- return Math.max(0, Math.min(length - 1, Math.floor(value)));
1811
- }
1812
-
1813
- function getNoFileMessage(flow) {
1814
- return `No selectable files for ${String(flow || 'flow').toUpperCase()}`;
1815
- }
1816
-
1817
- function formatScope(scope) {
1818
- if (scope === 'active') return 'ACTIVE';
1819
- if (scope === 'upcoming') return 'UPNEXT';
1820
- if (scope === 'completed') return 'DONE';
1821
- if (scope === 'intent') return 'INTENT';
1822
- if (scope === 'staged') return 'STAGED';
1823
- if (scope === 'unstaged') return 'UNSTAGED';
1824
- if (scope === 'untracked') return 'UNTRACKED';
1825
- if (scope === 'conflicted') return 'CONFLICT';
1826
- return 'FILE';
1827
- }
1828
-
1829
- function getNoPendingMessage(flow) {
1830
- if (flow === 'aidlc') return 'No queued bolts';
1831
- if (flow === 'simple') return 'No pending specs';
1832
- return 'No pending work items';
1833
- }
1834
-
1835
- function getNoCompletedMessage(flow) {
1836
- if (flow === 'aidlc') return 'No completed bolts yet';
1837
- if (flow === 'simple') return 'No completed specs yet';
1838
- return 'No completed runs yet';
1839
- }
1840
-
1841
- function getNoCurrentMessage(flow) {
1842
- if (flow === 'aidlc') return 'No active bolt';
1843
- if (flow === 'simple') return 'No active spec';
1844
- return 'No active run';
1845
- }
1846
-
1847
- function buildFireCurrentRunGroups(snapshot) {
1848
- const run = getCurrentRun(snapshot);
1849
- if (!run) {
1850
- return [];
1851
- }
1852
-
1853
- const workItems = Array.isArray(run.workItems) ? run.workItems : [];
1854
- const completed = workItems.filter((item) => item.status === 'completed').length;
1855
- const currentWorkItem = getCurrentFireWorkItem(run);
1856
- const awaitingApproval = isFireRunAwaitingApproval(run, currentWorkItem);
1857
-
1858
- const currentPhase = getCurrentPhaseLabel(run, currentWorkItem);
1859
- const phaseTrack = buildPhaseTrack(currentPhase);
1860
- const mode = String(currentWorkItem?.mode || 'confirm').toUpperCase();
1861
- const status = currentWorkItem?.status || 'pending';
1862
- const statusTag = status === 'in_progress' ? 'current' : status;
1863
-
1864
- const runIntentId = typeof run?.intent === 'string' ? run.intent : '';
1865
- const currentWorkItemFiles = workItems.map((item, index) => {
1866
- const itemId = typeof item?.id === 'string' && item.id !== '' ? item.id : `work-item-${index + 1}`;
1867
- const intentId = typeof item?.intent === 'string' && item.intent !== ''
1868
- ? item.intent
1869
- : (runIntentId || findIntentIdForWorkItem(snapshot, itemId));
1870
- const filePath = resolveFireWorkItemPath(snapshot, intentId, itemId, item?.filePath);
1871
- if (!filePath) {
1872
- return null;
1873
- }
1874
-
1875
- const itemMode = String(item?.mode || 'confirm').toUpperCase();
1876
- const itemStatus = item?.status || 'pending';
1877
- const isCurrent = Boolean(currentWorkItem?.id) && itemId === currentWorkItem.id;
1878
- const itemScope = isCurrent
1879
- ? 'active'
1880
- : (itemStatus === 'completed' ? 'completed' : 'upcoming');
1881
- const itemStatusTag = isCurrent ? 'current' : itemStatus;
1882
- const labelPath = buildIntentScopedLabel(snapshot, intentId, filePath, `${itemId}.md`);
1883
-
1884
- return {
1885
- label: `${labelPath} [${itemMode}] [${itemStatusTag}]`,
1886
- path: filePath,
1887
- scope: itemScope
1888
- };
1889
- }).filter(Boolean);
1890
-
1891
- const currentRunFiles = collectFireRunFiles(run).map((fileEntry) => ({
1892
- ...fileEntry,
1893
- label: path.basename(fileEntry.path || fileEntry.label || ''),
1894
- scope: 'active'
1895
- }));
1896
-
1897
- return [
1898
- {
1899
- key: `current:run:${run.id}:summary`,
1900
- label: `${run.id} [${run.scope}] ${completed}/${workItems.length} items${awaitingApproval ? ' [APPROVAL]' : ''}`,
1901
- files: []
1902
- },
1903
- {
1904
- key: `current:run:${run.id}:work-items`,
1905
- label: `WORK ITEMS (${currentWorkItemFiles.length})`,
1906
- files: filterExistingFiles(currentWorkItemFiles)
1907
- },
1908
- {
1909
- key: `current:run:${run.id}:run-files`,
1910
- label: 'RUN FILES',
1911
- files: filterExistingFiles(currentRunFiles)
1912
- }
1913
- ];
1914
- }
1915
-
1916
- function buildCurrentGroups(snapshot, flow) {
1917
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
1918
-
1919
- if (effectiveFlow === 'aidlc') {
1920
- const bolt = getCurrentBolt(snapshot);
1921
- if (!bolt) {
1922
- return [];
1923
- }
1924
- const stages = Array.isArray(bolt.stages) ? bolt.stages : [];
1925
- const completedStages = stages.filter((stage) => stage.status === 'completed').length;
1926
- const awaitingApproval = isAidlcBoltAwaitingApproval(bolt);
1927
- return [{
1928
- key: `current:bolt:${bolt.id}`,
1929
- label: `${bolt.id} [${bolt.type}] ${completedStages}/${stages.length} stages${awaitingApproval ? ' [APPROVAL]' : ''}`,
1930
- files: filterExistingFiles([
1931
- ...collectAidlcBoltFiles(bolt),
1932
- ...collectAidlcIntentContextFiles(snapshot, bolt.intent)
1933
- ])
1934
- }];
1935
- }
1936
-
1937
- if (effectiveFlow === 'simple') {
1938
- const spec = getCurrentSpec(snapshot);
1939
- if (!spec) {
1940
- return [];
1941
- }
1942
- return [{
1943
- key: `current:spec:${spec.name}`,
1944
- label: `${spec.name} [${spec.state}] ${spec.tasksCompleted}/${spec.tasksTotal} tasks`,
1945
- files: filterExistingFiles(collectSimpleSpecFiles(spec))
1946
- }];
1947
- }
1948
-
1949
- return buildFireCurrentRunGroups(snapshot);
1950
- }
1951
-
1952
- function buildRunFileGroups(fileEntries) {
1953
- const order = ['active', 'upcoming', 'completed', 'intent', 'other'];
1954
- const buckets = new Map(order.map((scope) => [scope, []]));
1955
-
1956
- for (const fileEntry of Array.isArray(fileEntries) ? fileEntries : []) {
1957
- const scope = order.includes(fileEntry?.scope) ? fileEntry.scope : 'other';
1958
- buckets.get(scope).push(fileEntry);
1959
- }
1960
-
1961
- const groups = [];
1962
- for (const scope of order) {
1963
- const files = buckets.get(scope) || [];
1964
- if (files.length === 0) {
1965
- continue;
1966
- }
1967
- groups.push({
1968
- key: `run-files:scope:${scope}`,
1969
- label: `${formatScope(scope)} files (${files.length})`,
1970
- files: filterExistingFiles(files)
1971
- });
1972
- }
1973
- return groups;
1974
- }
1975
-
1976
- function getFileEntityLabel(fileEntry, fallbackIndex = 0) {
1977
- const rawLabel = typeof fileEntry?.label === 'string' ? fileEntry.label : '';
1978
- if (rawLabel.includes('/')) {
1979
- return rawLabel.split('/')[0] || `item-${fallbackIndex + 1}`;
1980
- }
1981
-
1982
- const filePath = typeof fileEntry?.path === 'string' ? fileEntry.path : '';
1983
- if (filePath !== '') {
1984
- const parentDir = path.basename(path.dirname(filePath));
1985
- if (parentDir && parentDir !== '.' && parentDir !== path.sep) {
1986
- return parentDir;
1987
- }
1988
-
1989
- const baseName = path.basename(filePath);
1990
- if (baseName) {
1991
- return baseName;
1992
- }
1993
- }
1994
-
1995
- return `item-${fallbackIndex + 1}`;
1996
- }
1997
-
1998
- function buildRunFileEntityGroups(snapshot, flow, options = {}) {
1999
- const order = ['active', 'upcoming', 'completed', 'intent', 'other'];
2000
- const rankByScope = new Map(order.map((scope, index) => [scope, index]));
2001
- const entries = filterExistingFiles(getRunFileEntries(snapshot, flow, options));
2002
- const groupsByEntity = new Map();
2003
-
2004
- for (let index = 0; index < entries.length; index += 1) {
2005
- const fileEntry = entries[index];
2006
- const entity = getFileEntityLabel(fileEntry, index);
2007
- const scope = order.includes(fileEntry?.scope) ? fileEntry.scope : 'other';
2008
- const scopeRank = rankByScope.get(scope) ?? rankByScope.get('other');
2009
-
2010
- if (!groupsByEntity.has(entity)) {
2011
- groupsByEntity.set(entity, {
2012
- entity,
2013
- files: [],
2014
- scope,
2015
- scopeRank
2016
- });
2017
- }
2018
-
2019
- const group = groupsByEntity.get(entity);
2020
- group.files.push(fileEntry);
2021
-
2022
- if (scopeRank < group.scopeRank) {
2023
- group.scopeRank = scopeRank;
2024
- group.scope = scope;
2025
- }
2026
- }
2027
-
2028
- return Array.from(groupsByEntity.values())
2029
- .sort((a, b) => {
2030
- if (a.scopeRank !== b.scopeRank) {
2031
- return a.scopeRank - b.scopeRank;
2032
- }
2033
- return String(a.entity).localeCompare(String(b.entity));
2034
- })
2035
- .map((group) => ({
2036
- key: `run-files:entity:${group.entity}`,
2037
- label: `${group.entity} [${formatScope(group.scope)}] (${group.files.length})`,
2038
- files: filterExistingFiles(group.files)
2039
- }))
2040
- .filter((group) => group.files.length > 0);
2041
- }
2042
-
2043
- function normalizeInfoLine(line) {
2044
- const normalized = normalizePanelLine(line);
2045
- return {
2046
- label: normalized.text,
2047
- color: normalized.color,
2048
- bold: normalized.bold
2049
- };
2050
- }
2051
-
2052
- function toInfoRows(lines, keyPrefix, emptyLabel = 'No data') {
2053
- const safe = Array.isArray(lines) ? lines : [];
2054
- if (safe.length === 0) {
2055
- return [{
2056
- kind: 'info',
2057
- key: `${keyPrefix}:empty`,
2058
- label: emptyLabel,
2059
- selectable: false
2060
- }];
2061
- }
2062
-
2063
- return safe.map((line, index) => {
2064
- const normalized = normalizeInfoLine(line);
2065
- return {
2066
- kind: 'info',
2067
- key: `${keyPrefix}:${index}`,
2068
- label: normalized.label,
2069
- color: normalized.color,
2070
- bold: normalized.bold,
2071
- selectable: true
2072
- };
2073
- });
2074
- }
2075
-
2076
- function toLoadingRows(label, keyPrefix = 'loading') {
2077
- return [{
2078
- kind: 'loading',
2079
- key: `${keyPrefix}:row`,
2080
- label: typeof label === 'string' && label !== '' ? label : 'Loading...',
2081
- selectable: false
2082
- }];
2083
- }
2084
-
2085
- function buildOverviewIntentGroups(snapshot, flow, filter = 'next') {
2086
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
2087
- const normalizedFilter = filter === 'completed' ? 'completed' : 'next';
2088
- const isIncluded = (status) => {
2089
- if (normalizedFilter === 'completed') {
2090
- return status === 'completed';
2091
- }
2092
- return status !== 'completed';
2093
- };
2094
-
2095
- if (effectiveFlow === 'aidlc') {
2096
- const intents = Array.isArray(snapshot?.intents) ? snapshot.intents : [];
2097
- return intents
2098
- .filter((intent) => isIncluded(intent?.status || 'pending'))
2099
- .map((intent, index) => ({
2100
- key: `overview:intent:${intent?.id || index}`,
2101
- label: `${intent?.id || 'unknown'}: ${intent?.status || 'pending'} (${intent?.completedStories || 0}/${intent?.storyCount || 0} stories, ${intent?.completedUnits || 0}/${intent?.unitCount || 0} units)`,
2102
- files: filterExistingFiles(collectAidlcIntentContextFiles(snapshot, intent?.id))
2103
- }));
2104
- }
2105
-
2106
- if (effectiveFlow === 'simple') {
2107
- const specs = Array.isArray(snapshot?.specs) ? snapshot.specs : [];
2108
- return specs
2109
- .filter((spec) => isIncluded(spec?.state || 'pending'))
2110
- .map((spec, index) => ({
2111
- key: `overview:spec:${spec?.name || index}`,
2112
- label: `${spec?.name || 'unknown'}: ${spec?.state || 'pending'} (${spec?.tasksCompleted || 0}/${spec?.tasksTotal || 0} tasks)`,
2113
- files: filterExistingFiles(collectSimpleSpecFiles(spec))
2114
- }));
2115
- }
2116
-
2117
- const intents = Array.isArray(snapshot?.intents) ? snapshot.intents : [];
2118
- return intents
2119
- .filter((intent) => isIncluded(intent?.status || 'pending'))
2120
- .map((intent, index) => {
2121
- const workItems = Array.isArray(intent?.workItems) ? intent.workItems : [];
2122
- const done = workItems.filter((item) => item.status === 'completed').length;
2123
- const files = [{
2124
- label: buildIntentScopedLabel(snapshot, intent?.id, intent?.filePath, 'brief.md'),
2125
- path: intent?.filePath,
2126
- scope: 'intent'
2127
- }, ...workItems.map((item) => ({
2128
- label: buildIntentScopedLabel(
2129
- snapshot,
2130
- intent?.id,
2131
- item?.filePath,
2132
- `${item?.id || 'work-item'}.md`
2133
- ),
2134
- path: item?.filePath,
2135
- scope: item?.status === 'completed' ? 'completed' : 'upcoming'
2136
- }))];
2137
- return {
2138
- key: `overview:intent:${intent?.id || index}`,
2139
- label: `${intent?.id || 'unknown'}: ${intent?.status || 'pending'} (${done}/${workItems.length} work items)`,
2140
- files: filterExistingFiles(files)
2141
- };
2142
- });
2143
- }
2144
-
2145
- function buildStandardsRows(snapshot, flow) {
2146
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
2147
- if (effectiveFlow === 'simple') {
2148
- return [{
2149
- kind: 'info',
2150
- key: 'standards:empty:simple',
2151
- label: 'No standards for SIMPLE flow',
2152
- selectable: false
2153
- }];
2154
- }
2155
-
2156
- const standards = Array.isArray(snapshot?.standards) ? snapshot.standards : [];
2157
- const files = filterExistingFiles(standards.map((standard, index) => ({
2158
- label: `${standard?.name || standard?.type || `standard-${index}`}.md`,
2159
- path: standard?.filePath,
2160
- scope: 'file'
2161
- })));
2162
-
2163
- if (files.length === 0) {
2164
- return [{
2165
- kind: 'info',
2166
- key: 'standards:empty',
2167
- label: 'No standards found',
2168
- selectable: false
2169
- }];
2170
- }
2171
-
2172
- return files.map((file, index) => ({
2173
- kind: 'file',
2174
- key: `standards:file:${file.path}:${index}`,
2175
- label: file.label,
2176
- path: file.path,
2177
- scope: 'file',
2178
- selectable: true
2179
- }));
2180
- }
2181
-
2182
- function buildProjectGroups(snapshot, flow) {
2183
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
2184
- const files = [];
2185
-
2186
- if (effectiveFlow === 'aidlc') {
2187
- files.push({
2188
- label: 'memory-bank/project.yaml',
2189
- path: path.join(snapshot?.rootPath || '', 'project.yaml'),
2190
- scope: 'file'
2191
- });
2192
- } else if (effectiveFlow === 'simple') {
2193
- files.push({
2194
- label: 'package.json',
2195
- path: path.join(snapshot?.workspacePath || '', 'package.json'),
2196
- scope: 'file'
2197
- });
2198
- } else {
2199
- files.push({
2200
- label: '.specs-fire/state.yaml',
2201
- path: path.join(snapshot?.rootPath || '', 'state.yaml'),
2202
- scope: 'file'
2203
- });
2204
- }
2205
-
2206
- const projectName = snapshot?.project?.name || 'unknown-project';
2207
- return [{
2208
- key: `project:${projectName}`,
2209
- label: `project ${projectName}`,
2210
- files: filterExistingFiles(files)
2211
- }];
2212
- }
2213
-
2214
- function collectAidlcIntentContextFiles(snapshot, intentId) {
2215
- if (!snapshot || typeof intentId !== 'string' || intentId.trim() === '') {
2216
- return [];
2217
- }
2218
-
2219
- const intentPath = path.join(snapshot.rootPath || '', 'intents', intentId);
2220
- return [
2221
- {
2222
- label: `${intentId}/requirements.md`,
2223
- path: path.join(intentPath, 'requirements.md'),
2224
- scope: 'intent'
2225
- },
2226
- {
2227
- label: `${intentId}/system-context.md`,
2228
- path: path.join(intentPath, 'system-context.md'),
2229
- scope: 'intent'
2230
- },
2231
- {
2232
- label: `${intentId}/units.md`,
2233
- path: path.join(intentPath, 'units.md'),
2234
- scope: 'intent'
2235
- }
2236
- ];
2237
- }
2238
-
2239
- function filterExistingFiles(files) {
2240
- return (Array.isArray(files) ? files : []).filter((file) => {
2241
- if (!file || typeof file.path !== 'string' || typeof file.label !== 'string') {
2242
- return false;
2243
- }
2244
- if (file.allowMissing === true) {
2245
- return true;
2246
- }
2247
- return fileExists(file.path);
2248
- });
2249
- }
2250
-
2251
- function buildPendingGroups(snapshot, flow) {
2252
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
2253
-
2254
- if (effectiveFlow === 'aidlc') {
2255
- const pendingBolts = Array.isArray(snapshot?.pendingBolts) ? snapshot.pendingBolts : [];
2256
- return pendingBolts.map((bolt, index) => {
2257
- const deps = Array.isArray(bolt?.blockedBy) && bolt.blockedBy.length > 0
2258
- ? ` blocked_by:${bolt.blockedBy.join(',')}`
2259
- : '';
2260
- const location = `${bolt?.intent || 'unknown'}/${bolt?.unit || 'unknown'}`;
2261
- const boltFiles = collectAidlcBoltFiles(bolt);
2262
- const intentFiles = collectAidlcIntentContextFiles(snapshot, bolt?.intent);
2263
- return {
2264
- key: `pending:bolt:${bolt?.id || index}`,
2265
- label: `${bolt?.id || 'unknown'} (${bolt?.status || 'pending'}) in ${location}${deps}`,
2266
- files: filterExistingFiles([...boltFiles, ...intentFiles])
2267
- };
2268
- });
2269
- }
2270
-
2271
- if (effectiveFlow === 'simple') {
2272
- const pendingSpecs = Array.isArray(snapshot?.pendingSpecs) ? snapshot.pendingSpecs : [];
2273
- return pendingSpecs.map((spec, index) => ({
2274
- key: `pending:spec:${spec?.name || index}`,
2275
- label: `${spec?.name || 'unknown'} (${spec?.state || 'pending'}) ${spec?.tasksCompleted || 0}/${spec?.tasksTotal || 0} tasks`,
2276
- files: filterExistingFiles(collectSimpleSpecFiles(spec))
2277
- }));
2278
- }
2279
-
2280
- const pendingItems = Array.isArray(snapshot?.pendingItems) ? snapshot.pendingItems : [];
2281
- return pendingItems.map((item, index) => {
2282
- const deps = Array.isArray(item?.dependencies) && item.dependencies.length > 0
2283
- ? ` deps:${item.dependencies.join(',')}`
2284
- : '';
2285
- const intentTitle = item?.intentTitle || item?.intentId || 'unknown-intent';
2286
- const files = [];
2287
-
2288
- if (item?.filePath) {
2289
- files.push({
2290
- label: buildIntentScopedLabel(
2291
- snapshot,
2292
- item?.intentId,
2293
- item?.filePath,
2294
- `${item?.id || 'work-item'}.md`
2295
- ),
2296
- path: item.filePath,
2297
- scope: 'upcoming'
2298
- });
2299
- }
2300
- if (item?.intentId) {
2301
- files.push({
2302
- label: buildIntentScopedLabel(
2303
- snapshot,
2304
- item.intentId,
2305
- path.join(snapshot?.rootPath || '', 'intents', item.intentId, 'brief.md'),
2306
- 'brief.md'
2307
- ),
2308
- path: path.join(snapshot?.rootPath || '', 'intents', item.intentId, 'brief.md'),
2309
- scope: 'intent'
2310
- });
2311
- }
2312
-
2313
- return {
2314
- key: `pending:item:${item?.intentId || 'intent'}:${item?.id || index}`,
2315
- label: `${item?.id || 'work-item'} (${item?.mode || 'confirm'}/${item?.complexity || 'medium'}) in ${intentTitle}${deps}`,
2316
- files: filterExistingFiles(files)
2317
- };
2318
- });
2319
- }
2320
-
2321
- function buildCompletedGroups(snapshot, flow) {
2322
- const effectiveFlow = getEffectiveFlow(flow, snapshot);
2323
-
2324
- if (effectiveFlow === 'aidlc') {
2325
- const completedBolts = Array.isArray(snapshot?.completedBolts) ? snapshot.completedBolts : [];
2326
- return completedBolts.map((bolt, index) => {
2327
- const boltFiles = collectAidlcBoltFiles(bolt);
2328
- const intentFiles = collectAidlcIntentContextFiles(snapshot, bolt?.intent);
2329
- return {
2330
- key: `completed:bolt:${bolt?.id || index}`,
2331
- label: `${bolt?.id || 'unknown'} [${bolt?.type || 'bolt'}] done at ${bolt?.completedAt || 'unknown'}`,
2332
- files: filterExistingFiles([...boltFiles, ...intentFiles])
2333
- };
2334
- });
2335
- }
2336
-
2337
- if (effectiveFlow === 'simple') {
2338
- const completedSpecs = Array.isArray(snapshot?.completedSpecs) ? snapshot.completedSpecs : [];
2339
- return completedSpecs.map((spec, index) => ({
2340
- key: `completed:spec:${spec?.name || index}`,
2341
- label: `${spec?.name || 'unknown'} done at ${spec?.updatedAt || 'unknown'} (${spec?.tasksCompleted || 0}/${spec?.tasksTotal || 0})`,
2342
- files: filterExistingFiles(collectSimpleSpecFiles(spec))
2343
- }));
2344
- }
2345
-
2346
- const groups = [];
2347
- const completedRuns = Array.isArray(snapshot?.completedRuns) ? snapshot.completedRuns : [];
2348
- for (let index = 0; index < completedRuns.length; index += 1) {
2349
- const run = completedRuns[index];
2350
- const workItems = Array.isArray(run?.workItems) ? run.workItems : [];
2351
- const completed = workItems.filter((item) => item.status === 'completed').length;
2352
- groups.push({
2353
- key: `completed:run:${run?.id || index}`,
2354
- label: `${run?.id || 'run'} [${run?.scope || 'single'}] ${completed}/${workItems.length} done at ${run?.completedAt || 'unknown'}`,
2355
- files: filterExistingFiles(collectFireRunFiles(run).map((file) => ({ ...file, scope: 'completed' })))
2356
- });
2357
- }
2358
-
2359
- const completedIntents = Array.isArray(snapshot?.intents)
2360
- ? snapshot.intents.filter((intent) => intent?.status === 'completed')
2361
- : [];
2362
- for (let index = 0; index < completedIntents.length; index += 1) {
2363
- const intent = completedIntents[index];
2364
- groups.push({
2365
- key: `completed:intent:${intent?.id || index}`,
2366
- label: `intent ${intent?.id || 'unknown'} [completed]`,
2367
- files: filterExistingFiles([{
2368
- label: buildIntentScopedLabel(
2369
- snapshot,
2370
- intent?.id,
2371
- path.join(snapshot?.rootPath || '', 'intents', intent?.id || '', 'brief.md'),
2372
- 'brief.md'
2373
- ),
2374
- path: path.join(snapshot?.rootPath || '', 'intents', intent?.id || '', 'brief.md'),
2375
- scope: 'intent'
2376
- }])
2377
- });
2378
- }
2379
-
2380
- return groups;
2381
- }
2382
-
2383
- function buildGitChangeGroups(snapshot) {
2384
- const git = getGitChangesSnapshot(snapshot);
2385
-
2386
- if (!git.available) {
2387
- return [];
2388
- }
2389
-
2390
- const makeFiles = (items, scope) => items.map((item) => ({
2391
- label: item.relativePath,
2392
- path: item.path || path.join(git.rootPath || snapshot?.workspacePath || '', item.relativePath || ''),
2393
- scope,
2394
- allowMissing: true,
2395
- previewType: 'git-diff',
2396
- repoRoot: git.rootPath || snapshot?.workspacePath || '',
2397
- relativePath: item.relativePath || '',
2398
- bucket: item.bucket || scope
2399
- }));
2400
-
2401
- const groups = [];
2402
- groups.push({
2403
- key: 'git:staged',
2404
- label: `staged (${git.counts.staged || 0})`,
2405
- files: makeFiles(git.staged, 'staged')
2406
- });
2407
- groups.push({
2408
- key: 'git:unstaged',
2409
- label: `unstaged (${git.counts.unstaged || 0})`,
2410
- files: makeFiles(git.unstaged, 'unstaged')
2411
- });
2412
- groups.push({
2413
- key: 'git:untracked',
2414
- label: `untracked (${git.counts.untracked || 0})`,
2415
- files: makeFiles(git.untracked, 'untracked')
2416
- });
2417
- groups.push({
2418
- key: 'git:conflicted',
2419
- label: `conflicts (${git.counts.conflicted || 0})`,
2420
- files: makeFiles(git.conflicted, 'conflicted')
2421
- });
2422
-
2423
- return groups;
2424
- }
2425
-
2426
- function toExpandableRows(groups, emptyLabel, expandedGroups) {
2427
- if (!Array.isArray(groups) || groups.length === 0) {
2428
- return [{
2429
- kind: 'info',
2430
- key: 'section:empty',
2431
- label: emptyLabel,
2432
- selectable: false
2433
- }];
2434
- }
2435
-
2436
- const rows = [];
2437
-
2438
- for (const group of groups) {
2439
- const files = filterExistingFiles(group?.files);
2440
- const expandable = files.length > 0;
2441
- const expanded = expandable && Boolean(expandedGroups?.[group.key]);
2442
-
2443
- rows.push({
2444
- kind: 'group',
2445
- key: group.key,
2446
- label: group.label,
2447
- expandable,
2448
- expanded,
2449
- selectable: true
2450
- });
2451
-
2452
- if (expanded) {
2453
- for (let index = 0; index < files.length; index += 1) {
2454
- const file = files[index];
2455
- rows.push({
2456
- kind: file.previewType === 'git-diff' ? 'git-file' : 'file',
2457
- key: `${group.key}:file:${file.path}:${index}`,
2458
- label: file.label,
2459
- path: file.path,
2460
- scope: file.scope || 'file',
2461
- selectable: true,
2462
- previewType: file.previewType,
2463
- repoRoot: file.repoRoot,
2464
- relativePath: file.relativePath,
2465
- bucket: file.bucket
2466
- });
2467
- }
2468
- }
2469
- }
2470
-
2471
- return rows;
2472
- }
2473
-
2474
- function buildInteractiveRowsLines(rows, selectedIndex, icons, width, isFocusedSection) {
2475
- if (!Array.isArray(rows) || rows.length === 0) {
2476
- return [{ text: '', color: undefined, bold: false, selected: false }];
2477
- }
2478
-
2479
- const clampedIndex = clampIndex(selectedIndex, rows.length);
2480
-
2481
- return rows.map((row, index) => {
2482
- const selectable = row?.selectable !== false;
2483
- const isSelected = selectable && index === clampedIndex;
2484
- const cursor = isSelected
2485
- ? (isFocusedSection ? (icons.activeFile || '>') : '•')
2486
- : ' ';
2487
-
2488
- if (row.kind === 'group') {
2489
- const marker = row.expandable
2490
- ? (row.expanded ? (icons.groupExpanded || 'v') : (icons.groupCollapsed || '>'))
2491
- : '-';
2492
- return {
2493
- text: truncate(`${cursor} ${marker} ${row.label}`, width),
2494
- color: isSelected ? (isFocusedSection ? 'green' : 'cyan') : undefined,
2495
- bold: isSelected,
2496
- selected: isSelected
2497
- };
2498
- }
2499
-
2500
- if (row.kind === 'file' || row.kind === 'git-file' || row.kind === 'git-commit') {
2501
- const scope = row.scope ? `[${formatScope(row.scope)}] ` : '';
2502
- return {
2503
- text: truncate(`${cursor} ${icons.runFile} ${scope}${row.label}`, width),
2504
- color: isSelected ? (isFocusedSection ? 'green' : 'cyan') : 'gray',
2505
- bold: isSelected,
2506
- selected: isSelected
2507
- };
2508
- }
2509
-
2510
- if (row.kind === 'loading') {
2511
- return {
2512
- text: truncate(row.label || 'Loading...', width),
2513
- color: 'cyan',
2514
- bold: false,
2515
- selected: false,
2516
- loading: true
2517
- };
2518
- }
2519
-
2520
- return {
2521
- text: truncate(`${isSelected ? `${cursor} ` : ' '}${row.label || ''}`, width),
2522
- color: isSelected ? (isFocusedSection ? 'green' : 'cyan') : (row.color || 'gray'),
2523
- bold: isSelected || Boolean(row.bold),
2524
- selected: isSelected
2525
- };
2526
- });
2527
- }
2528
-
2529
- function getSelectedRow(rows, selectedIndex) {
2530
- if (!Array.isArray(rows) || rows.length === 0) {
2531
- return null;
2532
- }
2533
- return rows[clampIndex(selectedIndex, rows.length)] || null;
2534
- }
2535
-
2536
- function rowToFileEntry(row) {
2537
- if (!row) {
2538
- return null;
2539
- }
2540
-
2541
- if (row.kind === 'git-commit') {
2542
- const commitHash = typeof row.commitHash === 'string' ? row.commitHash : '';
2543
- if (commitHash === '') {
2544
- return null;
2545
- }
2546
- return {
2547
- label: row.label || commitHash,
2548
- path: commitHash,
2549
- scope: 'commit',
2550
- previewType: row.previewType || 'git-commit-diff',
2551
- repoRoot: row.repoRoot,
2552
- commitHash
2553
- };
2554
- }
2555
-
2556
- if ((row.kind !== 'file' && row.kind !== 'git-file') || typeof row.path !== 'string') {
2557
- return null;
2558
- }
2559
- return {
2560
- label: row.label || path.basename(row.path),
2561
- path: row.path,
2562
- scope: row.scope || 'file',
2563
- previewType: row.previewType,
2564
- repoRoot: row.repoRoot,
2565
- relativePath: row.relativePath,
2566
- bucket: row.bucket
2567
- };
2568
- }
2569
-
2570
- function firstFileEntryFromRows(rows) {
2571
- if (!Array.isArray(rows) || rows.length === 0) {
2572
- return null;
2573
- }
2574
-
2575
- for (const row of rows) {
2576
- const entry = rowToFileEntry(row);
2577
- if (entry) {
2578
- return entry;
2579
- }
2580
- }
2581
-
2582
- return null;
2583
- }
2584
-
2585
- function rowToWorktreeId(row) {
2586
- if (!row || typeof row.key !== 'string') {
2587
- return null;
2588
- }
2589
-
2590
- const prefix = 'worktree:item:';
2591
- if (!row.key.startsWith(prefix)) {
2592
- return null;
2593
- }
2594
-
2595
- const worktreeId = row.key.slice(prefix.length).trim();
2596
- return worktreeId === '' ? null : worktreeId;
2597
- }
2598
-
2599
- function moveRowSelection(rows, currentIndex, direction) {
2600
- if (!Array.isArray(rows) || rows.length === 0) {
2601
- return 0;
2602
- }
2603
-
2604
- const clamped = clampIndex(currentIndex, rows.length);
2605
- const step = direction >= 0 ? 1 : -1;
2606
- let next = clamped + step;
2607
-
2608
- while (next >= 0 && next < rows.length) {
2609
- if (rows[next]?.selectable !== false) {
2610
- return next;
2611
- }
2612
- next += step;
2613
- }
2614
-
2615
- return clamped;
2616
- }
2617
-
2618
- function openFileWithDefaultApp(filePath) {
2619
- if (typeof filePath !== 'string' || filePath.trim() === '') {
2620
- return {
2621
- ok: false,
2622
- message: 'No file selected to open.'
2623
- };
2624
- }
2625
-
2626
- if (!fileExists(filePath)) {
2627
- return {
2628
- ok: false,
2629
- message: `File not found: ${filePath}`
2630
- };
2631
- }
2632
-
2633
- let command = null;
2634
- let args = [];
2635
-
2636
- if (process.platform === 'darwin') {
2637
- command = 'open';
2638
- args = [filePath];
2639
- } else if (process.platform === 'win32') {
2640
- command = 'cmd';
2641
- args = ['/c', 'start', '', filePath];
2642
- } else {
2643
- command = 'xdg-open';
2644
- args = [filePath];
2645
- }
2646
-
2647
- const result = spawnSync(command, args, { stdio: 'ignore' });
2648
- if (result.error) {
2649
- return {
2650
- ok: false,
2651
- message: `Unable to open file: ${result.error.message}`
2652
- };
2653
- }
2654
- if (typeof result.status === 'number' && result.status !== 0) {
2655
- return {
2656
- ok: false,
2657
- message: `Open command failed with exit code ${result.status}.`
2658
- };
2659
- }
2660
-
2661
- return {
2662
- ok: true,
2663
- message: `Opened ${filePath}`
2664
- };
2665
- }
2666
-
2667
- function buildQuickHelpText(view, options = {}) {
2668
- const {
2669
- flow = 'fire',
2670
- previewOpen = false,
2671
- availableFlowCount = 1,
2672
- hasWorktrees = false
2673
- } = options;
2674
- const isAidlc = String(flow || '').toLowerCase() === 'aidlc';
2675
- const isSimple = String(flow || '').toLowerCase() === 'simple';
2676
- const activeLabel = isAidlc ? 'active bolt' : (isSimple ? 'active spec' : 'active run');
2677
-
2678
- const parts = ['1/2/3/4/5 tabs', 'g/G sections'];
2679
-
2680
- if (view === 'runs' || view === 'intents' || view === 'completed' || view === 'health' || view === 'git') {
2681
- if (previewOpen) {
2682
- parts.push('tab pane', '↑/↓ nav/scroll', 'v/space close');
2683
- } else {
2684
- parts.push('↑/↓ navigate', 'enter expand', 'v/space preview');
2685
- }
2686
- }
2687
- if (view === 'runs') {
2688
- if (hasWorktrees) {
2689
- parts.push('b worktrees', 'u others');
2690
- }
2691
- parts.push('a current', 'f files');
2692
- } else if (view === 'git') {
2693
- parts.push('6 status', '7 files', '8 commits', '- diff');
2694
- }
2695
- parts.push(`tab1 ${activeLabel}`);
2696
-
2697
- if (availableFlowCount > 1) {
2698
- parts.push('[/] flow');
2699
- }
2700
-
2701
- parts.push('r refresh', '? shortcuts', 'q quit');
2702
- return parts.join(' | ');
2703
- }
2704
-
2705
- function buildGitCommandStrip(view, options = {}) {
2706
- const {
2707
- hasWorktrees = false,
2708
- previewOpen = false
2709
- } = options;
2710
-
2711
- const parts = [];
2712
-
2713
- if (view === 'runs') {
2714
- if (hasWorktrees) {
2715
- parts.push('b worktrees');
2716
- }
2717
- parts.push('a current', 'f files', 'enter expand');
2718
- } else if (view === 'intents') {
2719
- parts.push('n next', 'x completed', 'enter expand');
2720
- } else if (view === 'completed') {
2721
- parts.push('c completed', 'enter expand');
2722
- } else if (view === 'health') {
2723
- parts.push('s standards', 't stats', 'w warnings');
2724
- } else if (view === 'git') {
2725
- parts.push('6 status', '7 files', '8 commits', '- diff', 'space preview');
2726
- }
2727
-
2728
- if (previewOpen) {
2729
- parts.push('tab pane', 'j/k scroll');
2730
- } else {
2731
- parts.push('v preview');
2732
- }
2733
-
2734
- parts.push('1-5 views', 'g/G panels', 'r refresh', '? help', 'q quit');
2735
- return parts.join(' | ');
2736
- }
2737
-
2738
- function buildGitCommandLogLine(options = {}) {
2739
- const {
2740
- statusLine = '',
2741
- activeFlow = 'fire',
2742
- watchEnabled = true,
2743
- watchStatus = 'watching',
2744
- selectedWorktreeLabel = null
2745
- } = options;
2746
-
2747
- if (typeof statusLine === 'string' && statusLine.trim() !== '') {
2748
- return `Command Log | ${statusLine}`;
2749
- }
2750
-
2751
- const watchLabel = watchEnabled ? watchStatus : 'off';
2752
- const worktreeSegment = selectedWorktreeLabel ? ` | wt:${selectedWorktreeLabel}` : '';
2753
- return `Command Log | flow:${String(activeFlow || 'fire').toUpperCase()} | watch:${watchLabel}${worktreeSegment} | ready`;
2754
- }
2755
-
2756
- function buildHelpOverlayLines(options = {}) {
2757
- const {
2758
- view = 'runs',
2759
- flow = 'fire',
2760
- previewOpen = false,
2761
- paneFocus = 'main',
2762
- availableFlowCount = 1,
2763
- showErrorSection = false,
2764
- hasWorktrees = false
2765
- } = options;
2766
- const isAidlc = String(flow || '').toLowerCase() === 'aidlc';
2767
- const isSimple = String(flow || '').toLowerCase() === 'simple';
2768
- const itemLabel = isAidlc ? 'bolt' : (isSimple ? 'spec' : 'run');
2769
- const itemPlural = isAidlc ? 'bolts' : (isSimple ? 'specs' : 'runs');
2770
-
2771
- const lines = [
2772
- { text: 'Global', color: 'cyan', bold: true },
2773
- 'q or Ctrl+C quit',
2774
- 'r refresh snapshot',
2775
- `1 active ${itemLabel} | 2 intents | 3 completed ${itemPlural} | 4 standards/health | 5 git changes`,
2776
- 'g next section | G previous section',
2777
- 'h/? toggle this shortcuts overlay',
2778
- 'esc close overlays (help/preview/fullscreen)',
2779
- { text: '', color: undefined, bold: false },
2780
- { text: 'Tab 1 Active', color: 'yellow', bold: true },
2781
- ...(hasWorktrees ? ['b focus worktrees section', 'u focus other-worktrees section'] : []),
2782
- `a focus active ${itemLabel}`,
2783
- `f focus ${itemLabel} files`,
2784
- 'up/down or j/k move selection',
2785
- 'enter expand/collapse selected folder row',
2786
- 'v or space preview selected file',
2787
- 'v twice quickly opens fullscreen preview overlay',
2788
- 'tab switch focus between main and preview panes',
2789
- 'o open selected file in system default app'
2790
- ];
2791
-
2792
- if (previewOpen) {
2793
- lines.push(`preview is open (focus: ${paneFocus})`);
2794
- }
2795
-
2796
- if (availableFlowCount > 1) {
2797
- lines.push('[/] (and m) switch flow');
2798
- }
2799
-
2800
- lines.push(
2801
- { text: '', color: undefined, bold: false },
2802
- { text: 'Tab 2 Intents', color: 'green', bold: true },
2803
- 'i focus intents',
2804
- 'n next intents | x completed intents',
2805
- 'left/right toggles next/completed when intents is focused',
2806
- { text: '', color: undefined, bold: false },
2807
- { text: 'Tab 3 Completed', color: 'blue', bold: true },
2808
- 'c focus completed items',
2809
- { text: '', color: undefined, bold: false },
2810
- { text: 'Tab 4 Standards/Health', color: 'magenta', bold: true },
2811
- `s standards | t stats | w warnings${showErrorSection ? ' | e errors' : ''}`,
2812
- { text: '', color: undefined, bold: false },
2813
- { text: 'Tab 5 Git Changes', color: 'yellow', bold: true },
2814
- '7 files: select changed files and preview per-file diffs',
2815
- '8 commits: select a commit to preview the full commit diff',
2816
- '6 status | 7 files | 8 commits | - diff',
2817
- { text: '', color: undefined, bold: false },
2818
- { text: `Current view: ${String(view || 'runs').toUpperCase()}`, color: 'gray', bold: false }
2819
- );
2820
-
2821
- return lines;
2822
- }
2823
-
2824
- function buildWorktreeOverlayLines(snapshot, selectedIndex, width) {
2825
- const meta = getDashboardWorktreeMeta(snapshot);
2826
- if (!meta) {
2827
- return [{
2828
- text: truncate('No worktrees available', width),
2829
- color: 'gray',
2830
- bold: false
2831
- }];
2832
- }
2833
-
2834
- const items = Array.isArray(meta.items) ? meta.items : [];
2835
- const clampedIndex = clampIndex(selectedIndex, items.length || 1);
2836
- const lines = [{
2837
- text: truncate('Use ↑/↓ and Enter to switch. Esc closes.', width),
2838
- color: 'gray',
2839
- bold: false
2840
- }];
2841
-
2842
- for (let index = 0; index < items.length; index += 1) {
2843
- const item = items[index];
2844
- const marker = index === clampedIndex ? '>' : ' ';
2845
- const current = item.isSelected ? '[CURRENT] ' : '';
2846
- const main = item.isMainBranch && !item.detached ? '[MAIN] ' : '';
2847
- const status = item.status === 'loading'
2848
- ? 'loading'
2849
- : (item.status === 'error'
2850
- ? 'error'
2851
- : (item.flowAvailable ? `${item.activeCount || 0} active` : 'flow unavailable'));
2852
- const pathLabel = item.path ? path.basename(item.path) : 'unknown';
2853
- lines.push({
2854
- text: truncate(`${marker} ${current}${main}${getWorktreeDisplayName(item)} (${pathLabel}) | ${status}`, width),
2855
- color: index === clampedIndex ? 'green' : (item.isSelected ? 'cyan' : 'gray'),
2856
- bold: index === clampedIndex || item.isSelected
2857
- });
2858
- }
2859
-
2860
- if (meta.hasPendingScans) {
2861
- lines.push({
2862
- text: truncate('Background scan in progress for additional worktrees...', width),
2863
- color: 'yellow',
2864
- bold: false
2865
- });
2866
- }
2867
-
2868
- return lines;
2869
- }
2870
-
2871
- function colorizeMarkdownLine(line, inCodeBlock) {
2872
- const text = String(line ?? '');
2873
-
2874
- if (/^\s*```/.test(text)) {
2875
- return {
2876
- color: 'magenta',
2877
- bold: true,
2878
- togglesCodeBlock: true
2879
- };
2880
- }
2881
-
2882
- if (/^\s{0,3}#{1,6}\s+/.test(text)) {
2883
- return {
2884
- color: 'cyan',
2885
- bold: true,
2886
- togglesCodeBlock: false
2887
- };
2888
- }
2889
-
2890
- if (/^\s*[-*+]\s+\[[ xX]\]/.test(text) || /^\s*[-*+]\s+/.test(text) || /^\s*\d+\.\s+/.test(text)) {
2891
- return {
2892
- color: 'yellow',
2893
- bold: false,
2894
- togglesCodeBlock: false
2895
- };
2896
- }
2897
-
2898
- if (/^\s*>\s+/.test(text)) {
2899
- return {
2900
- color: 'gray',
2901
- bold: false,
2902
- togglesCodeBlock: false
2903
- };
2904
- }
2905
-
2906
- if (/^\s*---\s*$/.test(text)) {
2907
- return {
2908
- color: 'yellow',
2909
- bold: false,
2910
- togglesCodeBlock: false
2911
- };
2912
- }
2913
-
2914
- if (inCodeBlock) {
2915
- return {
2916
- color: 'green',
2917
- bold: false,
2918
- togglesCodeBlock: false
2919
- };
2920
- }
2921
-
2922
- return {
2923
- color: undefined,
2924
- bold: false,
2925
- togglesCodeBlock: false
2926
- };
2927
- }
2928
-
2929
- function buildPreviewLines(fileEntry, width, scrollOffset, options = {}) {
2930
- const fullDocument = options?.fullDocument === true;
2931
-
2932
- if (!fileEntry || typeof fileEntry.path !== 'string') {
2933
- return [{ text: truncate('No file selected', width), color: 'gray', bold: false }];
2934
- }
2935
-
2936
- const isGitFilePreview = fileEntry.previewType === 'git-diff';
2937
- const isGitCommitPreview = fileEntry.previewType === 'git-commit-diff';
2938
- const isGitPreview = isGitFilePreview || isGitCommitPreview;
2939
- let rawLines = [];
2940
- if (isGitPreview) {
2941
- const diffText = isGitCommitPreview
2942
- ? loadGitCommitPreview(fileEntry)
2943
- : loadGitDiffPreview(fileEntry);
2944
- rawLines = String(diffText || '').split(/\r?\n/);
2945
- } else {
2946
- let content;
2947
- try {
2948
- content = fs.readFileSync(fileEntry.path, 'utf8');
2949
- } catch (error) {
2950
- return [{
2951
- text: truncate(`Unable to read ${fileEntry.label || fileEntry.path}: ${error.message}`, width),
2952
- color: 'red',
2953
- bold: false
2954
- }];
2955
- }
2956
- rawLines = String(content).split(/\r?\n/);
2957
- }
2958
-
2959
- const headLine = {
2960
- text: truncate(
2961
- isGitCommitPreview
2962
- ? `commit: ${fileEntry.commitHash || fileEntry.path}`
2963
- : `${isGitPreview ? 'diff' : 'file'}: ${fileEntry.path}`,
2964
- width
2965
- ),
2966
- color: 'cyan',
2967
- bold: true
2968
- };
2969
-
2970
- const cappedLines = fullDocument ? rawLines : rawLines.slice(0, 300);
2971
- const hiddenLineCount = fullDocument ? 0 : Math.max(0, rawLines.length - cappedLines.length);
2972
- let inCodeBlock = false;
2973
-
2974
- const highlighted = cappedLines.map((rawLine, index) => {
2975
- const prefixedLine = `${String(index + 1).padStart(4, ' ')} | ${rawLine}`;
2976
- let color;
2977
- let bold;
2978
- let togglesCodeBlock = false;
2979
-
2980
- if (isGitPreview) {
2981
- if (rawLine.startsWith('+++ ') || rawLine.startsWith('--- ') || rawLine.startsWith('diff --git')) {
2982
- color = 'cyan';
2983
- bold = true;
2984
- } else if (rawLine.startsWith('@@')) {
2985
- color = 'magenta';
2986
- bold = true;
2987
- } else if (rawLine.startsWith('+')) {
2988
- color = 'green';
2989
- bold = false;
2990
- } else if (rawLine.startsWith('-')) {
2991
- color = 'red';
2992
- bold = false;
2993
- } else {
2994
- color = undefined;
2995
- bold = false;
2996
- }
2997
- } else {
2998
- const markdownStyle = colorizeMarkdownLine(rawLine, inCodeBlock);
2999
- color = markdownStyle.color;
3000
- bold = markdownStyle.bold;
3001
- togglesCodeBlock = markdownStyle.togglesCodeBlock;
3002
- }
3003
-
3004
- if (togglesCodeBlock) {
3005
- inCodeBlock = !inCodeBlock;
3006
- }
3007
- return {
3008
- text: truncate(prefixedLine, width),
3009
- color,
3010
- bold
3011
- };
3012
- });
3013
-
3014
- if (hiddenLineCount > 0) {
3015
- highlighted.push({
3016
- text: truncate(`... ${hiddenLineCount} additional lines hidden`, width),
3017
- color: 'gray',
3018
- bold: false
3019
- });
3020
- }
3021
-
3022
- const clampedOffset = clampIndex(scrollOffset, highlighted.length);
3023
- const body = highlighted.slice(clampedOffset);
5
+ stringWidth,
6
+ toDashboardError,
7
+ safeJsonHash,
8
+ resolveIconSet,
9
+ truncate,
10
+ resolveFrameWidth,
11
+ fitLines,
12
+ clampIndex
13
+ } = require('./helpers');
3024
14
 
3025
- return [headLine, { text: '', color: undefined, bold: false }, ...body];
3026
- }
15
+ const {
16
+ getEffectiveFlow,
17
+ detectDashboardApprovalGate,
18
+ detectFireRunApprovalGate,
19
+ detectAidlcBoltApprovalGate,
20
+ buildHeaderLine,
21
+ buildErrorLines,
22
+ buildStatsLines,
23
+ buildWarningsLines,
24
+ getPanelTitles
25
+ } = require('./flow-builders');
3027
26
 
3028
- function allocateSingleColumnPanels(candidates, rowsBudget) {
3029
- const filtered = (candidates || []).filter(Boolean);
3030
- if (filtered.length === 0) {
3031
- return [];
3032
- }
27
+ const {
28
+ getGitChangesSnapshot,
29
+ buildGitStatusPanelLines,
30
+ buildGitCommitRows,
31
+ buildGitChangeGroups
32
+ } = require('./git-builders');
3033
33
 
3034
- const selected = [];
3035
- let remaining = Math.max(4, rowsBudget);
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');
3036
46
 
3037
- for (const panel of filtered) {
3038
- const margin = selected.length > 0 ? 1 : 0;
3039
- const minimumRows = 4 + margin;
47
+ const {
48
+ getNoCurrentMessage,
49
+ getNoFileMessage,
50
+ getNoCompletedMessage
51
+ } = require('./file-entries');
3040
52
 
3041
- if (remaining >= minimumRows || selected.length === 0) {
3042
- selected.push({
3043
- ...panel,
3044
- maxLines: 1
3045
- });
3046
- remaining -= minimumRows;
3047
- }
3048
- }
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');
3049
70
 
3050
- let index = 0;
3051
- while (remaining > 0 && selected.length > 0) {
3052
- const panelIndex = index % selected.length;
3053
- selected[panelIndex].maxLines += 1;
3054
- remaining -= 1;
3055
- index += 1;
3056
- }
71
+ const {
72
+ buildQuickHelpText,
73
+ buildGitCommandStrip,
74
+ buildGitCommandLogLine,
75
+ buildHelpOverlayLines
76
+ } = require('./overlays');
3057
77
 
3058
- return selected;
3059
- }
78
+ const {
79
+ buildPreviewLines,
80
+ allocateSingleColumnPanels
81
+ } = require('./preview');
3060
82
 
3061
83
  function createDashboardApp(deps) {
3062
84
  const {
@@ -3475,10 +497,10 @@ function createDashboardApp(deps) {
3475
497
  'git-changes': gitRows,
3476
498
  'git-commits': gitCommitRows
3477
499
  };
3478
- const worktreeItems = getWorktreeItems(snapshot);
500
+ const worktreeItemsList = getWorktreeItems(snapshot);
3479
501
  const selectedWorktree = getSelectedWorktree(snapshot);
3480
502
  const selectedWorktreeLabel = selectedWorktree ? getWorktreeDisplayName(selectedWorktree) : null;
3481
- const worktreeWatchSignature = `${snapshot?.dashboardWorktrees?.selectedWorktreeId || ''}|${worktreeItems
503
+ const worktreeWatchSignature = `${snapshot?.dashboardWorktrees?.selectedWorktreeId || ''}|${worktreeItemsList
3482
504
  .map((item) => `${item.id}:${item.status}:${item.activeCount}:${item.flowAvailable ? '1' : '0'}`)
3483
505
  .join(',')}`;
3484
506
  const rowLengthSignature = Object.entries(rowsBySection)
@@ -3566,7 +588,7 @@ function createDashboardApp(deps) {
3566
588
  return false;
3567
589
  }
3568
590
 
3569
- const nextItem = worktreeItems.find((item) => item.id === normalizedNextId);
591
+ const nextItem = worktreeItemsList.find((item) => item.id === normalizedNextId);
3570
592
  if (!nextItem) {
3571
593
  setStatusLine('Selected worktree is no longer available.');
3572
594
  return false;
@@ -3591,7 +613,7 @@ function createDashboardApp(deps) {
3591
613
  }
3592
614
 
3593
615
  return true;
3594
- }, [refresh, selectedWorktreeId, worktreeItems]);
616
+ }, [refresh, selectedWorktreeId, worktreeItemsList]);
3595
617
 
3596
618
  useInput((input, key) => {
3597
619
  if ((key.ctrl && input === 'c') || input === 'q') {
@@ -3630,12 +652,12 @@ function createDashboardApp(deps) {
3630
652
  }
3631
653
 
3632
654
  if (key.downArrow || input === 'j') {
3633
- setWorktreeOverlayIndex((previous) => Math.min(Math.max(0, worktreeItems.length - 1), previous + 1));
655
+ setWorktreeOverlayIndex((previous) => Math.min(Math.max(0, worktreeItemsList.length - 1), previous + 1));
3634
656
  return;
3635
657
  }
3636
658
 
3637
659
  if (key.return || key.enter) {
3638
- const selectedOverlayItem = worktreeItems[clampIndex(worktreeOverlayIndex, worktreeItems.length || 1)];
660
+ const selectedOverlayItem = worktreeItemsList[clampIndex(worktreeOverlayIndex, worktreeItemsList.length || 1)];
3639
661
  switchToWorktree(selectedOverlayItem?.id || '', { forceRefresh: true });
3640
662
  setWorktreeOverlayOpen(false);
3641
663
  return;
@@ -4070,6 +1092,13 @@ function createDashboardApp(deps) {
4070
1092
  setPreviewScroll(0);
4071
1093
  }, [previewOpen, overlayPreviewOpen, paneFocus, selectedFocusedFile?.path, previewTarget?.path]);
4072
1094
 
1095
+ useEffect(() => {
1096
+ if (ui.view !== 'git') {
1097
+ return;
1098
+ }
1099
+ setPreviewScroll(0);
1100
+ }, [ui.view, focusedSection, selectedGitFile?.path, selectedGitCommit?.commitHash]);
1101
+
4073
1102
  useEffect(() => {
4074
1103
  if (statusLine === '') {
4075
1104
  return undefined;
@@ -4235,8 +1264,8 @@ function createDashboardApp(deps) {
4235
1264
  : [];
4236
1265
  const gitInlineDiffTarget = (
4237
1266
  focusedSection === 'git-commits'
4238
- ? (selectedGitCommit || selectedGitFile || firstGitFile || previewTarget)
4239
- : (selectedGitFile || firstGitFile || previewTarget)
1267
+ ? (selectedGitCommit || selectedGitFile || firstGitFile)
1268
+ : (selectedGitFile || firstGitFile)
4240
1269
  ) || null;
4241
1270
  const gitInlineDiffLines = ui.view === 'git'
4242
1271
  ? buildPreviewLines(gitInlineDiffTarget, compactWidth, previewOpen && paneFocus === 'preview' ? previewScroll : 0, {