specsmd 0.1.51 → 0.1.52

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.
@@ -0,0 +1,330 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawnSync } = require('child_process');
4
+
5
+ function runGit(args, cwd, options = {}) {
6
+ const acceptedStatuses = Array.isArray(options.acceptedStatuses) && options.acceptedStatuses.length > 0
7
+ ? options.acceptedStatuses
8
+ : [0];
9
+ const result = spawnSync('git', args, {
10
+ cwd,
11
+ encoding: 'utf8',
12
+ maxBuffer: 10 * 1024 * 1024
13
+ });
14
+
15
+ if (result.error) {
16
+ return {
17
+ ok: false,
18
+ error: result.error.message || String(result.error),
19
+ stdout: '',
20
+ stderr: ''
21
+ };
22
+ }
23
+
24
+ if (typeof result.status === 'number' && !acceptedStatuses.includes(result.status)) {
25
+ return {
26
+ ok: false,
27
+ error: String(result.stderr || '').trim() || `git exited with code ${result.status}`,
28
+ stdout: String(result.stdout || ''),
29
+ stderr: String(result.stderr || '')
30
+ };
31
+ }
32
+
33
+ return {
34
+ ok: true,
35
+ error: null,
36
+ stdout: String(result.stdout || ''),
37
+ stderr: String(result.stderr || '')
38
+ };
39
+ }
40
+
41
+ function findGitRoot(worktreePath) {
42
+ if (typeof worktreePath !== 'string' || worktreePath.trim() === '') {
43
+ return null;
44
+ }
45
+
46
+ const probe = runGit(['rev-parse', '--show-toplevel'], worktreePath);
47
+ if (!probe.ok) {
48
+ return null;
49
+ }
50
+
51
+ const root = probe.stdout.trim();
52
+ return root === '' ? null : root;
53
+ }
54
+
55
+ function parseBranchSummary(line) {
56
+ const raw = String(line || '').replace(/^##\s*/, '').trim();
57
+ if (raw === '') {
58
+ return {
59
+ branch: '(unknown)',
60
+ upstream: null,
61
+ ahead: 0,
62
+ behind: 0,
63
+ detached: false
64
+ };
65
+ }
66
+
67
+ if (raw.startsWith('HEAD ')) {
68
+ return {
69
+ branch: '(detached)',
70
+ upstream: null,
71
+ ahead: 0,
72
+ behind: 0,
73
+ detached: true
74
+ };
75
+ }
76
+
77
+ const [branchPart, trackingPartRaw] = raw.split(/\s+/, 2);
78
+ let branch = branchPart;
79
+ let upstream = null;
80
+ if (branch.includes('...')) {
81
+ const [name, remote] = branch.split('...', 2);
82
+ branch = name || '(unknown)';
83
+ upstream = remote || null;
84
+ }
85
+
86
+ const trackingPart = typeof trackingPartRaw === 'string' ? trackingPartRaw : '';
87
+ const aheadMatch = trackingPart.match(/ahead\s+(\d+)/);
88
+ const behindMatch = trackingPart.match(/behind\s+(\d+)/);
89
+ const ahead = aheadMatch ? Number.parseInt(aheadMatch[1], 10) : 0;
90
+ const behind = behindMatch ? Number.parseInt(behindMatch[1], 10) : 0;
91
+
92
+ return {
93
+ branch: branch || '(unknown)',
94
+ upstream,
95
+ ahead: Number.isFinite(ahead) ? ahead : 0,
96
+ behind: Number.isFinite(behind) ? behind : 0,
97
+ detached: false
98
+ };
99
+ }
100
+
101
+ function parseStatusEntry(line, repoRoot) {
102
+ const raw = String(line || '');
103
+ if (raw.trim() === '' || raw.startsWith('## ')) {
104
+ return null;
105
+ }
106
+
107
+ const code = raw.slice(0, 2);
108
+ const statusX = code.charAt(0);
109
+ const statusY = code.charAt(1);
110
+ const remainder = raw.length > 3 ? raw.slice(3) : '';
111
+
112
+ let relativePath = remainder.trim();
113
+ if (relativePath.includes(' -> ')) {
114
+ const parts = relativePath.split(' -> ');
115
+ relativePath = parts[parts.length - 1].trim();
116
+ }
117
+
118
+ const absolutePath = relativePath === ''
119
+ ? ''
120
+ : path.join(repoRoot, relativePath);
121
+ const isUntracked = code === '??';
122
+ const isConflicted = statusX === 'U'
123
+ || statusY === 'U'
124
+ || code === 'AA'
125
+ || code === 'DD';
126
+ const isStaged = !isUntracked && statusX !== ' ';
127
+ const isUnstaged = !isUntracked && statusY !== ' ';
128
+
129
+ return {
130
+ code,
131
+ statusX,
132
+ statusY,
133
+ relativePath,
134
+ absolutePath,
135
+ staged: isStaged,
136
+ unstaged: isUnstaged,
137
+ untracked: isUntracked,
138
+ conflicted: isConflicted
139
+ };
140
+ }
141
+
142
+ function buildBucketItem(entry, bucket) {
143
+ return {
144
+ key: `${bucket}:${entry.relativePath}`,
145
+ bucket,
146
+ code: entry.code,
147
+ path: entry.absolutePath,
148
+ relativePath: entry.relativePath,
149
+ label: entry.relativePath
150
+ };
151
+ }
152
+
153
+ function listGitChanges(worktreePath) {
154
+ const gitRoot = findGitRoot(worktreePath);
155
+ if (!gitRoot) {
156
+ return {
157
+ available: false,
158
+ rootPath: null,
159
+ branch: '(not a git repo)',
160
+ upstream: null,
161
+ ahead: 0,
162
+ behind: 0,
163
+ detached: false,
164
+ clean: true,
165
+ counts: {
166
+ total: 0,
167
+ staged: 0,
168
+ unstaged: 0,
169
+ untracked: 0,
170
+ conflicted: 0
171
+ },
172
+ staged: [],
173
+ unstaged: [],
174
+ untracked: [],
175
+ conflicted: [],
176
+ generatedAt: new Date().toISOString()
177
+ };
178
+ }
179
+
180
+ const statusResult = runGit(
181
+ ['-c', 'color.ui=false', '-c', 'core.quotepath=false', 'status', '--porcelain', '--branch', '--untracked-files=all'],
182
+ gitRoot
183
+ );
184
+
185
+ if (!statusResult.ok) {
186
+ return {
187
+ available: false,
188
+ rootPath: gitRoot,
189
+ branch: '(status unavailable)',
190
+ upstream: null,
191
+ ahead: 0,
192
+ behind: 0,
193
+ detached: false,
194
+ clean: true,
195
+ counts: {
196
+ total: 0,
197
+ staged: 0,
198
+ unstaged: 0,
199
+ untracked: 0,
200
+ conflicted: 0
201
+ },
202
+ staged: [],
203
+ unstaged: [],
204
+ untracked: [],
205
+ conflicted: [],
206
+ error: statusResult.error,
207
+ generatedAt: new Date().toISOString()
208
+ };
209
+ }
210
+
211
+ const lines = statusResult.stdout.split(/\r?\n/).filter(Boolean);
212
+ const branchInfo = parseBranchSummary(lines[0] || '');
213
+
214
+ const staged = [];
215
+ const unstaged = [];
216
+ const untracked = [];
217
+ const conflicted = [];
218
+
219
+ for (const line of lines.slice(1)) {
220
+ const entry = parseStatusEntry(line, gitRoot);
221
+ if (!entry || entry.relativePath === '') {
222
+ continue;
223
+ }
224
+ if (entry.conflicted) {
225
+ conflicted.push(buildBucketItem(entry, 'conflicted'));
226
+ }
227
+ if (entry.staged) {
228
+ staged.push(buildBucketItem(entry, 'staged'));
229
+ }
230
+ if (entry.unstaged) {
231
+ unstaged.push(buildBucketItem(entry, 'unstaged'));
232
+ }
233
+ if (entry.untracked) {
234
+ untracked.push(buildBucketItem(entry, 'untracked'));
235
+ }
236
+ }
237
+
238
+ const uniqueCount = new Set([
239
+ ...staged.map((item) => item.relativePath),
240
+ ...unstaged.map((item) => item.relativePath),
241
+ ...untracked.map((item) => item.relativePath),
242
+ ...conflicted.map((item) => item.relativePath)
243
+ ]).size;
244
+
245
+ return {
246
+ available: true,
247
+ rootPath: gitRoot,
248
+ branch: branchInfo.branch,
249
+ upstream: branchInfo.upstream,
250
+ ahead: branchInfo.ahead,
251
+ behind: branchInfo.behind,
252
+ detached: branchInfo.detached,
253
+ clean: uniqueCount === 0,
254
+ counts: {
255
+ total: uniqueCount,
256
+ staged: staged.length,
257
+ unstaged: unstaged.length,
258
+ untracked: untracked.length,
259
+ conflicted: conflicted.length
260
+ },
261
+ staged,
262
+ unstaged,
263
+ untracked,
264
+ conflicted,
265
+ generatedAt: new Date().toISOString()
266
+ };
267
+ }
268
+
269
+ function readUntrackedFileDiff(repoRoot, absolutePath) {
270
+ const exists = typeof absolutePath === 'string' && absolutePath !== '' && fs.existsSync(absolutePath);
271
+ if (!exists) {
272
+ return '';
273
+ }
274
+
275
+ const result = runGit(
276
+ ['-c', 'color.ui=always', '--no-pager', 'diff', '--no-index', '--', '/dev/null', absolutePath],
277
+ repoRoot,
278
+ { acceptedStatuses: [0, 1] }
279
+ );
280
+ if (!result.ok) {
281
+ return '';
282
+ }
283
+ return result.stdout;
284
+ }
285
+
286
+ function loadGitDiffPreview(changeEntry) {
287
+ const bucket = typeof changeEntry?.bucket === 'string' ? changeEntry.bucket : 'unstaged';
288
+ const repoRoot = typeof changeEntry?.repoRoot === 'string'
289
+ ? changeEntry.repoRoot
290
+ : (typeof changeEntry?.workspacePath === 'string' ? findGitRoot(changeEntry.workspacePath) : null);
291
+ const relativePath = typeof changeEntry?.relativePath === 'string' ? changeEntry.relativePath : '';
292
+ const absolutePath = typeof changeEntry?.path === 'string' ? changeEntry.path : '';
293
+
294
+ if (!repoRoot) {
295
+ return '[git] repository is unavailable for preview.';
296
+ }
297
+ if (relativePath === '') {
298
+ return '[git] no file selected.';
299
+ }
300
+
301
+ if (bucket === 'untracked') {
302
+ const rawDiff = readUntrackedFileDiff(repoRoot, absolutePath);
303
+ if (rawDiff.trim() !== '') {
304
+ return rawDiff;
305
+ }
306
+ }
307
+
308
+ const args = ['-c', 'color.ui=always', '--no-pager', 'diff'];
309
+ if (bucket === 'staged') {
310
+ args.push('--cached');
311
+ }
312
+ args.push('--', relativePath);
313
+
314
+ const result = runGit(args, repoRoot);
315
+ if (!result.ok) {
316
+ return `[git] unable to load diff: ${result.error}`;
317
+ }
318
+
319
+ const output = result.stdout.trim();
320
+ if (output === '') {
321
+ return '[git] no diff output for this file.';
322
+ }
323
+
324
+ return result.stdout;
325
+ }
326
+
327
+ module.exports = {
328
+ listGitChanges,
329
+ loadGitDiffPreview
330
+ };
@@ -8,6 +8,7 @@ const { parseSimpleDashboard } = require('./simple/parser');
8
8
  const { formatDashboardText } = require('./tui/renderer');
9
9
  const { createDashboardApp } = require('./tui/app');
10
10
  const { discoverGitWorktrees, pickWorktree, pathExistsAsDirectory } = require('./git/worktrees');
11
+ const { listGitChanges } = require('./git/changes');
11
12
 
12
13
  function parseRefreshMs(raw) {
13
14
  const parsed = Number.parseInt(String(raw || '1000'), 10);
@@ -602,7 +603,8 @@ async function runFlowDashboard(options, flow, availableFlows = []) {
602
603
  snapshot: {
603
604
  ...selectedResult.snapshot,
604
605
  workspacePath: selectedWorktree?.path || selectedResult.snapshot?.workspacePath || workspacePath,
605
- dashboardWorktrees: envelope
606
+ dashboardWorktrees: envelope,
607
+ gitChanges: listGitChanges(selectedWorktree?.path || workspacePath)
606
608
  }
607
609
  };
608
610
  };
@@ -5,6 +5,7 @@ const stringWidthModule = require('string-width');
5
5
  const sliceAnsiModule = require('slice-ansi');
6
6
  const { createWatchRuntime } = require('../runtime/watch-runtime');
7
7
  const { createInitialUIState } = require('./store');
8
+ const { loadGitDiffPreview } = require('../git/changes');
8
9
 
9
10
  const stringWidth = typeof stringWidthModule === 'function'
10
11
  ? stringWidthModule
@@ -64,6 +65,7 @@ function resolveIconSet() {
64
65
  runs: '[R]',
65
66
  overview: '[O]',
66
67
  health: '[H]',
68
+ git: '[G]',
67
69
  runFile: '*',
68
70
  activeFile: '>',
69
71
  groupCollapsed: '>',
@@ -74,6 +76,7 @@ function resolveIconSet() {
74
76
  runs: '󰑮',
75
77
  overview: '󰍉',
76
78
  health: '󰓦',
79
+ git: '󰊢',
77
80
  runFile: '󰈔',
78
81
  activeFile: '󰜴',
79
82
  groupCollapsed: '󰐕',
@@ -1120,7 +1123,47 @@ function getPanelTitles(flow, snapshot) {
1120
1123
  files: 'Run Files',
1121
1124
  pending: 'Pending Queue',
1122
1125
  completed: 'Recent Completed Runs',
1123
- otherWorktrees: 'Other Worktrees: Active Runs'
1126
+ otherWorktrees: 'Other Worktrees: Active Runs',
1127
+ git: 'Git Changes'
1128
+ };
1129
+ }
1130
+
1131
+ function getGitChangesSnapshot(snapshot) {
1132
+ const gitChanges = snapshot?.gitChanges;
1133
+ if (!gitChanges || typeof gitChanges !== 'object') {
1134
+ return {
1135
+ available: false,
1136
+ branch: '(unavailable)',
1137
+ upstream: null,
1138
+ ahead: 0,
1139
+ behind: 0,
1140
+ counts: {
1141
+ total: 0,
1142
+ staged: 0,
1143
+ unstaged: 0,
1144
+ untracked: 0,
1145
+ conflicted: 0
1146
+ },
1147
+ staged: [],
1148
+ unstaged: [],
1149
+ untracked: [],
1150
+ conflicted: [],
1151
+ clean: true
1152
+ };
1153
+ }
1154
+ return {
1155
+ ...gitChanges,
1156
+ counts: gitChanges.counts || {
1157
+ total: 0,
1158
+ staged: 0,
1159
+ unstaged: 0,
1160
+ untracked: 0,
1161
+ conflicted: 0
1162
+ },
1163
+ staged: Array.isArray(gitChanges.staged) ? gitChanges.staged : [],
1164
+ unstaged: Array.isArray(gitChanges.unstaged) ? gitChanges.unstaged : [],
1165
+ untracked: Array.isArray(gitChanges.untracked) ? gitChanges.untracked : [],
1166
+ conflicted: Array.isArray(gitChanges.conflicted) ? gitChanges.conflicted : []
1124
1167
  };
1125
1168
  }
1126
1169
 
@@ -1300,6 +1343,9 @@ function getSectionOrderForView(view, options = {}) {
1300
1343
  if (view === 'health') {
1301
1344
  return ['standards', 'stats', 'warnings', 'error-details'];
1302
1345
  }
1346
+ if (view === 'git') {
1347
+ return ['git-changes'];
1348
+ }
1303
1349
  const sections = [];
1304
1350
  if (includeWorktrees) {
1305
1351
  sections.push('worktrees');
@@ -1640,6 +1686,10 @@ function formatScope(scope) {
1640
1686
  if (scope === 'upcoming') return 'UPNEXT';
1641
1687
  if (scope === 'completed') return 'DONE';
1642
1688
  if (scope === 'intent') return 'INTENT';
1689
+ if (scope === 'staged') return 'STAGED';
1690
+ if (scope === 'unstaged') return 'UNSTAGED';
1691
+ if (scope === 'untracked') return 'UNTRACKED';
1692
+ if (scope === 'conflicted') return 'CONFLICT';
1643
1693
  return 'FILE';
1644
1694
  }
1645
1695
 
@@ -2054,9 +2104,15 @@ function collectAidlcIntentContextFiles(snapshot, intentId) {
2054
2104
  }
2055
2105
 
2056
2106
  function filterExistingFiles(files) {
2057
- return (Array.isArray(files) ? files : []).filter((file) =>
2058
- file && typeof file.path === 'string' && typeof file.label === 'string' && fileExists(file.path)
2059
- );
2107
+ return (Array.isArray(files) ? files : []).filter((file) => {
2108
+ if (!file || typeof file.path !== 'string' || typeof file.label !== 'string') {
2109
+ return false;
2110
+ }
2111
+ if (file.allowMissing === true) {
2112
+ return true;
2113
+ }
2114
+ return fileExists(file.path);
2115
+ });
2060
2116
  }
2061
2117
 
2062
2118
  function buildPendingGroups(snapshot, flow) {
@@ -2191,6 +2247,49 @@ function buildCompletedGroups(snapshot, flow) {
2191
2247
  return groups;
2192
2248
  }
2193
2249
 
2250
+ function buildGitChangeGroups(snapshot) {
2251
+ const git = getGitChangesSnapshot(snapshot);
2252
+
2253
+ if (!git.available) {
2254
+ return [];
2255
+ }
2256
+
2257
+ const makeFiles = (items, scope) => items.map((item) => ({
2258
+ label: item.relativePath,
2259
+ path: item.path || path.join(git.rootPath || snapshot?.workspacePath || '', item.relativePath || ''),
2260
+ scope,
2261
+ allowMissing: true,
2262
+ previewType: 'git-diff',
2263
+ repoRoot: git.rootPath || snapshot?.workspacePath || '',
2264
+ relativePath: item.relativePath || '',
2265
+ bucket: item.bucket || scope
2266
+ }));
2267
+
2268
+ const groups = [];
2269
+ groups.push({
2270
+ key: 'git:staged',
2271
+ label: `staged (${git.counts.staged || 0})`,
2272
+ files: makeFiles(git.staged, 'staged')
2273
+ });
2274
+ groups.push({
2275
+ key: 'git:unstaged',
2276
+ label: `unstaged (${git.counts.unstaged || 0})`,
2277
+ files: makeFiles(git.unstaged, 'unstaged')
2278
+ });
2279
+ groups.push({
2280
+ key: 'git:untracked',
2281
+ label: `untracked (${git.counts.untracked || 0})`,
2282
+ files: makeFiles(git.untracked, 'untracked')
2283
+ });
2284
+ groups.push({
2285
+ key: 'git:conflicted',
2286
+ label: `conflicts (${git.counts.conflicted || 0})`,
2287
+ files: makeFiles(git.conflicted, 'conflicted')
2288
+ });
2289
+
2290
+ return groups;
2291
+ }
2292
+
2194
2293
  function toExpandableRows(groups, emptyLabel, expandedGroups) {
2195
2294
  if (!Array.isArray(groups) || groups.length === 0) {
2196
2295
  return [{
@@ -2221,12 +2320,16 @@ function toExpandableRows(groups, emptyLabel, expandedGroups) {
2221
2320
  for (let index = 0; index < files.length; index += 1) {
2222
2321
  const file = files[index];
2223
2322
  rows.push({
2224
- kind: 'file',
2323
+ kind: file.previewType === 'git-diff' ? 'git-file' : 'file',
2225
2324
  key: `${group.key}:file:${file.path}:${index}`,
2226
2325
  label: file.label,
2227
2326
  path: file.path,
2228
2327
  scope: file.scope || 'file',
2229
- selectable: true
2328
+ selectable: true,
2329
+ previewType: file.previewType,
2330
+ repoRoot: file.repoRoot,
2331
+ relativePath: file.relativePath,
2332
+ bucket: file.bucket
2230
2333
  });
2231
2334
  }
2232
2335
  }
@@ -2261,7 +2364,7 @@ function buildInteractiveRowsLines(rows, selectedIndex, icons, width, isFocusedS
2261
2364
  };
2262
2365
  }
2263
2366
 
2264
- if (row.kind === 'file') {
2367
+ if (row.kind === 'file' || row.kind === 'git-file') {
2265
2368
  const scope = row.scope ? `[${formatScope(row.scope)}] ` : '';
2266
2369
  return {
2267
2370
  text: truncate(`${cursor} ${icons.runFile} ${scope}${row.label}`, width),
@@ -2298,13 +2401,17 @@ function getSelectedRow(rows, selectedIndex) {
2298
2401
  }
2299
2402
 
2300
2403
  function rowToFileEntry(row) {
2301
- if (!row || row.kind !== 'file' || typeof row.path !== 'string') {
2404
+ if (!row || (row.kind !== 'file' && row.kind !== 'git-file') || typeof row.path !== 'string') {
2302
2405
  return null;
2303
2406
  }
2304
2407
  return {
2305
2408
  label: row.label || path.basename(row.path),
2306
2409
  path: row.path,
2307
- scope: row.scope || 'file'
2410
+ scope: row.scope || 'file',
2411
+ previewType: row.previewType,
2412
+ repoRoot: row.repoRoot,
2413
+ relativePath: row.relativePath,
2414
+ bucket: row.bucket
2308
2415
  };
2309
2416
  }
2310
2417
 
@@ -2401,9 +2508,9 @@ function buildQuickHelpText(view, options = {}) {
2401
2508
  const isSimple = String(flow || '').toLowerCase() === 'simple';
2402
2509
  const activeLabel = isAidlc ? 'active bolt' : (isSimple ? 'active spec' : 'active run');
2403
2510
 
2404
- const parts = ['1/2/3/4 tabs', 'g/G sections'];
2511
+ const parts = ['1/2/3/4/5 tabs', 'g/G sections'];
2405
2512
 
2406
- if (view === 'runs' || view === 'intents' || view === 'completed' || view === 'health') {
2513
+ if (view === 'runs' || view === 'intents' || view === 'completed' || view === 'health' || view === 'git') {
2407
2514
  if (previewOpen) {
2408
2515
  parts.push('tab pane', '↑/↓ nav/scroll', 'v/space close');
2409
2516
  } else {
@@ -2445,7 +2552,7 @@ function buildHelpOverlayLines(options = {}) {
2445
2552
  { text: 'Global', color: 'cyan', bold: true },
2446
2553
  'q or Ctrl+C quit',
2447
2554
  'r refresh snapshot',
2448
- `1 active ${itemLabel} | 2 intents | 3 completed ${itemPlural} | 4 standards/health`,
2555
+ `1 active ${itemLabel} | 2 intents | 3 completed ${itemPlural} | 4 standards/health | 5 git changes`,
2449
2556
  'g next section | G previous section',
2450
2557
  'h/? toggle this shortcuts overlay',
2451
2558
  'esc close overlays (help/preview/fullscreen)',
@@ -2483,6 +2590,9 @@ function buildHelpOverlayLines(options = {}) {
2483
2590
  { text: 'Tab 4 Standards/Health', color: 'magenta', bold: true },
2484
2591
  `s standards | t stats | w warnings${showErrorSection ? ' | e errors' : ''}`,
2485
2592
  { text: '', color: undefined, bold: false },
2593
+ { text: 'Tab 5 Git Changes', color: 'yellow', bold: true },
2594
+ 'select changed files and preview diffs',
2595
+ { text: '', color: undefined, bold: false },
2486
2596
  { text: `Current view: ${String(view || 'runs').toUpperCase()}`, color: 'gray', bold: false }
2487
2597
  );
2488
2598
 
@@ -2601,20 +2711,27 @@ function buildPreviewLines(fileEntry, width, scrollOffset, options = {}) {
2601
2711
  return [{ text: truncate('No file selected', width), color: 'gray', bold: false }];
2602
2712
  }
2603
2713
 
2604
- let content;
2605
- try {
2606
- content = fs.readFileSync(fileEntry.path, 'utf8');
2607
- } catch (error) {
2608
- return [{
2609
- text: truncate(`Unable to read ${fileEntry.label || fileEntry.path}: ${error.message}`, width),
2610
- color: 'red',
2611
- bold: false
2612
- }];
2714
+ const isGitPreview = fileEntry.previewType === 'git-diff';
2715
+ let rawLines = [];
2716
+ if (isGitPreview) {
2717
+ const diffText = loadGitDiffPreview(fileEntry);
2718
+ rawLines = String(diffText || '').split(/\r?\n/);
2719
+ } else {
2720
+ let content;
2721
+ try {
2722
+ content = fs.readFileSync(fileEntry.path, 'utf8');
2723
+ } catch (error) {
2724
+ return [{
2725
+ text: truncate(`Unable to read ${fileEntry.label || fileEntry.path}: ${error.message}`, width),
2726
+ color: 'red',
2727
+ bold: false
2728
+ }];
2729
+ }
2730
+ rawLines = String(content).split(/\r?\n/);
2613
2731
  }
2614
2732
 
2615
- const rawLines = String(content).split(/\r?\n/);
2616
2733
  const headLine = {
2617
- text: truncate(`file: ${fileEntry.path}`, width),
2734
+ text: truncate(`${isGitPreview ? 'diff' : 'file'}: ${fileEntry.path}`, width),
2618
2735
  color: 'cyan',
2619
2736
  bold: true
2620
2737
  };
@@ -2625,7 +2742,34 @@ function buildPreviewLines(fileEntry, width, scrollOffset, options = {}) {
2625
2742
 
2626
2743
  const highlighted = cappedLines.map((rawLine, index) => {
2627
2744
  const prefixedLine = `${String(index + 1).padStart(4, ' ')} | ${rawLine}`;
2628
- const { color, bold, togglesCodeBlock } = colorizeMarkdownLine(rawLine, inCodeBlock);
2745
+ let color;
2746
+ let bold;
2747
+ let togglesCodeBlock = false;
2748
+
2749
+ if (isGitPreview) {
2750
+ if (rawLine.startsWith('+++ ') || rawLine.startsWith('--- ') || rawLine.startsWith('diff --git')) {
2751
+ color = 'cyan';
2752
+ bold = true;
2753
+ } else if (rawLine.startsWith('@@')) {
2754
+ color = 'magenta';
2755
+ bold = true;
2756
+ } else if (rawLine.startsWith('+')) {
2757
+ color = 'green';
2758
+ bold = false;
2759
+ } else if (rawLine.startsWith('-')) {
2760
+ color = 'red';
2761
+ bold = false;
2762
+ } else {
2763
+ color = undefined;
2764
+ bold = false;
2765
+ }
2766
+ } else {
2767
+ const markdownStyle = colorizeMarkdownLine(rawLine, inCodeBlock);
2768
+ color = markdownStyle.color;
2769
+ bold = markdownStyle.bold;
2770
+ togglesCodeBlock = markdownStyle.togglesCodeBlock;
2771
+ }
2772
+
2629
2773
  if (togglesCodeBlock) {
2630
2774
  inCodeBlock = !inCodeBlock;
2631
2775
  }
@@ -2772,7 +2916,8 @@ function createDashboardApp(deps) {
2772
2916
  { id: 'runs', label: `1 ${icons.runs} ${primaryLabel}` },
2773
2917
  { id: 'intents', label: `2 ${icons.overview} INTENTS` },
2774
2918
  { id: 'completed', label: `3 ${icons.runs} ${completedLabel}` },
2775
- { id: 'health', label: `4 ${icons.health} STANDARDS/HEALTH` }
2919
+ { id: 'health', label: `4 ${icons.health} STANDARDS/HEALTH` },
2920
+ { id: 'git', label: `5 ${icons.git} GIT CHANGES` }
2776
2921
  ];
2777
2922
  const maxWidth = Math.max(8, Math.floor(width));
2778
2923
  const segments = [];
@@ -2902,7 +3047,8 @@ function createDashboardApp(deps) {
2902
3047
  runs: 'current-run',
2903
3048
  intents: 'intent-status',
2904
3049
  completed: 'completed-runs',
2905
- health: 'standards'
3050
+ health: 'standards',
3051
+ git: 'git-changes'
2906
3052
  });
2907
3053
  const [selectionBySection, setSelectionBySection] = useState({
2908
3054
  worktrees: 0,
@@ -2914,7 +3060,8 @@ function createDashboardApp(deps) {
2914
3060
  standards: 0,
2915
3061
  stats: 0,
2916
3062
  warnings: 0,
2917
- 'error-details': 0
3063
+ 'error-details': 0,
3064
+ 'git-changes': 0
2918
3065
  });
2919
3066
  const [expandedGroups, setExpandedGroups] = useState({});
2920
3067
  const [previewTarget, setPreviewTarget] = useState(null);
@@ -3068,6 +3215,37 @@ function createDashboardApp(deps) {
3068
3215
  'No error details'
3069
3216
  )
3070
3217
  : toLoadingRows('Loading error details...', 'error-loading');
3218
+ const gitRows = shouldHydrateSecondaryTabs
3219
+ ? (() => {
3220
+ const git = getGitChangesSnapshot(snapshot);
3221
+ const tracking = git.upstream
3222
+ ? `${git.upstream} (${git.ahead > 0 ? `ahead ${git.ahead}` : 'ahead 0'}, ${git.behind > 0 ? `behind ${git.behind}` : 'behind 0'})`
3223
+ : 'no upstream';
3224
+ const headerRows = [{
3225
+ kind: 'info',
3226
+ key: 'git:branch',
3227
+ label: git.available
3228
+ ? `branch ${git.branch}${git.detached ? ' [detached]' : ''} | ${tracking}`
3229
+ : 'git: repository unavailable in selected worktree',
3230
+ color: git.available ? 'cyan' : 'red',
3231
+ bold: true,
3232
+ selectable: false
3233
+ }, {
3234
+ kind: 'info',
3235
+ key: 'git:counts',
3236
+ label: `changes ${git.counts.total || 0} | staged ${git.counts.staged || 0} | unstaged ${git.counts.unstaged || 0} | untracked ${git.counts.untracked || 0} | conflicts ${git.counts.conflicted || 0}`,
3237
+ color: 'gray',
3238
+ bold: false,
3239
+ selectable: false
3240
+ }];
3241
+ const groups = toExpandableRows(
3242
+ buildGitChangeGroups(snapshot),
3243
+ git.available ? 'Working tree clean' : 'No git changes',
3244
+ expandedGroups
3245
+ );
3246
+ return [...headerRows, ...groups];
3247
+ })()
3248
+ : toLoadingRows('Loading git changes...', 'git-loading');
3071
3249
 
3072
3250
  const rowsBySection = {
3073
3251
  worktrees: worktreeRows,
@@ -3079,7 +3257,8 @@ function createDashboardApp(deps) {
3079
3257
  standards: standardsRows,
3080
3258
  stats: statsRows,
3081
3259
  warnings: warningsRows,
3082
- 'error-details': errorDetailsRows
3260
+ 'error-details': errorDetailsRows,
3261
+ 'git-changes': gitRows
3083
3262
  };
3084
3263
  const worktreeItems = getWorktreeItems(snapshot);
3085
3264
  const selectedWorktree = getSelectedWorktree(snapshot);
@@ -3269,6 +3448,12 @@ function createDashboardApp(deps) {
3269
3448
  return;
3270
3449
  }
3271
3450
 
3451
+ if (input === '5') {
3452
+ setUi((previous) => ({ ...previous, view: 'git' }));
3453
+ setPaneFocus('main');
3454
+ return;
3455
+ }
3456
+
3272
3457
  if ((input === ']' || input === 'm') && availableFlowIds.length > 1) {
3273
3458
  snapshotHashRef.current = safeJsonHash(null);
3274
3459
  errorHashRef.current = null;
@@ -3291,13 +3476,15 @@ function createDashboardApp(deps) {
3291
3476
  standards: 0,
3292
3477
  stats: 0,
3293
3478
  warnings: 0,
3294
- 'error-details': 0
3479
+ 'error-details': 0,
3480
+ 'git-changes': 0
3295
3481
  });
3296
3482
  setSectionFocus({
3297
3483
  runs: 'current-run',
3298
3484
  intents: 'intent-status',
3299
3485
  completed: 'completed-runs',
3300
- health: 'standards'
3486
+ health: 'standards',
3487
+ git: 'git-changes'
3301
3488
  });
3302
3489
  setOverviewIntentFilter('next');
3303
3490
  setExpandedGroups({});
@@ -3332,13 +3519,15 @@ function createDashboardApp(deps) {
3332
3519
  standards: 0,
3333
3520
  stats: 0,
3334
3521
  warnings: 0,
3335
- 'error-details': 0
3522
+ 'error-details': 0,
3523
+ 'git-changes': 0
3336
3524
  });
3337
3525
  setSectionFocus({
3338
3526
  runs: 'current-run',
3339
3527
  intents: 'intent-status',
3340
3528
  completed: 'completed-runs',
3341
- health: 'standards'
3529
+ health: 'standards',
3530
+ git: 'git-changes'
3342
3531
  });
3343
3532
  setOverviewIntentFilter('next');
3344
3533
  setExpandedGroups({});
@@ -3442,6 +3631,11 @@ function createDashboardApp(deps) {
3442
3631
  setSectionFocus((previous) => ({ ...previous, health: 'error-details' }));
3443
3632
  return;
3444
3633
  }
3634
+ } else if (ui.view === 'git') {
3635
+ if (input === 'd') {
3636
+ setSectionFocus((previous) => ({ ...previous, git: 'git-changes' }));
3637
+ return;
3638
+ }
3445
3639
  }
3446
3640
 
3447
3641
  if (key.escape) {
@@ -3717,7 +3911,9 @@ function createDashboardApp(deps) {
3717
3911
  });
3718
3912
 
3719
3913
  runtime.start();
3720
- const fallbackIntervalMs = Math.max(refreshMs, 5000);
3914
+ const fallbackIntervalMs = ui.view === 'git'
3915
+ ? Math.max(refreshMs, 1000)
3916
+ : Math.max(refreshMs, 5000);
3721
3917
  const interval = setInterval(() => {
3722
3918
  void refresh();
3723
3919
  }, fallbackIntervalMs);
@@ -3726,7 +3922,7 @@ function createDashboardApp(deps) {
3726
3922
  clearInterval(interval);
3727
3923
  void runtime.close();
3728
3924
  };
3729
- }, [watchEnabled, refreshMs, refresh, rootPath, workspacePath, resolveRootPathForFlow, resolveRootPathsForFlow, activeFlow, worktreeWatchSignature, selectedWorktreeId]);
3925
+ }, [watchEnabled, refreshMs, refresh, rootPath, workspacePath, resolveRootPathForFlow, resolveRootPathsForFlow, activeFlow, ui.view, worktreeWatchSignature, selectedWorktreeId]);
3730
3926
 
3731
3927
  useEffect(() => {
3732
3928
  if (!stdout || typeof stdout.write !== 'function') {
@@ -3885,6 +4081,15 @@ function createDashboardApp(deps) {
3885
4081
  borderColor: 'red'
3886
4082
  });
3887
4083
  }
4084
+ } else if (ui.view === 'git') {
4085
+ panelCandidates = [
4086
+ {
4087
+ key: 'git-changes',
4088
+ title: panelTitles.git || 'Git Changes',
4089
+ lines: sectionLines['git-changes'],
4090
+ borderColor: 'yellow'
4091
+ }
4092
+ ];
3888
4093
  } else {
3889
4094
  panelCandidates = [];
3890
4095
  if (worktreeSectionEnabled) {
@@ -15,12 +15,15 @@ function cycleView(current) {
15
15
  if (current === 'completed') {
16
16
  return 'health';
17
17
  }
18
+ if (current === 'health') {
19
+ return 'git';
20
+ }
18
21
  return 'runs';
19
22
  }
20
23
 
21
24
  function cycleViewBackward(current) {
22
25
  if (current === 'runs') {
23
- return 'health';
26
+ return 'git';
24
27
  }
25
28
  if (current === 'intents') {
26
29
  return 'runs';
@@ -28,7 +31,10 @@ function cycleViewBackward(current) {
28
31
  if (current === 'completed') {
29
32
  return 'intents';
30
33
  }
31
- return 'completed';
34
+ if (current === 'health') {
35
+ return 'completed';
36
+ }
37
+ return 'health';
32
38
  }
33
39
 
34
40
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specsmd",
3
- "version": "0.1.51",
3
+ "version": "0.1.52",
4
4
  "description": "Multi-agent orchestration system for AI-native software development. Delivers AI-DLC, Agile, and custom SDLC flows as markdown-based agent systems.",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {