specsmd 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/dashboard/tui/app.js +1065 -146
  2. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const { spawnSync } = require('child_process');
3
4
  const { createWatchRuntime } = require('../runtime/watch-runtime');
4
- const { createInitialUIState, cycleView, cycleViewBackward } = require('./store');
5
+ const { createInitialUIState } = require('./store');
5
6
 
6
7
  function toDashboardError(error, defaultCode = 'DASHBOARD_ERROR') {
7
8
  if (!error) {
@@ -54,14 +55,20 @@ function resolveIconSet() {
54
55
  runs: '[R]',
55
56
  overview: '[O]',
56
57
  health: '[H]',
57
- runFile: '*'
58
+ runFile: '*',
59
+ activeFile: '>',
60
+ groupCollapsed: '>',
61
+ groupExpanded: 'v'
58
62
  };
59
63
 
60
64
  const nerd = {
61
65
  runs: '󰑮',
62
66
  overview: '󰍉',
63
67
  health: '󰓦',
64
- runFile: '󰈔'
68
+ runFile: '󰈔',
69
+ activeFile: '󰜴',
70
+ groupCollapsed: '󰐕',
71
+ groupExpanded: '󰐗'
65
72
  };
66
73
 
67
74
  if (mode === 'ascii') {
@@ -96,14 +103,16 @@ function normalizePanelLine(line) {
96
103
  return {
97
104
  text: typeof line.text === 'string' ? line.text : String(line.text ?? ''),
98
105
  color: line.color,
99
- bold: Boolean(line.bold)
106
+ bold: Boolean(line.bold),
107
+ selected: Boolean(line.selected)
100
108
  };
101
109
  }
102
110
 
103
111
  return {
104
112
  text: String(line ?? ''),
105
113
  color: undefined,
106
- bold: false
114
+ bold: false,
115
+ selected: false
107
116
  };
108
117
  }
109
118
 
@@ -120,6 +129,15 @@ function fitLines(lines, maxLines, width) {
120
129
  return safeLines;
121
130
  }
122
131
 
132
+ const selectedIndex = safeLines.findIndex((line) => line.selected);
133
+ if (selectedIndex >= 0) {
134
+ const windowSize = Math.max(1, maxLines);
135
+ let start = selectedIndex - Math.floor(windowSize / 2);
136
+ start = Math.max(0, start);
137
+ start = Math.min(start, Math.max(0, safeLines.length - windowSize));
138
+ return safeLines.slice(start, start + windowSize);
139
+ }
140
+
123
141
  const visible = safeLines.slice(0, Math.max(1, maxLines - 1));
124
142
  visible.push({
125
143
  text: truncate(`... +${safeLines.length - visible.length} more`, width),
@@ -719,52 +737,232 @@ function getPanelTitles(flow, snapshot) {
719
737
  };
720
738
  }
721
739
 
740
+ function getSectionOrderForView(view) {
741
+ if (view === 'overview') {
742
+ return ['project', 'intent-status', 'standards'];
743
+ }
744
+ if (view === 'health') {
745
+ return ['stats', 'warnings', 'error-details'];
746
+ }
747
+ return ['current-run', 'run-files', 'pending', 'completed'];
748
+ }
749
+
750
+ function cycleSection(view, currentSectionKey, direction = 1, availableSections = null) {
751
+ const order = Array.isArray(availableSections) && availableSections.length > 0
752
+ ? availableSections
753
+ : getSectionOrderForView(view);
754
+ if (order.length === 0) {
755
+ return currentSectionKey;
756
+ }
757
+
758
+ const currentIndex = order.indexOf(currentSectionKey);
759
+ const safeIndex = currentIndex >= 0 ? currentIndex : 0;
760
+ const nextIndex = (safeIndex + direction + order.length) % order.length;
761
+ return order[nextIndex];
762
+ }
763
+
764
+ function fileExists(filePath) {
765
+ try {
766
+ return fs.statSync(filePath).isFile();
767
+ } catch {
768
+ return false;
769
+ }
770
+ }
771
+
772
+ function listMarkdownFiles(dirPath) {
773
+ try {
774
+ return fs.readdirSync(dirPath, { withFileTypes: true })
775
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
776
+ .map((entry) => entry.name)
777
+ .sort((a, b) => a.localeCompare(b));
778
+ } catch {
779
+ return [];
780
+ }
781
+ }
782
+
783
+ function pushFileEntry(entries, seenPaths, candidate) {
784
+ if (!candidate || typeof candidate.path !== 'string' || typeof candidate.label !== 'string') {
785
+ return;
786
+ }
787
+
788
+ if (!fileExists(candidate.path)) {
789
+ return;
790
+ }
791
+
792
+ if (seenPaths.has(candidate.path)) {
793
+ return;
794
+ }
795
+
796
+ seenPaths.add(candidate.path);
797
+ entries.push({
798
+ path: candidate.path,
799
+ label: candidate.label,
800
+ scope: candidate.scope || 'other'
801
+ });
802
+ }
803
+
804
+ function collectFireRunFiles(run) {
805
+ if (!run || typeof run.folderPath !== 'string') {
806
+ return [];
807
+ }
808
+
809
+ const names = ['run.md'];
810
+ if (run.hasPlan) names.push('plan.md');
811
+ if (run.hasTestReport) names.push('test-report.md');
812
+ if (run.hasWalkthrough) names.push('walkthrough.md');
813
+
814
+ return names.map((fileName) => ({
815
+ label: `${run.id}/${fileName}`,
816
+ path: path.join(run.folderPath, fileName)
817
+ }));
818
+ }
819
+
820
+ function collectAidlcBoltFiles(bolt) {
821
+ if (!bolt || typeof bolt.path !== 'string') {
822
+ return [];
823
+ }
824
+
825
+ const fileNames = Array.isArray(bolt.files) && bolt.files.length > 0
826
+ ? bolt.files
827
+ : listMarkdownFiles(bolt.path);
828
+
829
+ return fileNames.map((fileName) => ({
830
+ label: `${bolt.id}/${fileName}`,
831
+ path: path.join(bolt.path, fileName)
832
+ }));
833
+ }
834
+
835
+ function collectSimpleSpecFiles(spec) {
836
+ if (!spec || typeof spec.path !== 'string') {
837
+ return [];
838
+ }
839
+
840
+ const names = [];
841
+ if (spec.hasRequirements) names.push('requirements.md');
842
+ if (spec.hasDesign) names.push('design.md');
843
+ if (spec.hasTasks) names.push('tasks.md');
844
+
845
+ return names.map((fileName) => ({
846
+ label: `${spec.name}/${fileName}`,
847
+ path: path.join(spec.path, fileName)
848
+ }));
849
+ }
850
+
722
851
  function getRunFileEntries(snapshot, flow) {
723
852
  const effectiveFlow = getEffectiveFlow(flow, snapshot);
853
+ const entries = [];
854
+ const seenPaths = new Set();
724
855
 
725
856
  if (effectiveFlow === 'aidlc') {
726
857
  const bolt = getCurrentBolt(snapshot);
727
- if (!bolt || typeof bolt.path !== 'string') {
728
- return [];
858
+ for (const file of collectAidlcBoltFiles(bolt)) {
859
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'active' });
729
860
  }
730
- const files = Array.isArray(bolt.files) ? bolt.files : [];
731
- return files.map((fileName) => ({
732
- name: fileName,
733
- path: path.join(bolt.path, fileName)
734
- }));
861
+
862
+ const pendingBolts = Array.isArray(snapshot?.pendingBolts) ? snapshot.pendingBolts : [];
863
+ for (const pendingBolt of pendingBolts) {
864
+ for (const file of collectAidlcBoltFiles(pendingBolt)) {
865
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'upcoming' });
866
+ }
867
+ }
868
+
869
+ const completedBolts = Array.isArray(snapshot?.completedBolts) ? snapshot.completedBolts : [];
870
+ for (const completedBolt of completedBolts) {
871
+ for (const file of collectAidlcBoltFiles(completedBolt)) {
872
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'completed' });
873
+ }
874
+ }
875
+
876
+ const intentIds = new Set([
877
+ ...pendingBolts.map((item) => item?.intent).filter(Boolean),
878
+ ...completedBolts.map((item) => item?.intent).filter(Boolean)
879
+ ]);
880
+
881
+ for (const intentId of intentIds) {
882
+ const intentPath = path.join(snapshot?.rootPath || '', 'intents', intentId);
883
+ pushFileEntry(entries, seenPaths, {
884
+ label: `${intentId}/requirements.md`,
885
+ path: path.join(intentPath, 'requirements.md'),
886
+ scope: 'intent'
887
+ });
888
+ pushFileEntry(entries, seenPaths, {
889
+ label: `${intentId}/system-context.md`,
890
+ path: path.join(intentPath, 'system-context.md'),
891
+ scope: 'intent'
892
+ });
893
+ pushFileEntry(entries, seenPaths, {
894
+ label: `${intentId}/units.md`,
895
+ path: path.join(intentPath, 'units.md'),
896
+ scope: 'intent'
897
+ });
898
+ }
899
+ return entries;
735
900
  }
