specsmd 0.1.50 → 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 {
@@ -2415,9 +2522,6 @@ function buildQuickHelpText(view, options = {}) {
2415
2522
  parts.push('b worktrees', 'u others');
2416
2523
  }
2417
2524
  parts.push('a current', 'f files');
2418
- if (hasWorktrees) {
2419
- parts.push('w worktree');
2420
- }
2421
2525
  }
2422
2526
  parts.push(`tab1 ${activeLabel}`);
2423
2527
 
@@ -2448,7 +2552,7 @@ function buildHelpOverlayLines(options = {}) {
2448
2552
  { text: 'Global', color: 'cyan', bold: true },
2449
2553
  'q or Ctrl+C quit',
2450
2554
  'r refresh snapshot',
2451
- `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`,
2452
2556
  'g next section | G previous section',
2453
2557
  'h/? toggle this shortcuts overlay',
2454
2558
  'esc close overlays (help/preview/fullscreen)',
@@ -2457,7 +2561,6 @@ function buildHelpOverlayLines(options = {}) {
2457
2561
  ...(hasWorktrees ? ['b focus worktrees section', 'u focus other-worktrees section'] : []),
2458
2562
  `a focus active ${itemLabel}`,
2459
2563
  `f focus ${itemLabel} files`,
2460
- ...(hasWorktrees ? ['w open worktree switcher'] : []),
2461
2564
  'up/down or j/k move selection',
2462
2565
  'enter expand/collapse selected folder row',
2463
2566
  'v or space preview selected file',
@@ -2487,6 +2590,9 @@ function buildHelpOverlayLines(options = {}) {
2487
2590
  { text: 'Tab 4 Standards/Health', color: 'magenta', bold: true },
2488
2591
  `s standards | t stats | w warnings${showErrorSection ? ' | e errors' : ''}`,
2489
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 },
2490
2596
  { text: `Current view: ${String(view || 'runs').toUpperCase()}`, color: 'gray', bold: false }
2491
2597
  );
2492
2598
 
@@ -2605,20 +2711,27 @@ function buildPreviewLines(fileEntry, width, scrollOffset, options = {}) {
2605
2711
  return [{ text: truncate('No file selected', width), color: 'gray', bold: false }];
2606
2712
  }
2607
2713
 
2608
- let content;
2609
- try {
2610
- content = fs.readFileSync(fileEntry.path, 'utf8');
2611
- } catch (error) {
2612
- return [{
2613
- text: truncate(`Unable to read ${fileEntry.label || fileEntry.path}: ${error.message}`, width),
2614
- color: 'red',
2615
- bold: false
2616
- }];
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/);
2617
2731
  }
2618
2732
 
2619
- const rawLines = String(content).split(/\r?\n/);
2620
2733
  const headLine = {
2621
- text: truncate(`file: ${fileEntry.path}`, width),
2734
+ text: truncate(`${isGitPreview ? 'diff' : 'file'}: ${fileEntry.path}`, width),
2622
2735
  color: 'cyan',
2623
2736
  bold: true
2624
2737
  };
