specsmd 0.1.55 → 0.1.57

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.
@@ -324,7 +324,39 @@ function loadGitDiffPreview(changeEntry) {
324
324
  return result.stdout;
325
325
  }
326
326
 
327
+ function loadGitCommitPreview(changeEntry) {
328
+ const repoRoot = typeof changeEntry?.repoRoot === 'string'
329
+ ? changeEntry.repoRoot
330
+ : (typeof changeEntry?.workspacePath === 'string' ? findGitRoot(changeEntry.workspacePath) : null);
331
+ const commitHash = typeof changeEntry?.commitHash === 'string'
332
+ ? changeEntry.commitHash.trim()
333
+ : '';
334
+
335
+ if (!repoRoot) {
336
+ return '[git] repository is unavailable for commit preview.';
337
+ }
338
+ if (commitHash === '') {
339
+ return '[git] no commit selected.';
340
+ }
341
+
342
+ const result = runGit(
343
+ ['-c', 'color.ui=false', '--no-pager', 'show', '--patch', '--stat', '--no-ext-diff', commitHash],
344
+ repoRoot
345
+ );
346
+ if (!result.ok) {
347
+ return `[git] unable to load commit diff: ${result.error}`;
348
+ }
349
+
350
+ const output = result.stdout.trim();
351
+ if (output === '') {
352
+ return '[git] no commit output for this selection.';
353
+ }
354
+
355
+ return result.stdout;
356
+ }
357
+
327
358
  module.exports = {
328
359
  listGitChanges,
329
- loadGitDiffPreview
360
+ loadGitDiffPreview,
361
+ loadGitCommitPreview
330
362
  };
@@ -5,7 +5,6 @@ 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');
9
8
 
10
9
  const stringWidth = typeof stringWidthModule === 'function'
11
10
  ? stringWidthModule
@@ -13,6 +12,10 @@ const stringWidth = typeof stringWidthModule === 'function'
13
12
  const sliceAnsi = typeof sliceAnsiModule === 'function'
14
13
  ? sliceAnsiModule
15
14
  : sliceAnsiModule.default;
15
+ const {
16
+ loadGitDiffPreview,
17
+ loadGitCommitPreview
18
+ } = require('../git/changes');
16
19
 