736
901
 
737
902
  if (effectiveFlow === 'simple') {
738
903
  const spec = getCurrentSpec(snapshot);
739
- if (!spec || typeof spec.path !== 'string') {
740
- return [];
904
+ for (const file of collectSimpleSpecFiles(spec)) {
905
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'active' });
741
906
  }
742
907
 
743
- const files = [];
744
- if (spec.hasRequirements) files.push('requirements.md');
745
- if (spec.hasDesign) files.push('design.md');
746
- if (spec.hasTasks) files.push('tasks.md');
908
+ const pendingSpecs = Array.isArray(snapshot?.pendingSpecs) ? snapshot.pendingSpecs : [];
909
+ for (const pendingSpec of pendingSpecs) {
910
+ for (const file of collectSimpleSpecFiles(pendingSpec)) {
911
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'upcoming' });
912
+ }
913
+ }
747
914
 
748
- return files.map((fileName) => ({
749
- name: fileName,
750
- path: path.join(spec.path, fileName)
751
- }));
915
+ const completedSpecs = Array.isArray(snapshot?.completedSpecs) ? snapshot.completedSpecs : [];
916
+ for (const completedSpec of completedSpecs) {
917
+ for (const file of collectSimpleSpecFiles(completedSpec)) {
918
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'completed' });
919
+ }
920
+ }
921
+
922
+ return entries;
752
923
  }
753
924
 
754
925
  const run = getCurrentRun(snapshot);
755
- if (!run || typeof run.folderPath !== 'string') {
756
- return [];
926
+ for (const file of collectFireRunFiles(run)) {
927
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'active' });
757
928
  }
758
929
 
759
- const files = ['run.md'];
760
- if (run.hasPlan) files.push('plan.md');
761
- if (run.hasTestReport) files.push('test-report.md');
762
- if (run.hasWalkthrough) files.push('walkthrough.md');
930
+ const pendingItems = Array.isArray(snapshot?.pendingItems) ? snapshot.pendingItems : [];
931
+ for (const pendingItem of pendingItems) {
932
+ pushFileEntry(entries, seenPaths, {
933
+ label: `${pendingItem?.intentId || 'intent'}/${pendingItem?.id || 'work-item'}.md`,
934
+ path: pendingItem?.filePath,
935
+ scope: 'upcoming'
936
+ });
763
937
 
764
- return files.map((fileName) => ({
765
- name: fileName,
766
- path: path.join(run.folderPath, fileName)
767
- }));
938
+ if (pendingItem?.intentId) {
939
+ pushFileEntry(entries, seenPaths, {
940
+ label: `${pendingItem.intentId}/brief.md`,
941
+ path: path.join(snapshot?.rootPath || '', 'intents', pendingItem.intentId, 'brief.md'),
942
+ scope: 'intent'
943
+ });
944
+ }
945
+ }
946
+
947
+ const completedRuns = Array.isArray(snapshot?.completedRuns) ? snapshot.completedRuns : [];
948
+ for (const completedRun of completedRuns) {
949
+ for (const file of collectFireRunFiles(completedRun)) {
950
+ pushFileEntry(entries, seenPaths, { ...file, scope: 'completed' });
951
+ }
952
+ }
953
+
954
+ const completedIntents = Array.isArray(snapshot?.intents)
955
+ ? snapshot.intents.filter((intent) => intent?.status === 'completed')
956
+ : [];
957
+ for (const intent of completedIntents) {
958
+ pushFileEntry(entries, seenPaths, {
959
+ label: `${intent.id}/brief.md`,
960
+ path: path.join(snapshot?.rootPath || '', 'intents', intent.id, 'brief.md'),
961
+ scope: 'intent'
962
+ });
963
+ }
964
+
965
+ return entries;
768
966
  }
769
967
 