@@ -2629,7 +2742,34 @@ function buildPreviewLines(fileEntry, width, scrollOffset, options = {}) {
2629
2742
 
2630
2743
  const highlighted = cappedLines.map((rawLine, index) => {
2631
2744
  const prefixedLine = `${String(index + 1).padStart(4, ' ')} | ${rawLine}`;
2632
- 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
+
2633
2773
  if (togglesCodeBlock) {
2634
2774
  inCodeBlock = !inCodeBlock;
2635
2775
  }
@@ -2776,7 +2916,8 @@ function createDashboardApp(deps) {
2776
2916
  { id: 'runs', label: `1 ${icons.runs} ${primaryLabel}` },
2777
2917
  { id: 'intents', label: `2 ${icons.overview} INTENTS` },
2778
2918
  { id: 'completed', label: `3 ${icons.runs} ${completedLabel}` },
2779
- { 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` }
2780
2921
  ];
2781
2922
  const maxWidth = Math.max(8, Math.floor(width));
2782
2923
  const segments = [];
@@ -2906,7 +3047,8 @@ function createDashboardApp(deps) {
2906
3047
  runs: 'current-run',
2907
3048
  intents: 'intent-status',
2908
3049
  completed: 'completed-runs',
2909
- health: 'standards'
3050
+ health: 'standards',
3051
+ git: 'git-changes'
2910
3052
  });
2911
3053
  const [selectionBySection, setSelectionBySection] = useState({
2912
3054
  worktrees: 0,
@@ -2918,7 +3060,8 @@ function createDashboardApp(deps) {
2918
3060
  standards: 0,
2919
3061
  stats: 0,
2920
3062
  warnings: 0,
2921
- 'error-details': 0
3063
+ 'error-details': 0,
3064
+ 'git-changes': 0
2922
3065
  });
2923
3066
  const [expandedGroups, setExpandedGroups] = useState({});
2924
3067
  const [previewTarget, setPreviewTarget] = useState(null);
@@ -2960,9 +3103,7 @@ function createDashboardApp(deps) {
2960
3103
  const previewVisibleRows = Number.isFinite(terminalSize.rows) ? terminalSize.rows : (process.stdout.rows || 40);
2961
3104
  const showErrorPanelForSections = Boolean(error) && previewVisibleRows >= 18;
2962
3105
  const worktreeSectionEnabled = hasMultipleWorktrees(snapshot);
2963
- const otherWorktreesSectionEnabled = worktreeSectionEnabled
2964
- && isSelectedWorktreeMain(snapshot)
2965
- && getEffectiveFlow(activeFlow, snapshot) !== 'simple';
3106
+ const otherWorktreesSectionEnabled = worktreeSectionEnabled;
2966
3107
 
2967
3108
  const getAvailableSections = useCallback((viewId) => {
2968
3109
  const base = getSectionOrderForView(viewId, {
@@ -3074,6 +3215,37 @@ function createDashboardApp(deps) {
3074
3215
  'No error details'
3075
3216
  )
3076
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');
3077
3249
 
3078
3250
  const rowsBySection = {
3079
3251
  worktrees: worktreeRows,
@@ -3085,7 +3257,8 @@ function createDashboardApp(deps) {
3085
3257
  standards: standardsRows,
3086
3258
  stats: statsRows,
3087
3259
  warnings: warningsRows,
3088
- 'error-details': errorDetailsRows
3260
+ 'error-details': errorDetailsRows,
3261
+ 'git-changes': gitRows
3089
3262
  };
3090
3263
  const worktreeItems = getWorktreeItems(snapshot);
3091
3264
  const selectedWorktree = getSelectedWorktree(snapshot);
@@ -3275,9 +3448,8 @@ function createDashboardApp(deps) {
3275
3448
  return;
3276
3449
  }
3277
3450
 
3278
- if (ui.view === 'runs' && input === 'w' && worktreeSectionEnabled) {
3279
- setWorktreeOverlayIndex(clampIndex(worktreeItems.findIndex((item) => item.id === selectedWorktreeId), worktreeItems.length || 1));
3280
- setWorktreeOverlayOpen(true);
3451
+ if (input === '5') {
3452
+ setUi((previous) => ({ ...previous, view: 'git' }));
3281
3453
  setPaneFocus('main');
3282
3454
  return;
3283
3455
  }
@@ -3304,13 +3476,15 @@ function createDashboardApp(deps) {
3304
3476
  standards: 0,
3305
3477
  stats: 0,
3306
3478
  warnings: 0,
3307
- 'error-details': 0
3479
+ 'error-details': 0,
3480
+ 'git-changes': 0
3308
3481
  });
3309
3482
  setSectionFocus({
3310
3483
  runs: 'current-run',
3311
3484
  intents: 'intent-status',
3312
3485
  completed: 'completed-runs',
3313
- health: 'standards'
3486
+ health: 'standards',
3487
+ git: 'git-changes'
3314
3488
  });
3315
3489
  setOverviewIntentFilter('next');
3316
3490
  setExpandedGroups({});
@@ -3345,13 +3519,15 @@ function createDashboardApp(deps) {
3345
3519
  standards: 0,
3346
3520
  stats: 0,
3347
3521
  warnings: 0,
3348
- 'error-details': 0
3522
+ 'error-details': 0,
3523
+ 'git-changes': 0
3349
3524
  });
3350
3525
  setSectionFocus({
3351
3526
  runs: 'current-run',
3352
3527
  intents: 'intent-status',
3353
3528
  completed: 'completed-runs',
3354
- health: 'standards'
3529
+ health: 'standards',
3530
+ git: 'git-changes'
3355
3531
  });
3356
3532
  setOverviewIntentFilter('next');
3357
3533
  setExpandedGroups({});
@@ -3455,6 +3631,11 @@ function createDashboardApp(deps) {
3455
3631
  setSectionFocus((previous) => ({ ...previous, health: 'error-details' }));
3456
3632
  return;
3457
3633
  }
3634
+ } else if (ui.view === 'git') {
3635
+ if (input === 'd') {
3636
+ setSectionFocus((previous) => ({ ...previous, git: 'git-changes' }));
3637
+ return;
3638
+ }
3458
3639
  }
3459
3640
 
3460
3641
  if (key.escape) {
@@ -3730,7 +3911,9 @@ function createDashboardApp(deps) {
3730
3911
  });
3731
3912
 
3732
3913
  runtime.start();
3733
- const fallbackIntervalMs = Math.max(refreshMs, 5000);
3914
+ const fallbackIntervalMs = ui.view === 'git'
3915
+ ? Math.max(refreshMs, 1000)
3916
+ : Math.max(refreshMs, 5000);
3734
3917
  const interval = setInterval(() => {
3735
3918
  void refresh();
3736
3919
  }, fallbackIntervalMs);
@@ -3739,7 +3922,7 @@ function createDashboardApp(deps) {
3739
3922
  clearInterval(interval);
3740
3923
  void runtime.close();
3741
3924
  };
3742
- }, [watchEnabled, refreshMs, refresh, rootPath, workspacePath, resolveRootPathForFlow, resolveRootPathsForFlow, activeFlow, worktreeWatchSignature, selectedWorktreeId]);
3925
+ }, [watchEnabled, refreshMs, refresh, rootPath, workspacePath, resolveRootPathForFlow, resolveRootPathsForFlow, activeFlow, ui.view, worktreeWatchSignature, selectedWorktreeId]);
3743
3926
 
3744
3927
  useEffect(() => {
3745
3928
  if (!stdout || typeof stdout.write !== 'function') {
@@ -3898,6 +4081,15 @@ function createDashboardApp(deps) {
3898
4081
  borderColor: 'red'
3899
4082
  });
3900
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
+ ];
3901
4093
  } else {
3902
4094
  panelCandidates = [];
3903
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.50",
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": {