17
20
  function toDashboardError(error, defaultCode = 'DASHBOARD_ERROR') {
18
21
  if (!error) {
@@ -1250,135 +1253,51 @@ function buildGitStatusPanelLines(snapshot) {
1250
1253
  ];
1251
1254
  }
1252
1255
 
1253
- function buildGitBranchesPanelLines(snapshot) {
1256
+ function buildGitCommitRows(snapshot) {
1254
1257
  const git = getGitChangesSnapshot(snapshot);
1255
1258
  if (!git.available) {
1256
1259
  return [{
1257
- text: 'No branch list (git unavailable)',
1258
- color: 'gray',
1259
- bold: false
1260
- }];
1261
- }
1262
-
1263
- const branchLines = readGitCommandLines(git.rootPath, [
1264
- '-c',
1265
- 'color.ui=false',
1266
- 'for-each-ref',
1267
- '--sort=-committerdate',
1268
- '--format=%(if)%(HEAD)%(then)*%(else) %(end) %(refname:short)',
1269
- 'refs/heads'
1270
- ], { limit: 8 });
1271
-
1272
- const lines = [{
1273
- text: 'Local branches · Remotes · Tags',
1274
- color: 'cyan',
1275
- bold: true
1276
- }];
1277
-
1278
- if (branchLines.length === 0) {
1279
- lines.push({
1280
- text: 'No local branches found',
1281
- color: 'gray',
1282
- bold: false
1283
- });
1284
- return lines;
1285
- }
1286
-
1287
- for (const line of branchLines) {
1288
- const isCurrent = line.startsWith('*');
1289
- lines.push({
1290
- text: line,
1291
- color: isCurrent ? 'green' : 'gray',
1292
- bold: isCurrent
1293
- });
1294
- }
1295
-
1296
- return lines;
1297
- }
1298
-
1299
- function buildGitCommitsPanelLines(snapshot) {
1300
- const git = getGitChangesSnapshot(snapshot);
1301
- if (!git.available) {
1302
- return [{
1303
- text: 'No commit log (git unavailable)',
1304
- color: 'gray',
1305
- bold: false
1260
+ kind: 'info',
1261
+ key: 'git:commits:unavailable',
1262
+ label: 'No commit history (git unavailable)',
1263
+ selectable: false
1306
1264
  }];
1307
1265
  }
1308
1266
 
1309
- const reflogLines = readGitCommandLines(git.rootPath, [
1267
+ const commitLines = readGitCommandLines(git.rootPath, [
1310
1268
  '-c',
1311
1269
  'color.ui=false',
1312
- 'reflog',
1270
+ 'log',
1313
1271
  '--date=relative',
1314
- '--pretty=format:%h %gs'
1315
- ], { limit: 8 });
1316
-
1317
- const lines = [{
1318
- text: 'Commits · Reflog',
1319
- color: 'cyan',
1320
- bold: true
1321
- }];
1322
-
1323
- if (reflogLines.length === 0) {
1324
- lines.push({
1325
- text: 'No reflog entries',
1326
- color: 'gray',
1327
- bold: false
1328
- });
1329
- return lines;
1330
- }
1331
-
1332
- lines.push(...reflogLines.map((line) => ({
1333
- text: line,
1334
- color: 'gray',
1335
- bold: false
1336
- })));
1337
- return lines;
1338
- }
1272
+ '--pretty=format:%h %s',
1273
+ '--max-count=30'
1274
+ ], { limit: 30 });
1339
1275
 
1340
- function buildGitStashPanelLines(snapshot) {
1341
- const git = getGitChangesSnapshot(snapshot);
1342
- if (!git.available) {
1276
+ if (commitLines.length === 0) {
1343
1277
  return [{
1344
- text: 'No stash info (git unavailable)',
1345
- color: 'gray',
1346
- bold: false
1278
+ kind: 'info',
1279
+ key: 'git:commits:empty',
1280
+ label: 'No commits found',
1281
+ selectable: false
1347
1282
  }];
1348
1283
  }
1349
1284
 
1350
- const stashLines = readGitCommandLines(git.rootPath, [
1351
- '-c',
1352
- 'color.ui=false',
1353
- 'stash',
1354
- 'list',
1355
- '--pretty=format:%gd %s'
1356
- ], {
1357
- acceptedStatuses: [0],
1358
- limit: 4
1359
- });
1360
-
1361
- const lines = [{
1362
- text: 'Stash',
1363
- color: 'cyan',
1364
- bold: true
1365
- }];
1366
-
1367
- if (stashLines.length === 0) {
1368
- lines.push({
1369
- text: 'No stashes',
1370
- color: 'gray',
1371
- bold: false
1372
- });
1373
- return lines;
1374
- }
1285
+ return commitLines.map((line, index) => {
1286
+ const firstSpace = line.indexOf(' ');
1287
+ const commitHash = firstSpace > 0 ? line.slice(0, firstSpace) : '';
1288
+ const message = firstSpace > 0 ? line.slice(firstSpace + 1) : line;
1289
+ const label = commitHash ? `${commitHash} ${message}` : message;
1375
1290
 
1376
- lines.push(...stashLines.map((line) => ({
1377
- text: line,
1378
- color: 'gray',
1379
- bold: false
1380
- })));
1381
- return lines;
1291
+ return {
1292
+ kind: 'git-commit',
1293
+ key: `git:commit:${commitHash || index}:${index}`,
1294
+ label,
1295
+ commitHash,
1296
+ repoRoot: git.rootPath,
1297
+ previewType: 'git-commit-diff',
1298
+ selectable: true
1299
+ };
1300
+ });
1382
1301
  }
1383
1302
 
1384
1303
  function getDashboardWorktreeMeta(snapshot) {
@@ -1558,7 +1477,7 @@ function getSectionOrderForView(view, options = {}) {
1558
1477
  return ['standards', 'stats', 'warnings', 'error-details'];
1559
1478
  }
1560
1479
  if (view === 'git') {
1561
- return ['git-status', 'git-changes', 'git-branches', 'git-commits', 'git-stash', 'git-diff'];
1480
+ return ['git-status', 'git-changes', 'git-commits', 'git-diff'];
1562
1481
  }
1563
1482
  const sections = [];
1564
1483
  if (includeWorktrees) {
@@ -2578,7 +2497,7 @@ function buildInteractiveRowsLines(rows, selectedIndex, icons, width, isFocusedS
2578
2497
  };
2579
2498
  }
2580
2499
 
2581
- if (row.kind === 'file' || row.kind === 'git-file') {
2500
+ if (row.kind === 'file' || row.kind === 'git-file' || row.kind === 'git-commit') {
2582
2501
  const scope = row.scope ? `[${formatScope(row.scope)}] ` : '';
2583
2502
  return {
2584
2503
  text: truncate(`${cursor} ${icons.runFile} ${scope}${row.label}`, width),
@@ -2615,7 +2534,26 @@ function getSelectedRow(rows, selectedIndex) {
2615
2534
  }
2616
2535
 
2617
2536
  function rowToFileEntry(row) {
2618
- if (!row || (row.kind !== 'file' && row.kind !== 'git-file') || typeof row.path !== 'string') {
2537
+ if (!row) {
2538
+ return null;
2539
+ }
2540
+
2541
+ if (row.kind === 'git-commit') {
2542
+ const commitHash = typeof row.commitHash === 'string' ? row.commitHash : '';
2543
+ if (commitHash === '') {
2544
+ return null;
2545
+ }
2546
+ return {
2547
+ label: row.label || commitHash,
2548
+ path: commitHash,
2549
+ scope: 'commit',
2550
+ previewType: row.previewType || 'git-commit-diff',
2551
+ repoRoot: row.repoRoot,
2552
+ commitHash
2553
+ };
2554
+ }
2555
+
2556
+ if ((row.kind !== 'file' && row.kind !== 'git-file') || typeof row.path !== 'string') {
2619
2557
  return null;
2620
2558
  }
2621
2559
  return {
@@ -2752,7 +2690,7 @@ function buildQuickHelpText(view, options = {}) {
2752
2690
  }
2753
2691
  parts.push('a current', 'f files');
2754
2692
  } else if (view === 'git') {
2755
- parts.push('6 status', '7 files', '8 branches', '9 commits', '0 stash', '- diff');
2693
+ parts.push('6 status', '7 files', '8 commits', '- diff');
2756
2694
  }
2757
2695
  parts.push(`tab1 ${activeLabel}`);
2758
2696
 
@@ -2784,7 +2722,7 @@ function buildGitCommandStrip(view, options = {}) {
2784
2722
  } else if (view === 'health') {
2785
2723
  parts.push('s standards', 't stats', 'w warnings');
2786
2724
  } else if (view === 'git') {
2787
- parts.push('6 status', '7 files', '8 branches', '9 commits', '0 stash', '- diff', 'space preview');
2725
+ parts.push('6 status', '7 files', '8 commits', '- diff', 'space preview');
2788
2726
  }
2789
2727
 
2790
2728
  if (previewOpen) {
@@ -2873,8 +2811,9 @@ function buildHelpOverlayLines(options = {}) {
2873
2811
  `s standards | t stats | w warnings${showErrorSection ? ' | e errors' : ''}`,
2874
2812
  { text: '', color: undefined, bold: false },
2875
2813
  { text: 'Tab 5 Git Changes', color: 'yellow', bold: true },
2876
- 'select changed files and preview diffs',
2877
- '6 status | 7 files | 8 branches | 9 commits | 0 stash | - diff',
2814
+ '7 files: select changed files and preview per-file diffs',
2815
+ '8 commits: select a commit to preview the full commit diff',
2816
+ '6 status | 7 files | 8 commits | - diff',
2878
2817
  { text: '', color: undefined, bold: false },
2879
2818
  { text: `Current view: ${String(view || 'runs').toUpperCase()}`, color: 'gray', bold: false }
2880
2819
  );
@@ -2987,6 +2926,12 @@ function colorizeMarkdownLine(line, inCodeBlock) {
2987
2926
  };
2988
2927
  }
2989
2928
 
2929
+ function sanitizeRenderLine(value) {
2930
+ const raw = String(value ?? '');
2931
+ const withoutAnsi = raw.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '');
2932
+ return withoutAnsi.replace(/[\u0000-\u0008\u000B-\u001A\u001C-\u001F\u007F]/g, '');
2933
+ }
2934
+
2990
2935
  function buildPreviewLines(fileEntry, width, scrollOffset, options = {}) {
2991
2936
  const fullDocument = options?.fullDocument === true;
2992
2937
 
@@ -2994,10 +2939,14 @@ function buildPreviewLines(fileEntry, width, scrollOffset, options = {}) {
2994
2939
  return [{ text: truncate('No file selected', width), color: 'gray', bold: false }];
2995
2940
  }
2996
2941
 
2997
- const isGitPreview = fileEntry.previewType === 'git-diff';
2942
+ const isGitFilePreview = fileEntry.previewType === 'git-diff';
2943
+ const isGitCommitPreview = fileEntry.previewType === 'git-commit-diff';
2944
+ const isGitPreview = isGitFilePreview || isGitCommitPreview;
2998
2945
  let rawLines = [];
2999
2946
  if (isGitPreview) {
3000
- const diffText = loadGitDiffPreview(fileEntry);
2947
+ const diffText = isGitCommitPreview
2948
+ ? loadGitCommitPreview(fileEntry)
2949
+ : loadGitDiffPreview(fileEntry);
3001
2950
  rawLines = String(diffText || '').split(/\r?\n/);
3002
2951
  } else {
3003
2952
  let content;
@@ -3014,12 +2963,18 @@ function buildPreviewLines(fileEntry, width, scrollOffset, options = {}) {
3014
2963
  }
3015
2964
 
3016
2965
  const headLine = {
3017
- text: truncate(`${isGitPreview ? 'diff' : 'file'}: ${fileEntry.path}`, width),
2966
+ text: truncate(
2967
+ isGitCommitPreview
2968
+ ? `commit: ${fileEntry.commitHash || fileEntry.path}`
2969
+ : `${isGitPreview ? 'diff' : 'file'}: ${fileEntry.path}`,
2970
+ width
2971
+ ),
3018
2972
  color: 'cyan',
3019
2973
  bold: true
3020
2974
  };
3021
2975
 
3022
- const cappedLines = fullDocument ? rawLines : rawLines.slice(0, 300);
2976
+ const normalizedLines = rawLines.map((line) => sanitizeRenderLine(line));
2977
+ const cappedLines = fullDocument ? normalizedLines : normalizedLines.slice(0, 300);
3023
2978
  const hiddenLineCount = fullDocument ? 0 : Math.max(0, rawLines.length - cappedLines.length);
3024
2979
  let inCodeBlock = false;
3025
2980
 
@@ -3331,7 +3286,7 @@ function createDashboardApp(deps) {
3331
3286
  intents: 'intent-status',
3332
3287
  completed: 'completed-runs',
3333
3288
  health: 'standards',
3334
- git: 'git-changes'
3289
+ git: 'git-status'
3335
3290
  });
3336
3291
  const [selectionBySection, setSelectionBySection] = useState({
3337
3292
  worktrees: 0,
@@ -3344,7 +3299,8 @@ function createDashboardApp(deps) {
3344
3299
  stats: 0,
3345
3300
  warnings: 0,
3346
3301
  'error-details': 0,
3347
- 'git-changes': 0
3302
+ 'git-changes': 0,
3303
+ 'git-commits': 0
3348
3304
  });
3349
3305
  const [expandedGroups, setExpandedGroups] = useState({});
3350
3306
  const [previewTarget, setPreviewTarget] = useState(null);
@@ -3508,6 +3464,9 @@ function createDashboardApp(deps) {
3508
3464
  );
3509
3465
  })()
3510
3466
  : toLoadingRows('Loading git changes...', 'git-loading');
3467
+ const gitCommitRows = shouldHydrateSecondaryTabs
3468
+ ? buildGitCommitRows(snapshot)
3469
+ : toLoadingRows('Loading commit history...', 'git-commits-loading');
3511
3470
 
3512
3471
  const rowsBySection = {
3513
3472
  worktrees: worktreeRows,
@@ -3520,7 +3479,8 @@ function createDashboardApp(deps) {
3520
3479
  stats: statsRows,
3521
3480
  warnings: warningsRows,
3522
3481
  'error-details': errorDetailsRows,
3523
- 'git-changes': gitRows
3482
+ 'git-changes': gitRows,
3483
+ 'git-commits': gitCommitRows
3524
3484
  };
3525
3485
  const worktreeItems = getWorktreeItems(snapshot);
3526
3486
  const selectedWorktree = getSelectedWorktree(snapshot);
@@ -3543,6 +3503,8 @@ function createDashboardApp(deps) {
3543
3503
  const selectedFocusedFile = rowToFileEntry(selectedFocusedRow);
3544
3504
  const selectedGitRow = getSelectedRow(gitRows, selectionBySection['git-changes'] || 0);
3545
3505
  const selectedGitFile = rowToFileEntry(selectedGitRow);
3506
+ const selectedGitCommitRow = getSelectedRow(gitCommitRows, selectionBySection['git-commits'] || 0);
3507
+ const selectedGitCommit = rowToFileEntry(selectedGitCommitRow);
3546
3508
  const firstGitFile = firstFileEntryFromRows(gitRows);
3547
3509
 
3548
3510
  const refresh = useCallback(async (overrideSelectedWorktreeId = null) => {
@@ -3742,14 +3704,15 @@ function createDashboardApp(deps) {
3742
3704
  stats: 0,
3743
3705
  warnings: 0,
3744
3706
  'error-details': 0,
3745
- 'git-changes': 0
3707
+ 'git-changes': 0,
3708
+ 'git-commits': 0
3746
3709
  });
3747
3710
  setSectionFocus({
3748
3711
  runs: 'current-run',
3749
3712
  intents: 'intent-status',
3750
3713
  completed: 'completed-runs',
3751
3714
  health: 'standards',
3752
- git: 'git-changes'
3715
+ git: 'git-status'
3753
3716
  });
3754
3717
  setOverviewIntentFilter('next');
3755
3718
  setExpandedGroups({});
@@ -3785,14 +3748,15 @@ function createDashboardApp(deps) {
3785
3748
  stats: 0,
3786
3749
  warnings: 0,
3787
3750
  'error-details': 0,
3788
- 'git-changes': 0
3751
+ 'git-changes': 0,
3752
+ 'git-commits': 0
3789
3753
  });
3790
3754
  setSectionFocus({
3791
3755
  runs: 'current-run',
3792
3756
  intents: 'intent-status',
3793
3757
  completed: 'completed-runs',
3794
3758
  health: 'standards',
3795
- git: 'git-changes'
3759
+ git: 'git-status'
3796
3760
  });
3797
3761
  setOverviewIntentFilter('next');
3798
3762
  setExpandedGroups({});
@@ -3908,20 +3872,10 @@ function createDashboardApp(deps) {
3908
3872
  return;
3909
3873
  }
3910
3874
  if (input === '8') {
3911
- setSectionFocus((previous) => ({ ...previous, git: 'git-branches' }));
3912
- setPaneFocus('main');
3913
- return;
3914
- }
3915
- if (input === '9') {
3916
3875
  setSectionFocus((previous) => ({ ...previous, git: 'git-commits' }));
3917
3876
  setPaneFocus('main');
3918
3877
  return;
3919
3878
  }
3920
- if (input === '0') {
3921
- setSectionFocus((previous) => ({ ...previous, git: 'git-stash' }));
3922
- setPaneFocus('main');
3923
- return;
3924
- }
3925
3879
  if (input === '-') {
3926
3880
  setSectionFocus((previous) => ({ ...previous, git: 'git-diff' }));
3927
3881
  setPaneFocus('main');
@@ -4038,6 +3992,10 @@ function createDashboardApp(deps) {
4038
3992
 
4039
3993
  if (input === 'o') {
4040
3994
  const target = selectedFocusedFile || previewTarget;
3995
+ if (target?.previewType === 'git-commit-diff') {
3996
+ setStatusLine('Commit entries cannot be opened as files.');
3997
+ return;
3998
+ }
4041
3999
  const result = openFileWithDefaultApp(target?.path);
4042
4000
  setStatusLine(result.message);
4043
4001
  }
@@ -4119,6 +4077,13 @@ function createDashboardApp(deps) {
4119
4077
  setPreviewScroll(0);
4120
4078
  }, [previewOpen, overlayPreviewOpen, paneFocus, selectedFocusedFile?.path, previewTarget?.path]);
4121
4079
 
4080
+ useEffect(() => {
4081
+ if (ui.view !== 'git') {
4082
+ return;
4083
+ }
4084
+ setPreviewScroll(0);
4085
+ }, [ui.view, focusedSection, selectedGitFile?.path, selectedGitCommit?.commitHash]);
4086
+
4122
4087
  useEffect(() => {
4123
4088
  if (statusLine === '') {
4124
4089
  return undefined;
@@ -4282,16 +4247,17 @@ function createDashboardApp(deps) {
4282
4247
  fullDocument: overlayPreviewOpen
4283
4248
  })
4284
4249
  : [];
4285
- const gitInlineDiffTarget = selectedGitFile || firstGitFile || previewTarget || null;
4250
+ const gitInlineDiffTarget = (
4251
+ focusedSection === 'git-commits'
4252
+ ? (selectedGitCommit || selectedGitFile || firstGitFile)
4253
+ : (selectedGitFile || firstGitFile)
4254
+ ) || null;
4286
4255
  const gitInlineDiffLines = ui.view === 'git'
4287
4256
  ? buildPreviewLines(gitInlineDiffTarget, compactWidth, previewOpen && paneFocus === 'preview' ? previewScroll : 0, {
4288
4257
  fullDocument: false
4289
4258
  })
4290
4259
  : [];
4291
4260
  const gitStatusPanelLines = ui.view === 'git' ? buildGitStatusPanelLines(snapshot) : [];
4292
- const gitBranchesPanelLines = ui.view === 'git' ? buildGitBranchesPanelLines(snapshot) : [];
4293
- const gitCommitsPanelLines = ui.view === 'git' ? buildGitCommitsPanelLines(snapshot) : [];
4294
- const gitStashPanelLines = ui.view === 'git' ? buildGitStashPanelLines(snapshot) : [];
4295
4261
 
4296
4262
  const shortcutsOverlayLines = buildHelpOverlayLines({
4297
4263
  view: ui.view,
@@ -4411,27 +4377,15 @@ function createDashboardApp(deps) {
4411
4377
  lines: sectionLines['git-changes'],
4412
4378
  borderColor: 'yellow'
4413
4379
  },
4414
- {
4415
- key: 'git-branches',
4416
- title: '[8]-Local branches',
4417
- lines: gitBranchesPanelLines,
4418
- borderColor: 'magenta'
4419
- },
4420
4380
  {
4421
4381
  key: 'git-commits',
4422
- title: '[9]-Commits',
4423
- lines: gitCommitsPanelLines,
4382
+ title: '[8]-Commits',
4383
+ lines: sectionLines['git-commits'],
4424
4384
  borderColor: 'cyan'
4425
4385
  },
4426
- {
4427
- key: 'git-stash',
4428
- title: '[0]-Stash',
4429
- lines: gitStashPanelLines,
4430
- borderColor: 'blue'
4431
- },
4432
4386
  {
4433
4387
  key: 'git-diff',
4434
- title: '[-]-Unstaged changes',
4388
+ title: focusedSection === 'git-commits' ? '[-]-Selected commit diff' : '[-]-Unstaged changes',
4435
4389
  lines: gitInlineDiffLines,
4436
4390
  borderColor: 'yellow'
4437
4391
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specsmd",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
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": {