770
968
  function clampIndex(value, length) {
@@ -778,32 +976,372 @@ function clampIndex(value, length) {
778
976
  }
779
977
 
780
978
  function getNoFileMessage(flow) {
781
- if (flow === 'aidlc') {
782
- return 'No bolt files (no active bolt)';
979
+ return `No selectable files for ${String(flow || 'flow').toUpperCase()}`;
980
+ }
981
+
982
+ function formatScope(scope) {
983
+ if (scope === 'active') return 'ACTIVE';
984
+ if (scope === 'upcoming') return 'UPNEXT';
985
+ if (scope === 'completed') return 'DONE';
986
+ if (scope === 'intent') return 'INTENT';
987
+ return 'FILE';
988
+ }
989
+
990
+ function getNoPendingMessage(flow) {
991
+ if (flow === 'aidlc') return 'No queued bolts';
992
+ if (flow === 'simple') return 'No pending specs';
993
+ return 'No pending work items';
994
+ }
995
+
996
+ function getNoCompletedMessage(flow) {
997
+ if (flow === 'aidlc') return 'No completed bolts yet';
998
+ if (flow === 'simple') return 'No completed specs yet';
999
+ return 'No completed runs yet';
1000
+ }
1001
+
1002
+ function toRunFileRows(fileEntries, flow) {
1003
+ if (!Array.isArray(fileEntries) || fileEntries.length === 0) {
1004
+ return [{
1005
+ kind: 'info',
1006
+ key: 'run-files:empty',
1007
+ label: getNoFileMessage(flow),
1008
+ selectable: false
1009
+ }];
783
1010
  }
784
- if (flow === 'simple') {
785
- return 'No spec files (no active spec)';
1011
+
1012
+ return fileEntries.map((file, index) => ({
1013
+ kind: 'file',
1014
+ key: `run-files:${file.path}:${index}`,
1015
+ label: file.label,
1016
+ path: file.path,
1017
+ scope: file.scope || 'file',
1018
+ selectable: true
1019
+ }));
1020
+ }
1021
+
1022
+ function collectAidlcIntentContextFiles(snapshot, intentId) {
1023
+ if (!snapshot || typeof intentId !== 'string' || intentId.trim() === '') {
1024
+ return [];
786
1025
  }
787
- return 'No run files (no active run)';
1026
+
1027
+ const intentPath = path.join(snapshot.rootPath || '', 'intents', intentId);
1028
+ return [
1029
+ {
1030
+ label: `${intentId}/requirements.md`,
1031
+ path: path.join(intentPath, 'requirements.md'),
1032
+ scope: 'intent'
1033
+ },
1034
+ {
1035
+ label: `${intentId}/system-context.md`,
1036
+ path: path.join(intentPath, 'system-context.md'),
1037
+ scope: 'intent'
1038
+ },
1039
+ {
1040
+ label: `${intentId}/units.md`,
1041
+ path: path.join(intentPath, 'units.md'),
1042
+ scope: 'intent'
1043
+ }
1044
+ ];
788
1045
  }
789
1046
 
790
- function buildSelectableRunFileLines(fileEntries, selectedIndex, icons, width, flow) {
791
- if (!Array.isArray(fileEntries) || fileEntries.length === 0) {
792
- return [truncate(getNoFileMessage(flow), width)];
1047
+ function filterExistingFiles(files) {
1048
+ return (Array.isArray(files) ? files : []).filter((file) =>
1049
+ file && typeof file.path === 'string' && typeof file.label === 'string' && fileExists(file.path)
1050
+ );
1051
+ }
1052
+
1053
+ function buildPendingGroups(snapshot, flow) {
1054
+ const effectiveFlow = getEffectiveFlow(flow, snapshot);
1055
+
1056
+ if (effectiveFlow === 'aidlc') {
1057
+ const pendingBolts = Array.isArray(snapshot?.pendingBolts) ? snapshot.pendingBolts : [];
1058
+ return pendingBolts.map((bolt, index) => {
1059
+ const deps = Array.isArray(bolt?.blockedBy) && bolt.blockedBy.length > 0
1060
+ ? ` blocked_by:${bolt.blockedBy.join(',')}`
1061
+ : '';
1062
+ const location = `${bolt?.intent || 'unknown'}/${bolt?.unit || 'unknown'}`;
1063
+ const boltFiles = collectAidlcBoltFiles(bolt);
1064
+ const intentFiles = collectAidlcIntentContextFiles(snapshot, bolt?.intent);
1065
+ return {
1066
+ key: `pending:bolt:${bolt?.id || index}`,
1067
+ label: `${bolt?.id || 'unknown'} (${bolt?.status || 'pending'}) in ${location}${deps}`,
1068
+ files: filterExistingFiles([...boltFiles, ...intentFiles])
1069
+ };
1070
+ });
793
1071
  }
794
1072
 
795
- const clampedIndex = clampIndex(selectedIndex, fileEntries.length);
796
- return fileEntries.map((file, index) => {
797
- const isSelected = index === clampedIndex;
798
- const prefix = isSelected ? '>' : ' ';
1073
+ if (effectiveFlow === 'simple') {
1074
+ const pendingSpecs = Array.isArray(snapshot?.pendingSpecs) ? snapshot.pendingSpecs : [];
1075
+ return pendingSpecs.map((spec, index) => ({
1076
+ key: `pending:spec:${spec?.name || index}`,
1077
+ label: `${spec?.name || 'unknown'} (${spec?.state || 'pending'}) ${spec?.tasksCompleted || 0}/${spec?.tasksTotal || 0} tasks`,
1078
+ files: filterExistingFiles(collectSimpleSpecFiles(spec))
1079
+ }));
1080
+ }
1081
+
1082
+ const pendingItems = Array.isArray(snapshot?.pendingItems) ? snapshot.pendingItems : [];
1083
+ return pendingItems.map((item, index) => {
1084
+ const deps = Array.isArray(item?.dependencies) && item.dependencies.length > 0
1085
+ ? ` deps:${item.dependencies.join(',')}`
1086
+ : '';
1087
+ const intentTitle = item?.intentTitle || item?.intentId || 'unknown-intent';
1088
+ const files = [];
1089
+
1090
+ if (item?.filePath) {
1091
+ files.push({
1092
+ label: `${item?.intentId || 'intent'}/${item?.id || 'work-item'}.md`,
1093
+ path: item.filePath,
1094
+ scope: 'upcoming'
1095
+ });
1096
+ }
1097
+ if (item?.intentId) {
1098
+ files.push({
1099
+ label: `${item.intentId}/brief.md`,
1100
+ path: path.join(snapshot?.rootPath || '', 'intents', item.intentId, 'brief.md'),
1101
+ scope: 'intent'
1102
+ });
1103
+ }
1104
+
799
1105
  return {
800
- text: truncate(`${prefix} ${icons.runFile} ${file.name}`, width),
801
- color: isSelected ? 'cyan' : undefined,
802
- bold: isSelected
1106
+ key: `pending:item:${item?.intentId || 'intent'}:${item?.id || index}`,
1107
+ label: `${item?.id || 'work-item'} (${item?.mode || 'confirm'}/${item?.complexity || 'medium'}) in ${intentTitle}${deps}`,
1108
+ files: filterExistingFiles(files)
803
1109
  };
804
1110
  });
805
1111
  }
806
1112
 
1113
+ function buildCompletedGroups(snapshot, flow) {
1114
+ const effectiveFlow = getEffectiveFlow(flow, snapshot);
1115
+
1116
+ if (effectiveFlow === 'aidlc') {
1117
+ const completedBolts = Array.isArray(snapshot?.completedBolts) ? snapshot.completedBolts : [];
1118
+ return completedBolts.map((bolt, index) => {
1119
+ const boltFiles = collectAidlcBoltFiles(bolt);
1120
+ const intentFiles = collectAidlcIntentContextFiles(snapshot, bolt?.intent);
1121
+ return {
1122
+ key: `completed:bolt:${bolt?.id || index}`,
1123
+ label: `${bolt?.id || 'unknown'} [${bolt?.type || 'bolt'}] done at ${bolt?.completedAt || 'unknown'}`,
1124
+ files: filterExistingFiles([...boltFiles, ...intentFiles])
1125
+ };
1126
+ });
1127
+ }
1128
+
1129
+ if (effectiveFlow === 'simple') {
1130
+ const completedSpecs = Array.isArray(snapshot?.completedSpecs) ? snapshot.completedSpecs : [];
1131
+ return completedSpecs.map((spec, index) => ({
1132
+ key: `completed:spec:${spec?.name || index}`,
1133
+ label: `${spec?.name || 'unknown'} done at ${spec?.updatedAt || 'unknown'} (${spec?.tasksCompleted || 0}/${spec?.tasksTotal || 0})`,
1134
+ files: filterExistingFiles(collectSimpleSpecFiles(spec))
1135
+ }));
1136
+ }
1137
+
1138
+ const groups = [];
1139
+ const completedRuns = Array.isArray(snapshot?.completedRuns) ? snapshot.completedRuns : [];
1140
+ for (let index = 0; index < completedRuns.length; index += 1) {
1141
+ const run = completedRuns[index];
1142
+ const workItems = Array.isArray(run?.workItems) ? run.workItems : [];
1143
+ const completed = workItems.filter((item) => item.status === 'completed').length;
1144
+ groups.push({
1145
+ key: `completed:run:${run?.id || index}`,
1146
+ label: `${run?.id || 'run'} [${run?.scope || 'single'}] ${completed}/${workItems.length} done at ${run?.completedAt || 'unknown'}`,
1147
+ files: filterExistingFiles(collectFireRunFiles(run).map((file) => ({ ...file, scope: 'completed' })))
1148
+ });
1149
+ }
1150
+
1151
+ const completedIntents = Array.isArray(snapshot?.intents)
1152
+ ? snapshot.intents.filter((intent) => intent?.status === 'completed')
1153
+ : [];
1154
+ for (let index = 0; index < completedIntents.length; index += 1) {
1155
+ const intent = completedIntents[index];
1156
+ groups.push({
1157
+ key: `completed:intent:${intent?.id || index}`,
1158
+ label: `intent ${intent?.id || 'unknown'} [completed]`,
1159
+ files: filterExistingFiles([{
1160
+ label: `${intent?.id || 'intent'}/brief.md`,
1161
+ path: path.join(snapshot?.rootPath || '', 'intents', intent?.id || '', 'brief.md'),
1162
+ scope: 'intent'
1163
+ }])
1164
+ });
1165
+ }
1166
+
1167
+ return groups;
1168
+ }
1169
+
1170
+ function toExpandableRows(groups, emptyLabel, expandedGroups) {
1171
+ if (!Array.isArray(groups) || groups.length === 0) {
1172
+ return [{
1173
+ kind: 'info',
1174
+ key: 'section:empty',
1175
+ label: emptyLabel,
1176
+ selectable: false
1177
+ }];
1178
+ }
1179
+
1180
+ const rows = [];
1181
+
1182
+ for (const group of groups) {
1183
+ const files = filterExistingFiles(group?.files);
1184
+ const expandable = files.length > 0;
1185
+ const expanded = expandable && Boolean(expandedGroups?.[group.key]);
1186
+
1187
+ rows.push({
1188
+ kind: 'group',
1189
+ key: group.key,
1190
+ label: group.label,
1191
+ expandable,
1192
+ expanded,
1193
+ selectable: true
1194
+ });
1195
+
1196
+ if (expanded) {
1197
+ for (let index = 0; index < files.length; index += 1) {
1198
+ const file = files[index];
1199
+ rows.push({
1200
+ kind: 'file',
1201
+ key: `${group.key}:file:${file.path}:${index}`,
1202
+ label: file.label,
1203
+ path: file.path,
1204
+ scope: file.scope || 'file',
1205
+ selectable: true
1206
+ });
1207
+ }
1208
+ }
1209
+ }
1210
+
1211
+ return rows;
1212
+ }
1213
+
1214
+ function buildInteractiveRowsLines(rows, selectedIndex, icons, width, isFocusedSection) {
1215
+ if (!Array.isArray(rows) || rows.length === 0) {
1216
+ return [{ text: '', color: undefined, bold: false, selected: false }];
1217
+ }
1218
+
1219
+ const clampedIndex = clampIndex(selectedIndex, rows.length);
1220
+
1221
+ return rows.map((row, index) => {
1222
+ const selectable = row?.selectable !== false;
1223
+ const isSelected = selectable && index === clampedIndex;
1224
+ const cursor = isSelected
1225
+ ? (isFocusedSection ? (icons.activeFile || '>') : '•')
1226
+ : ' ';
1227
+
1228
+ if (row.kind === 'group') {
1229
+ const marker = row.expandable
1230
+ ? (row.expanded ? (icons.groupExpanded || 'v') : (icons.groupCollapsed || '>'))
1231
+ : '-';
1232
+ return {
1233
+ text: truncate(`${cursor} ${marker} ${row.label}`, width),
1234
+ color: isSelected ? (isFocusedSection ? 'green' : 'cyan') : undefined,
1235
+ bold: isSelected,
1236
+ selected: isSelected
1237
+ };
1238
+ }
1239
+
1240
+ if (row.kind === 'file') {
1241
+ const scope = row.scope ? `[${formatScope(row.scope)}] ` : '';
1242
+ return {
1243
+ text: truncate(`${cursor} ${icons.runFile} ${scope}${row.label}`, width),
1244
+ color: isSelected ? (isFocusedSection ? 'green' : 'cyan') : 'gray',
1245
+ bold: isSelected,
1246
+ selected: isSelected
1247
+ };
1248
+ }
1249
+
1250
+ return {
1251
+ text: truncate(` ${row.label || ''}`, width),
1252
+ color: 'gray',
1253
+ bold: false,
1254
+ selected: false
1255
+ };
1256
+ });
1257
+ }
1258
+
1259
+ function getSelectedRow(rows, selectedIndex) {
1260
+ if (!Array.isArray(rows) || rows.length === 0) {
1261
+ return null;
1262
+ }
1263
+ return rows[clampIndex(selectedIndex, rows.length)] || null;
1264
+ }
1265
+
1266
+ function rowToFileEntry(row) {
1267
+ if (!row || row.kind !== 'file' || typeof row.path !== 'string') {
1268
+ return null;
1269
+ }
1270
+ return {
1271
+ label: row.label || path.basename(row.path),
1272
+ path: row.path,
1273
+ scope: row.scope || 'file'
1274
+ };
1275
+ }
1276
+
1277
+ function moveRowSelection(rows, currentIndex, direction) {
1278
+ if (!Array.isArray(rows) || rows.length === 0) {
1279
+ return 0;
1280
+ }
1281
+
1282
+ const clamped = clampIndex(currentIndex, rows.length);
1283
+ const step = direction >= 0 ? 1 : -1;
1284
+ let next = clamped + step;
1285
+
1286
+ while (next >= 0 && next < rows.length) {
1287
+ if (rows[next]?.selectable !== false) {
1288
+ return next;
1289
+ }
1290
+ next += step;
1291
+ }
1292
+
1293
+ return clamped;
1294
+ }
1295
+
1296
+ function openFileWithDefaultApp(filePath) {
1297
+ if (typeof filePath !== 'string' || filePath.trim() === '') {
1298
+ return {
1299
+ ok: false,
1300
+ message: 'No file selected to open.'
1301
+ };
1302
+ }
1303
+
1304
+ if (!fileExists(filePath)) {
1305
+ return {
1306
+ ok: false,
1307
+ message: `File not found: ${filePath}`
1308
+ };
1309
+ }
1310
+
1311
+ let command = null;
1312
+ let args = [];
1313
+
1314
+ if (process.platform === 'darwin') {
1315
+ command = 'open';
1316
+ args = [filePath];
1317
+ } else if (process.platform === 'win32') {
1318
+ command = 'cmd';
1319
+ args = ['/c', 'start', '', filePath];
1320
+ } else {
1321
+ command = 'xdg-open';
1322
+ args = [filePath];
1323
+ }
1324
+
1325
+ const result = spawnSync(command, args, { stdio: 'ignore' });
1326
+ if (result.error) {
1327
+ return {
1328
+ ok: false,
1329
+ message: `Unable to open file: ${result.error.message}`
1330
+ };
1331
+ }
1332
+ if (typeof result.status === 'number' && result.status !== 0) {
1333
+ return {
1334
+ ok: false,
1335
+ message: `Open command failed with exit code ${result.status}.`
1336
+ };
1337
+ }
1338
+
1339
+ return {
1340
+ ok: true,
1341
+ message: `Opened ${filePath}`
1342
+ };
1343
+ }
1344
+
807
1345
  function colorizeMarkdownLine(line, inCodeBlock) {
808
1346
  const text = String(line ?? '');
809
1347
 
@@ -862,7 +1400,9 @@ function colorizeMarkdownLine(line, inCodeBlock) {
862
1400
  };
863
1401
  }
864
1402
 
865
- function buildPreviewLines(fileEntry, width, scrollOffset) {
1403
+ function buildPreviewLines(fileEntry, width, scrollOffset, options = {}) {
1404
+ const fullDocument = options?.fullDocument === true;
1405
+
866
1406
  if (!fileEntry || typeof fileEntry.path !== 'string') {
867
1407
  return [{ text: truncate('No file selected', width), color: 'gray', bold: false }];
868
1408
  }
@@ -872,7 +1412,7 @@ function buildPreviewLines(fileEntry, width, scrollOffset) {
872
1412
  content = fs.readFileSync(fileEntry.path, 'utf8');
873
1413
  } catch (error) {
874
1414
  return [{
875
- text: truncate(`Unable to read ${fileEntry.name}: ${error.message}`, width),
1415
+ text: truncate(`Unable to read ${fileEntry.label || fileEntry.path}: ${error.message}`, width),
876
1416
  color: 'red',
877
1417
  bold: false
878
1418
  }];
@@ -885,8 +1425,8 @@ function buildPreviewLines(fileEntry, width, scrollOffset) {
885
1425
  bold: true
886
1426
  };
887
1427
 
888
- const cappedLines = rawLines.slice(0, 300);
889
- const hiddenLineCount = Math.max(0, rawLines.length - cappedLines.length);
1428
+ const cappedLines = fullDocument ? rawLines : rawLines.slice(0, 300);
1429
+ const hiddenLineCount = fullDocument ? 0 : Math.max(0, rawLines.length - cappedLines.length);
890
1430
  let inCodeBlock = false;
891
1431
 
892
1432
  const highlighted = cappedLines.map((rawLine, index) => {
@@ -977,23 +1517,31 @@ function createDashboardApp(deps) {
977
1517
  maxLines,
978
1518
  borderColor,
979
1519
  marginBottom,
980
- dense
1520
+ dense,
1521
+ focused
981
1522
  } = props;
982
1523
 
983
1524
  const contentWidth = Math.max(18, width - 4);
984
1525
  const visibleLines = fitLines(lines, maxLines, contentWidth);
1526
+ const panelBorderColor = focused ? 'cyan' : (borderColor || 'gray');
1527
+ const titleColor = focused ? 'black' : 'cyan';
1528
+ const titleBackground = focused ? 'cyan' : undefined;
985
1529
 
986
1530
  return React.createElement(
987
1531
  Box,
988
1532
  {
989
1533
  flexDirection: 'column',
990
1534
  borderStyle: dense ? 'single' : 'round',
991
- borderColor: borderColor || 'gray',
1535
+ borderColor: panelBorderColor,
992
1536
  paddingX: dense ? 0 : 1,
993
1537
  width,
994
1538
  marginBottom: marginBottom || 0
995
1539
  },
996
- React.createElement(Text, { bold: true, color: 'cyan' }, truncate(title, contentWidth)),
1540
+ React.createElement(
1541
+ Text,
1542
+ { bold: true, color: titleColor, backgroundColor: titleBackground },
1543
+ truncate(title, contentWidth)
1544
+ ),
997
1545
  ...visibleLines.map((line, index) => React.createElement(
998
1546
  Text,
999
1547
  {
@@ -1024,8 +1572,8 @@ function createDashboardApp(deps) {
1024
1572
  {
1025
1573
  key: tab.id,
1026
1574
  bold: isActive,
1027
- color: isActive ? 'black' : 'gray',
1028
- backgroundColor: isActive ? 'cyan' : undefined
1575
+ color: isActive ? 'white' : 'gray',
1576
+ backgroundColor: isActive ? 'blue' : undefined
1029
1577
  },
1030
1578
  tab.label
1031
1579
  );
@@ -1072,14 +1620,29 @@ function createDashboardApp(deps) {
1072
1620
  const initialNormalizedError = initialError ? toDashboardError(initialError) : null;
1073
1621
  const snapshotHashRef = useRef(safeJsonHash(initialSnapshot || null));
1074
1622
  const errorHashRef = useRef(initialNormalizedError ? safeJsonHash(initialNormalizedError) : null);
1623
+ const lastVPressRef = useRef(0);
1075
1624
 
1076
1625
  const [activeFlow, setActiveFlow] = useState(fallbackFlow);
1077
1626
  const [snapshot, setSnapshot] = useState(initialSnapshot || null);
1078
1627
  const [error, setError] = useState(initialNormalizedError);
1079
1628
  const [ui, setUi] = useState(createInitialUIState());
1080
- const [selectedFileIndex, setSelectedFileIndex] = useState(0);
1629
+ const [sectionFocus, setSectionFocus] = useState({
1630
+ runs: 'run-files',
1631
+ overview: 'project',
1632
+ health: 'stats'
1633
+ });
1634
+ const [selectionBySection, setSelectionBySection] = useState({
1635
+ 'run-files': 0,
1636
+ pending: 0,
1637
+ completed: 0
1638
+ });
1639
+ const [expandedGroups, setExpandedGroups] = useState({});
1640
+ const [previewTarget, setPreviewTarget] = useState(null);
1081
1641
  const [previewOpen, setPreviewOpen] = useState(false);
1642
+ const [paneFocus, setPaneFocus] = useState('main');
1643
+ const [overlayPreviewOpen, setOverlayPreviewOpen] = useState(false);
1082
1644
  const [previewScroll, setPreviewScroll] = useState(0);
1645
+ const [statusLine, setStatusLine] = useState('');
1083
1646
  const [lastRefreshAt, setLastRefreshAt] = useState(new Date().toISOString());
1084
1647
  const [watchStatus, setWatchStatus] = useState(watchEnabled ? 'watching' : 'off');
1085
1648
  const [terminalSize, setTerminalSize] = useState(() => ({
@@ -1102,9 +1665,42 @@ function createDashboardApp(deps) {
1102
1665
  }
1103
1666
  };
1104
1667
  }, [parseSnapshotForFlow, parseSnapshot]);
1668
+
1669
+ const previewVisibleRows = Number.isFinite(terminalSize.rows) ? terminalSize.rows : (process.stdout.rows || 40);
1670
+ const showErrorPanelForSections = Boolean(error) && previewVisibleRows >= 18;
1671
+ const getAvailableSections = useCallback((viewId) => {
1672
+ const base = getSectionOrderForView(viewId);
1673
+ return base.filter((sectionKey) => sectionKey !== 'error-details' || showErrorPanelForSections);
1674
+ }, [showErrorPanelForSections]);
1675
+
1105
1676
  const runFileEntries = getRunFileEntries(snapshot, activeFlow);
1106
- const clampedSelectedFileIndex = clampIndex(selectedFileIndex, runFileEntries.length);
1107
- const selectedFile = runFileEntries[clampedSelectedFileIndex] || null;
1677
+ const runFileRows = toRunFileRows(runFileEntries, activeFlow);
1678
+ const pendingRows = toExpandableRows(
1679
+ buildPendingGroups(snapshot, activeFlow),
1680
+ getNoPendingMessage(getEffectiveFlow(activeFlow, snapshot)),
1681
+ expandedGroups
1682
+ );
1683
+ const completedRows = toExpandableRows(
1684
+ buildCompletedGroups(snapshot, activeFlow),
1685
+ getNoCompletedMessage(getEffectiveFlow(activeFlow, snapshot)),
1686
+ expandedGroups
1687
+ );
1688
+
1689
+ const rowsBySection = {
1690
+ 'run-files': runFileRows,
1691
+ pending: pendingRows,
1692
+ completed: completedRows
1693
+ };
1694
+
1695
+ const currentSectionOrder = getAvailableSections(ui.view);
1696
+ const focusedSection = currentSectionOrder.includes(sectionFocus[ui.view])
1697
+ ? sectionFocus[ui.view]
1698
+ : (currentSectionOrder[0] || 'current-run');
1699
+
1700
+ const focusedRows = rowsBySection[focusedSection] || [];
1701
+ const focusedIndex = selectionBySection[focusedSection] || 0;
1702
+ const selectedFocusedRow = getSelectedRow(focusedRows, focusedIndex);
1703
+ const selectedFocusedFile = rowToFileEntry(selectedFocusedRow);
1108
1704
 
1109
1705
  const refresh = useCallback(async () => {
1110
1706
  const now = new Date().toISOString();
@@ -1166,41 +1762,6 @@ function createDashboardApp(deps) {
1166
1762
  return;
1167
1763
  }
1168
1764
 
1169
- if (input === 'v' && ui.view === 'runs') {
1170
- if (selectedFile) {
1171
- setPreviewOpen((previous) => !previous);
1172
- setPreviewScroll(0);
1173
- }
1174
- return;
1175
- }
1176
-
1177
- if (key.escape && previewOpen) {
1178
- setPreviewOpen(false);
1179
- setPreviewScroll(0);
1180
- return;
1181
- }
1182
-
1183
- if (ui.view === 'runs' && (key.upArrow || key.downArrow || input === 'j' || input === 'k')) {
1184
- const moveDown = key.downArrow || input === 'j';
1185
- const moveUp = key.upArrow || input === 'k';
1186
-
1187
- if (previewOpen) {
1188
- if (moveDown) {
1189
- setPreviewScroll((previous) => previous + 1);
1190
- } else if (moveUp) {
1191
- setPreviewScroll((previous) => Math.max(0, previous - 1));
1192
- }
1193
- return;
1194
- }
1195
-
1196
- if (moveDown) {
1197
- setSelectedFileIndex((previous) => clampIndex(previous + 1, runFileEntries.length));
1198
- } else if (moveUp) {
1199
- setSelectedFileIndex((previous) => clampIndex(previous - 1, runFileEntries.length));
1200
- }
1201
- return;
1202
- }
1203
-
1204
1765
  if (input === 'h' || input === '?') {
1205
1766
  setUi((previous) => ({ ...previous, showHelp: !previous.showHelp }));
1206
1767
  return;
@@ -1208,31 +1769,19 @@ function createDashboardApp(deps) {
1208
1769
 
1209
1770
  if (input === '1') {
1210
1771
  setUi((previous) => ({ ...previous, view: 'runs' }));
1772
+ setPaneFocus('main');
1211
1773
  return;
1212
1774
  }
1213
1775
 
1214
1776
  if (input === '2') {
1215
1777
  setUi((previous) => ({ ...previous, view: 'overview' }));
1778
+ setPaneFocus('main');
1216
1779
  return;
1217
1780
  }
1218
1781
 
1219
1782
  if (input === '3') {
1220
1783
  setUi((previous) => ({ ...previous, view: 'health' }));
1221
- return;
1222
- }
1223
-
1224
- if (key.tab) {
1225
- setUi((previous) => ({ ...previous, view: cycleView(previous.view) }));
1226
- return;
1227
- }
1228
-
1229
- if (key.rightArrow) {
1230
- setUi((previous) => ({ ...previous, view: cycleView(previous.view) }));
1231
- return;
1232
- }
1233
-
1234
- if (key.leftArrow) {
1235
- setUi((previous) => ({ ...previous, view: cycleViewBackward(previous.view) }));
1784
+ setPaneFocus('main');
1236
1785
  return;
1237
1786
  }
1238
1787
 
@@ -1248,8 +1797,22 @@ function createDashboardApp(deps) {
1248
1797
  : 0;
1249
1798
  return availableFlowIds[nextIndex];
1250
1799
  });
1800
+ setSelectionBySection({
1801
+ 'run-files': 0,
1802
+ pending: 0,
1803
+ completed: 0
1804
+ });
1805
+ setSectionFocus({
1806
+ runs: 'run-files',
1807
+ overview: 'project',
1808
+ health: 'stats'
1809
+ });
1810
+ setExpandedGroups({});
1811
+ setPreviewTarget(null);
1251
1812
  setPreviewOpen(false);
1813
+ setOverlayPreviewOpen(false);
1252
1814
  setPreviewScroll(0);
1815
+ setPaneFocus('main');
1253
1816
  return;
1254
1817
  }
1255
1818
 
@@ -1265,8 +1828,210 @@ function createDashboardApp(deps) {
1265
1828
  : 0;
1266
1829
  return availableFlowIds[nextIndex];
1267
1830
  });
1831
+ setSelectionBySection({
1832
+ 'run-files': 0,
1833
+ pending: 0,
1834
+ completed: 0
1835
+ });
1836
+ setSectionFocus({
1837
+ runs: 'run-files',
1838
+ overview: 'project',
1839
+ health: 'stats'
1840
+ });
1841
+ setExpandedGroups({});
1842
+ setPreviewTarget(null);
1268
1843
  setPreviewOpen(false);
1844
+ setOverlayPreviewOpen(false);
1269
1845
  setPreviewScroll(0);
1846
+ setPaneFocus('main');
1847
+ return;
1848
+ }
1849
+
1850
+ const availableSections = getAvailableSections(ui.view);
1851
+ const activeSection = availableSections.includes(sectionFocus[ui.view])
1852
+ ? sectionFocus[ui.view]
1853
+ : (availableSections[0] || 'current-run');
1854
+
1855
+ if (key.tab && ui.view === 'runs' && previewOpen) {
1856
+ setPaneFocus((previous) => (previous === 'main' ? 'preview' : 'main'));
1857
+ return;
1858
+ }
1859
+
1860
+ if (key.rightArrow || input === 'g') {
1861
+ setSectionFocus((previous) => ({
1862
+ ...previous,
1863
+ [ui.view]: cycleSection(ui.view, activeSection, 1, availableSections)
1864
+ }));
1865
+ setPaneFocus('main');
1866
+ return;
1867
+ }
1868
+
1869
+ if (key.leftArrow || input === 'G') {
1870
+ setSectionFocus((previous) => ({
1871
+ ...previous,
1872
+ [ui.view]: cycleSection(ui.view, activeSection, -1, availableSections)
1873
+ }));
1874
+ setPaneFocus('main');
1875
+ return;
1876
+ }
1877
+
1878
+ if (ui.view === 'runs') {
1879
+ if (input === 'a') {
1880
+ setSectionFocus((previous) => ({ ...previous, runs: 'current-run' }));
1881
+ setPaneFocus('main');
1882
+ return;
1883
+ }
1884
+ if (input === 'f') {
1885
+ setSectionFocus((previous) => ({ ...previous, runs: 'run-files' }));
1886
+ setPaneFocus('main');
1887
+ return;
1888
+ }
1889
+ if (input === 'p') {
1890
+ setSectionFocus((previous) => ({ ...previous, runs: 'pending' }));
1891
+ setPaneFocus('main');
1892
+ return;
1893
+ }
1894
+ if (input === 'c') {
1895
+ setSectionFocus((previous) => ({ ...previous, runs: 'completed' }));
1896
+ setPaneFocus('main');
1897
+ return;
1898
+ }
1899
+ } else if (ui.view === 'overview') {
1900
+ if (input === 'p') {
1901
+ setSectionFocus((previous) => ({ ...previous, overview: 'project' }));
1902
+ return;
1903
+ }
1904
+ if (input === 'i') {
1905
+ setSectionFocus((previous) => ({ ...previous, overview: 'intent-status' }));
1906
+ return;
1907
+ }
1908
+ if (input === 's') {
1909
+ setSectionFocus((previous) => ({ ...previous, overview: 'standards' }));
1910
+ return;
1911
+ }
1912
+ } else if (ui.view === 'health') {
1913
+ if (input === 't') {
1914
+ setSectionFocus((previous) => ({ ...previous, health: 'stats' }));
1915
+ return;
1916
+ }
1917
+ if (input === 'w') {
1918
+ setSectionFocus((previous) => ({ ...previous, health: 'warnings' }));
1919
+ return;
1920
+ }
1921
+ if (input === 'e' && showErrorPanelForSections) {
1922
+ setSectionFocus((previous) => ({ ...previous, health: 'error-details' }));
1923
+ return;
1924
+ }
1925
+ }
1926
+
1927
+ if (key.escape) {
1928
+ if (overlayPreviewOpen) {
1929
+ setOverlayPreviewOpen(false);
1930
+ setPaneFocus('preview');
1931
+ return;
1932
+ }
1933
+ if (previewOpen) {
1934
+ setPreviewOpen(false);
1935
+ setPreviewScroll(0);
1936
+ setPaneFocus('main');
1937
+ return;
1938
+ }
1939
+ }
1940
+
1941
+ if (ui.view === 'runs' && (key.upArrow || key.downArrow || input === 'j' || input === 'k')) {
1942
+ const moveDown = key.downArrow || input === 'j';
1943
+ const moveUp = key.upArrow || input === 'k';
1944
+
1945
+ if (overlayPreviewOpen || (previewOpen && paneFocus === 'preview')) {
1946
+ if (moveDown) {
1947
+ setPreviewScroll((previous) => previous + 1);
1948
+ } else if (moveUp) {
1949
+ setPreviewScroll((previous) => Math.max(0, previous - 1));
1950
+ }
1951
+ return;
1952
+ }
1953
+
1954
+ const targetSection = activeSection === 'current-run' ? 'run-files' : activeSection;
1955
+ if (targetSection !== activeSection) {
1956
+ setSectionFocus((previous) => ({ ...previous, runs: targetSection }));
1957
+ }
1958
+
1959
+ const targetRows = rowsBySection[targetSection] || [];
1960
+ if (targetRows.length === 0) {
1961
+ return;
1962
+ }
1963
+
1964
+ const currentIndex = selectionBySection[targetSection] || 0;
1965
+ const nextIndex = moveDown
1966
+ ? moveRowSelection(targetRows, currentIndex, 1)
1967
+ : moveRowSelection(targetRows, currentIndex, -1);
1968
+
1969
+ setSelectionBySection((previous) => ({
1970
+ ...previous,
1971
+ [targetSection]: nextIndex
1972
+ }));
1973
+ return;
1974
+ }
1975
+
1976
+ if (ui.view === 'runs' && (key.return || key.enter)) {
1977
+ if (activeSection === 'pending' || activeSection === 'completed') {
1978
+ const rowsForSection = rowsBySection[activeSection] || [];
1979
+ const selectedRow = getSelectedRow(rowsForSection, selectionBySection[activeSection] || 0);
1980
+ if (selectedRow?.kind === 'group' && selectedRow.expandable) {
1981
+ setExpandedGroups((previous) => ({
1982
+ ...previous,
1983
+ [selectedRow.key]: !previous[selectedRow.key]
1984
+ }));
1985
+ }
1986
+ }
1987
+ return;
1988
+ }
1989
+
1990
+ if (input === 'v' && ui.view === 'runs') {
1991
+ const target = selectedFocusedFile || previewTarget;
1992
+ if (!target) {
1993
+ setStatusLine('Select a file row first (run files, pending, or completed).');
1994
+ return;
1995
+ }
1996
+
1997
+ const now = Date.now();
1998
+ const isDoublePress = (now - lastVPressRef.current) <= 320;
1999
+ lastVPressRef.current = now;
2000
+
2001
+ if (isDoublePress) {
2002
+ setPreviewTarget(target);
2003
+ setPreviewOpen(true);
2004
+ setOverlayPreviewOpen(true);
2005
+ setPreviewScroll(0);
2006
+ setPaneFocus('preview');
2007
+ return;
2008
+ }
2009
+
2010
+ if (!previewOpen) {
2011
+ setPreviewTarget(target);
2012
+ setPreviewOpen(true);
2013
+ setOverlayPreviewOpen(false);
2014
+ setPreviewScroll(0);
2015
+ setPaneFocus('main');
2016
+ return;
2017
+ }
2018
+
2019
+ if (overlayPreviewOpen) {
2020
+ setOverlayPreviewOpen(false);
2021
+ setPaneFocus('preview');
2022
+ return;
2023
+ }
2024
+
2025
+ setPreviewOpen(false);
2026
+ setPreviewScroll(0);
2027
+ setPaneFocus('main');
2028
+ return;
2029
+ }
2030
+
2031
+ if (input === 'o' && ui.view === 'runs') {
2032
+ const target = selectedFocusedFile || previewTarget;
2033
+ const result = openFileWithDefaultApp(target?.path);
2034
+ setStatusLine(result.message);
1270
2035
  }
1271
2036
  });
1272
2037
 
@@ -1275,20 +2040,51 @@ function createDashboardApp(deps) {
1275
2040
  }, [refresh]);
1276
2041
 
1277
2042
  useEffect(() => {
1278
- setSelectedFileIndex((previous) => clampIndex(previous, runFileEntries.length));
1279
- if (runFileEntries.length === 0) {
1280
- setPreviewOpen(false);
1281
- setPreviewScroll(0);
1282
- }
1283
- }, [activeFlow, runFileEntries.length, snapshot?.generatedAt]);
2043
+ setSelectionBySection((previous) => ({
2044
+ ...previous,
2045
+ 'run-files': clampIndex(previous['run-files'] || 0, runFileRows.length),
2046
+ pending: clampIndex(previous.pending || 0, pendingRows.length),
2047
+ completed: clampIndex(previous.completed || 0, completedRows.length)
2048
+ }));
2049
+ }, [activeFlow, runFileRows.length, pendingRows.length, completedRows.length, snapshot?.generatedAt]);
1284
2050
 
1285
2051
  useEffect(() => {
1286
2052
  if (ui.view !== 'runs') {
1287
2053
  setPreviewOpen(false);
2054
+ setOverlayPreviewOpen(false);
1288
2055
  setPreviewScroll(0);
2056
+ setPaneFocus('main');
1289
2057
  }
1290
2058
  }, [ui.view]);
1291
2059
 
2060
+ useEffect(() => {
2061
+ if (!previewOpen || overlayPreviewOpen || paneFocus !== 'main') {
2062
+ return;
2063
+ }
2064
+ if (!selectedFocusedFile?.path) {
2065
+ return;
2066
+ }
2067
+ if (previewTarget?.path === selectedFocusedFile.path) {
2068
+ return;
2069
+ }
2070
+ setPreviewTarget(selectedFocusedFile);
2071
+ setPreviewScroll(0);
2072
+ }, [previewOpen, overlayPreviewOpen, paneFocus, selectedFocusedFile?.path, previewTarget?.path]);
2073
+
2074
+ useEffect(() => {
2075
+ if (statusLine === '') {
2076
+ return undefined;
2077
+ }
2078
+
2079
+ const timeout = setTimeout(() => {
2080
+ setStatusLine('');
2081
+ }, 3500);
2082
+
2083
+ return () => {
2084
+ clearTimeout(timeout);
2085
+ };
2086
+ }, [statusLine]);
2087
+
1292
2088
  useEffect(() => {
1293
2089
  if (!stdout || typeof stdout.on !== 'function') {
1294
2090
  setTerminalSize({
@@ -1358,12 +2154,20 @@ function createDashboardApp(deps) {
1358
2154
  };
1359
2155
  }, [watchEnabled, refreshMs, refresh, rootPath, workspacePath, resolveRootPathForFlow, activeFlow]);
1360
2156
 
2157
+ useEffect(() => {
2158
+ if (!stdout || typeof stdout.write !== 'function') {
2159
+ return;
2160
+ }
2161
+ if (stdout.isTTY === false) {
2162
+ return;
2163
+ }
2164
+ stdout.write('\u001B[2J\u001B[3J\u001B[H');
2165
+ }, [stdout]);
2166
+
1361
2167
  const cols = Number.isFinite(terminalSize.columns) ? terminalSize.columns : (process.stdout.columns || 120);
1362
2168
  const rows = Number.isFinite(terminalSize.rows) ? terminalSize.rows : (process.stdout.rows || 40);
1363
2169
 
1364
2170
  const fullWidth = Math.max(40, cols - 1);
1365
- const compactWidth = Math.max(18, fullWidth - 4);
1366
-
1367
2171
  const showHelpLine = ui.showHelp && rows >= 14;
1368
2172
  const showErrorPanel = Boolean(error) && rows >= 18;
1369
2173
  const showErrorInline = Boolean(error) && !showErrorPanel;
@@ -1373,28 +2177,72 @@ function createDashboardApp(deps) {
1373
2177
  const contentRowsBudget = Math.max(4, rows - reservedRows);
1374
2178
  const ultraCompact = rows <= 14;
1375
2179
  const panelTitles = getPanelTitles(activeFlow, snapshot);
1376
- const runFileLines = buildSelectableRunFileLines(runFileEntries, clampedSelectedFileIndex, icons, compactWidth, activeFlow);
1377
- const previewLines = previewOpen ? buildPreviewLines(selectedFile, compactWidth, previewScroll) : [];
2180
+ const splitPreviewLayout = ui.view === 'runs' && previewOpen && !overlayPreviewOpen && cols >= 110 && rows >= 16;
2181
+ const mainPaneWidth = splitPreviewLayout
2182
+ ? Math.max(34, Math.floor((fullWidth - 1) * 0.52))
2183
+ : fullWidth;
2184
+ const previewPaneWidth = splitPreviewLayout
2185
+ ? Math.max(30, fullWidth - mainPaneWidth - 1)
2186
+ : fullWidth;
2187
+ const mainCompactWidth = Math.max(18, mainPaneWidth - 4);
2188
+ const previewCompactWidth = Math.max(18, previewPaneWidth - 4);
2189
+
2190
+ const runFileLines = buildInteractiveRowsLines(
2191
+ runFileRows,
2192
+ selectionBySection['run-files'] || 0,
2193
+ icons,
2194
+ mainCompactWidth,
2195
+ ui.view === 'runs' && focusedSection === 'run-files' && paneFocus === 'main'
2196
+ );
2197
+ const pendingLines = buildInteractiveRowsLines(
2198
+ pendingRows,
2199
+ selectionBySection.pending || 0,
2200
+ icons,
2201
+ mainCompactWidth,
2202
+ ui.view === 'runs' && focusedSection === 'pending' && paneFocus === 'main'
2203
+ );
2204
+ const completedLines = buildInteractiveRowsLines(
2205
+ completedRows,
2206
+ selectionBySection.completed || 0,
2207
+ icons,
2208
+ mainCompactWidth,
2209
+ ui.view === 'runs' && focusedSection === 'completed' && paneFocus === 'main'
2210
+ );
2211
+ const effectivePreviewTarget = previewTarget || selectedFocusedFile;
2212
+ const previewLines = previewOpen
2213
+ ? buildPreviewLines(effectivePreviewTarget, previewCompactWidth, previewScroll, {
2214
+ fullDocument: overlayPreviewOpen
2215
+ })
2216
+ : [];
1378
2217
 
1379
2218
  let panelCandidates;
1380
- if (ui.view === 'overview') {
2219
+ if (ui.view === 'runs' && previewOpen && overlayPreviewOpen) {
2220
+ panelCandidates = [
2221
+ {
2222
+ key: 'preview-overlay',
2223
+ title: `Preview: ${effectivePreviewTarget?.label || 'unknown'}`,
2224
+ lines: previewLines,
2225
+ borderColor: 'magenta'
2226
+ }
2227
+ ];
2228
+ } else if (ui.view === 'overview') {
1381
2229
  panelCandidates = [
1382
2230
  {
1383
2231
  key: 'project',
1384
2232
  title: 'Project + Workspace',
1385
- lines: buildOverviewProjectLines(snapshot, compactWidth, activeFlow),
2233
+ lines: buildOverviewProjectLines(snapshot, mainCompactWidth, activeFlow),
1386
2234
  borderColor: 'green'
1387
2235
  },
1388
2236
  {
1389
2237
  key: 'intent-status',
1390
2238
  title: 'Intent Status',
1391
- lines: buildOverviewIntentLines(snapshot, compactWidth, activeFlow),
2239
+ lines: buildOverviewIntentLines(snapshot, mainCompactWidth, activeFlow),
1392
2240
  borderColor: 'yellow'
1393
2241
  },
1394
2242
  {
1395
2243
  key: 'standards',
1396
2244
  title: 'Standards',
1397
- lines: buildOverviewStandardsLines(snapshot, compactWidth, activeFlow),
2245
+ lines: buildOverviewStandardsLines(snapshot, mainCompactWidth, activeFlow),
1398
2246
  borderColor: 'blue'
1399
2247
  }
1400
2248
  ];
@@ -1403,13 +2251,13 @@ function createDashboardApp(deps) {
1403
2251
  {
1404
2252
  key: 'stats',
1405
2253
  title: 'Stats',
1406
- lines: buildStatsLines(snapshot, compactWidth, activeFlow),
2254
+ lines: buildStatsLines(snapshot, mainCompactWidth, activeFlow),
1407
2255
  borderColor: 'magenta'
1408
2256
  },
1409
2257
  {
1410
2258
  key: 'warnings',
1411
2259
  title: 'Warnings',
1412
- lines: buildWarningsLines(snapshot, compactWidth),
2260
+ lines: buildWarningsLines(snapshot, mainCompactWidth),
1413
2261
  borderColor: 'red'
1414
2262
  }
1415
2263
  ];
@@ -1418,16 +2266,17 @@ function createDashboardApp(deps) {
1418
2266
  panelCandidates.push({
1419
2267
  key: 'error-details',
1420
2268
  title: 'Error Details',
1421
- lines: buildErrorLines(error, compactWidth),
2269
+ lines: buildErrorLines(error, mainCompactWidth),
1422
2270
  borderColor: 'red'
1423
2271
  });
1424
2272
  }
1425
2273
  } else {
2274
+ const includeInlinePreviewPanel = previewOpen && !splitPreviewLayout;
1426
2275
  panelCandidates = [
1427
2276
  {
1428
2277
  key: 'current-run',
1429
2278
  title: panelTitles.current,
1430
- lines: buildCurrentRunLines(snapshot, compactWidth, activeFlow),
2279
+ lines: buildCurrentRunLines(snapshot, mainCompactWidth, activeFlow),
1431
2280
  borderColor: 'green'
1432
2281
  },
1433
2282
  {
@@ -1436,10 +2285,10 @@ function createDashboardApp(deps) {
1436
2285
  lines: runFileLines,
1437
2286
  borderColor: 'yellow'
1438
2287
  },
1439
- previewOpen
2288
+ includeInlinePreviewPanel
1440
2289
  ? {
1441
2290
  key: 'preview',
1442
- title: `Preview: ${selectedFile?.name || 'unknown'}`,
2291
+ title: `Preview: ${effectivePreviewTarget?.label || 'unknown'}`,
1443
2292
  lines: previewLines,
1444
2293
  borderColor: 'magenta'
1445
2294
  }
@@ -1447,19 +2296,19 @@ function createDashboardApp(deps) {
1447
2296
  {
1448
2297
  key: 'pending',
1449
2298
  title: panelTitles.pending,
1450
- lines: buildPendingLines(snapshot, compactWidth, activeFlow),
2299
+ lines: pendingLines,
1451
2300
  borderColor: 'yellow'
1452
2301
  },
1453
2302
  {
1454
2303
  key: 'completed',
1455
2304
  title: panelTitles.completed,
1456
- lines: buildCompletedLines(snapshot, compactWidth, activeFlow),
2305
+ lines: completedLines,
1457
2306
  borderColor: 'blue'
1458
2307
  }
1459
2308
  ];
1460
2309
  }
1461
2310
 
1462
- if (ultraCompact) {
2311
+ if (ultraCompact && !splitPreviewLayout) {
1463
2312
  if (previewOpen) {
1464
2313
  panelCandidates = panelCandidates.filter((panel) => panel && (panel.key === 'current-run' || panel.key === 'preview'));
1465
2314
  } else {
@@ -1469,8 +2318,83 @@ function createDashboardApp(deps) {
1469
2318
 
1470
2319
  const panels = allocateSingleColumnPanels(panelCandidates, contentRowsBudget);
1471
2320
  const flowSwitchHint = availableFlowIds.length > 1 ? ' | [ or ] switch flow' : '';
1472
- const previewHint = previewOpen ? ' | ↑/↓ scroll preview' : ' | ↑/↓ select file | v preview';
1473
- const helpText = `q quit | r refresh | h/? help | ←/→ or tab switch views | 1 runs | 2 overview | 3 health${previewHint}${flowSwitchHint}`;
2321
+ const sectionHint = ui.view === 'runs'
2322
+ ? ' | a active | f files | p pending | c done'
2323
+ : (ui.view === 'overview' ? ' | p project | i intents | s standards' : ' | t stats | w warnings | e errors');
2324
+ const previewHint = ui.view === 'runs'
2325
+ ? (previewOpen
2326
+ ? ` | tab ${paneFocus === 'preview' ? 'main' : 'preview'} | ↑/↓ ${paneFocus === 'preview' ? 'scroll' : 'navigate'} | v close | vv fullscreen`
2327
+ : ' | ↑/↓ navigate | enter expand | v preview | vv fullscreen | o open')
2328
+ : '';
2329
+ const helpText = `q quit | r refresh | h/? help | 1 runs | 2 overview | 3 health | g/G section${sectionHint}${previewHint}${flowSwitchHint}`;
2330
+
2331
+ const renderPanel = (panel, index, width, isFocused) => React.createElement(SectionPanel, {
2332
+ key: panel.key,
2333
+ title: panel.title,
2334
+ lines: panel.lines,
2335
+ width,
2336
+ maxLines: panel.maxLines,
2337
+ borderColor: panel.borderColor,
2338
+ marginBottom: densePanels ? 0 : (index === panels.length - 1 ? 0 : 1),
2339
+ dense: densePanels,
2340
+ focused: isFocused
2341
+ });
2342
+
2343
+ let contentNode;
2344
+ if (splitPreviewLayout && ui.view === 'runs' && !overlayPreviewOpen) {
2345
+ const previewPanel = {
2346
+ key: 'preview-split',
2347
+ title: `Preview: ${effectivePreviewTarget?.label || 'unknown'}`,
2348
+ lines: previewLines,
2349
+ borderColor: 'magenta',
2350
+ maxLines: Math.max(4, contentRowsBudget)
2351
+ };
2352
+
2353
+ contentNode = React.createElement(
2354
+ Box,
2355
+ { width: fullWidth, flexDirection: 'row' },
2356
+ React.createElement(
2357
+ Box,
2358
+ { width: mainPaneWidth, flexDirection: 'column' },
2359
+ ...panels.map((panel, index) => React.createElement(SectionPanel, {
2360
+ key: panel.key,
2361
+ title: panel.title,
2362
+ lines: panel.lines,
2363
+ width: mainPaneWidth,
2364
+ maxLines: panel.maxLines,
2365
+ borderColor: panel.borderColor,
2366
+ marginBottom: densePanels ? 0 : (index === panels.length - 1 ? 0 : 1),
2367
+ dense: densePanels,
2368
+ focused: paneFocus === 'main' && panel.key === focusedSection
2369
+ }))
2370
+ ),
2371
+ React.createElement(Box, { width: 1 }, React.createElement(Text, null, ' ')),
2372
+ React.createElement(
2373
+ Box,
2374
+ { width: previewPaneWidth, flexDirection: 'column' },
2375
+ React.createElement(SectionPanel, {
2376
+ key: previewPanel.key,
2377
+ title: previewPanel.title,
2378
+ lines: previewPanel.lines,
2379
+ width: previewPaneWidth,
2380
+ maxLines: previewPanel.maxLines,
2381
+ borderColor: previewPanel.borderColor,
2382
+ marginBottom: 0,
2383
+ dense: densePanels,
2384
+ focused: paneFocus === 'preview'
2385
+ })
2386
+ )
2387
+ );
2388
+ } else {
2389
+ contentNode = panels.map((panel, index) => renderPanel(
2390
+ panel,
2391
+ index,
2392
+ fullWidth,
2393
+ (panel.key === 'preview' || panel.key === 'preview-overlay')
2394
+ ? paneFocus === 'preview'
2395
+ : (paneFocus === 'main' && panel.key === focusedSection)
2396
+ ));
2397
+ }
1474
2398
 
1475
2399
  return React.createElement(
1476
2400
  Box,
@@ -1484,24 +2408,19 @@ function createDashboardApp(deps) {
1484
2408
  showErrorPanel
1485
2409
  ? React.createElement(SectionPanel, {
1486
2410
  title: 'Errors',
1487
- lines: buildErrorLines(error, compactWidth),
2411
+ lines: buildErrorLines(error, Math.max(18, fullWidth - 4)),
1488
2412
  width: fullWidth,
1489
2413
  maxLines: 2,
1490
2414
  borderColor: 'red',
1491
2415
  marginBottom: densePanels ? 0 : 1,
1492
- dense: densePanels
2416
+ dense: densePanels,
2417
+ focused: paneFocus === 'main' && focusedSection === 'error-details'
1493
2418
  })
1494
2419
  : null,
1495
- ...panels.map((panel, index) => React.createElement(SectionPanel, {
1496
- key: panel.key,
1497
- title: panel.title,
1498
- lines: panel.lines,
1499
- width: fullWidth,
1500
- maxLines: panel.maxLines,
1501
- borderColor: panel.borderColor,
1502
- marginBottom: densePanels ? 0 : (index === panels.length - 1 ? 0 : 1),
1503
- dense: densePanels
1504
- })),
2420
+ ...(Array.isArray(contentNode) ? contentNode : [contentNode]),
2421
+ statusLine !== ''
2422
+ ? React.createElement(Text, { color: 'yellow' }, truncate(statusLine, fullWidth))
2423
+ : null,
1505
2424
  showHelpLine
1506
2425
  ? React.createElement(Text, { color: 'gray' }, truncate(helpText, fullWidth))
1507
2426
  : null