git-coco 0.46.0 → 0.47.0

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.
@@ -54,7 +54,7 @@ import { pathToFileURL } from 'url';
54
54
  /**
55
55
  * Current build version from package.json
56
56
  */
57
- const BUILD_VERSION = "0.46.0";
57
+ const BUILD_VERSION = "0.47.0";
58
58
 
59
59
  const isInteractive = (config) => {
60
60
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -26188,6 +26188,35 @@ function LogInkApp(deps) {
26188
26188
  })();
26189
26189
  return () => { active = false; };
26190
26190
  }, [git, state.activeView, state.diffSource, state.stashDiffRef]);
26191
+ // #879 (item 2) — load commit detail for the active bisect's
26192
+ // current candidate so the bisect surface can show "what changed
26193
+ // here" alongside the decision keys. Mirrors the history-detail
26194
+ // loader's shape but keyed on `bisect.currentSha` and only fires
26195
+ // when the bisect view is active. Best-effort: any failure leaves
26196
+ // the surface in its non-detail mode (decision log only) — never
26197
+ // crash the workstation because git couldn't resolve a sha.
26198
+ const [bisectCandidateDetail, setBisectCandidateDetail] = React.useState(undefined);
26199
+ const [bisectCandidateLoading, setBisectCandidateLoading] = React.useState(false);
26200
+ const bisectCandidateSha = state.activeView === 'bisect' && context.bisect?.active
26201
+ ? context.bisect.currentSha
26202
+ : '';
26203
+ React.useEffect(() => {
26204
+ if (!bisectCandidateSha) {
26205
+ setBisectCandidateDetail(undefined);
26206
+ setBisectCandidateLoading(false);
26207
+ return;
26208
+ }
26209
+ let active = true;
26210
+ setBisectCandidateLoading(true);
26211
+ void (async () => {
26212
+ const next = await safe(getCommitDetail(git, bisectCandidateSha));
26213
+ if (active) {
26214
+ setBisectCandidateDetail(next);
26215
+ setBisectCandidateLoading(false);
26216
+ }
26217
+ })();
26218
+ return () => { active = false; };
26219
+ }, [git, bisectCandidateSha]);
26191
26220
  // #779 — load `git diff <base>..<head>` once the diff view becomes
26192
26221
  // active with diffSource='compare'. Mirrors the stash loader's
26193
26222
  // shape; the surface renders the lines via the same +/-/@@ coloring
@@ -27591,7 +27620,7 @@ function LogInkApp(deps) {
27591
27620
  if (showOnboarding) {
27592
27621
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
27593
27622
  }
27594
- return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
27623
+ return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
27595
27624
  }
27596
27625
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
27597
27626
  const { Box, Text } = components;
@@ -27854,7 +27883,7 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
27854
27883
  : []),
27855
27884
  ];
27856
27885
  }
27857
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
27886
+ function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
27858
27887
  if (state.activeView === 'status') {
27859
27888
  return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27860
27889
  }
@@ -27874,7 +27903,7 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
27874
27903
  return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27875
27904
  }
27876
27905
  if (state.activeView === 'bisect') {
27877
- return renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27906
+ return renderBisectSurface(h, components, state, context, contextStatus, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme);
27878
27907
  }
27879
27908
  if (state.activeView === 'stash') {
27880
27909
  return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
@@ -28587,7 +28616,7 @@ function renderReflogSurface(h, components, state, context, contextStatus, bodyR
28587
28616
  * navigable so the user can read the documentation before starting
28588
28617
  * — they can't break anything from here.
28589
28618
  */
28590
- function renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
28619
+ function renderBisectSurface(h, components, state, context, contextStatus, candidateDetail, candidateLoading, bodyRows, width, theme) {
28591
28620
  const { Box, Text } = components;
28592
28621
  const focused = state.focus === 'commits';
28593
28622
  const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
@@ -28598,23 +28627,84 @@ function renderBisectSurface(h, components, state, context, contextStatus, bodyR
28598
28627
  lines.push(h(Text, { key: 'bisect-loading', dimColor: true }, truncate$1('· Loading bisect status…', width - 4)));
28599
28628
  }
28600
28629
  else if (!bisect?.active) {
28601
- // No bisect active. Surface the CLI on-ramp — starting from the
28602
- // TUI is intentionally out of scope for this PR (#784 follow-up).
28603
- // The user is expected to enter via `git bisect start <bad> <good>`
28604
- // and re-open `coco ui`; once bisect is active this view drives
28605
- // the rest.
28606
- lines.push(h(Text, { key: 'bisect-empty-1', bold: true }, truncate$1('No bisect in progress.', width - 4)));
28607
- lines.push(h(Text, { key: 'bisect-empty-2' }, ''));
28608
- lines.push(h(Text, { key: 'bisect-empty-3' }, truncate$1('Start one from the shell with:', width - 4)));
28609
- lines.push(h(Text, { key: 'bisect-empty-4', color: accent }, truncate$1(' git bisect start <bad-ref> <good-ref>', width - 4)));
28610
- lines.push(h(Text, { key: 'bisect-empty-5' }, ''));
28611
- lines.push(h(Text, { key: 'bisect-empty-6', dimColor: true }, truncate$1('coco will pick up the active bisect on the next refresh — actions will become available here.', width - 4)));
28630
+ // Empty-state explainer (#879). Teaches the bisect workflow in
28631
+ // ~30 seconds: what it is, how it works, how to start one (CLI
28632
+ // entry remains the supported on-ramp until the in-TUI start
28633
+ // child item lands), and a tip about picking the good anchor.
28634
+ // Bisect is a rarely-used feature even for experienced users —
28635
+ // shipping it with terse copy assumes muscle memory the median
28636
+ // user doesn't have.
28637
+ const empty = [
28638
+ { key: 'title', text: 'Bisect find the commit that introduced a bug.', opts: { bold: true } },
28639
+ { key: 'spacer-1', text: '' },
28640
+ { key: 'how-h', text: 'How it works', opts: { bold: true } },
28641
+ { key: 'how-1', text: ' Binary search through history. You mark commits as "good" (bug' },
28642
+ { key: 'how-2', text: ' not present) or "bad" (bug present); git narrows the range until' },
28643
+ { key: 'how-3', text: ' it identifies the first bad commit.' },
28644
+ { key: 'spacer-2', text: '' },
28645
+ { key: 'start-h', text: 'How to start', opts: { bold: true } },
28646
+ { key: 'start-1', text: ' From your shell:' },
28647
+ { key: 'start-2', text: ' git bisect start <bad-ref> <good-ref>', opts: { accent: true } },
28648
+ { key: 'start-3', text: ' Then come back here — coco picks up the active bisect and gives' },
28649
+ { key: 'start-4', text: ' you single-keystroke controls:' },
28650
+ { key: 'start-5', text: ' g mark good s skip (e.g. doesn\'t build)', opts: { accent: true } },
28651
+ { key: 'start-6', text: ' b mark bad x reset / cancel', opts: { accent: true } },
28652
+ { key: 'spacer-3', text: '' },
28653
+ { key: 'tip-h', text: 'Tip', opts: { bold: true } },
28654
+ { key: 'tip-1', text: ' Pick a recent release tag as your "good" anchor if you don\'t' },
28655
+ { key: 'tip-2', text: ' remember when the bug appeared. Tags are visible from the tags' },
28656
+ { key: 'tip-3', text: ' view (g t).' },
28657
+ ];
28658
+ for (const row of empty) {
28659
+ lines.push(h(Text, {
28660
+ key: `bisect-empty-${row.key}`,
28661
+ bold: row.opts?.bold,
28662
+ dimColor: row.opts?.dim,
28663
+ color: row.opts?.accent ? accent : undefined,
28664
+ }, truncate$1(row.text, width - 4)));
28665
+ }
28612
28666
  }
28613
28667
  else {
28614
- // Active bisect. Two-section body: current candidate, recent
28615
- // decisions. Action keys live in the footer.
28668
+ // Active bisect. Three-section body: current candidate (sha +
28669
+ // commit summary so the user can judge the diff at a glance),
28670
+ // recent decisions, action hints. Action keys live in the footer.
28616
28671
  const headerSha = bisect.currentSha ? bisect.currentSha.slice(0, 8) : '<unknown>';
28617
28672
  lines.push(h(Text, { key: 'bisect-active-title', bold: true }, truncate$1(`Bisecting · current candidate ${headerSha}`, width - 4)));
28673
+ // #879 (item 2) — render commit detail for the current candidate.
28674
+ // Lets the user judge "does this look like it would cause the bug?"
28675
+ // before they run their tests, instead of dropping to shell to
28676
+ // git show. Loading is brief (one git show invocation) and the
28677
+ // surface falls back to just the sha header when the detail
28678
+ // hasn't arrived yet (or git rejected the lookup).
28679
+ if (candidateLoading) {
28680
+ lines.push(h(Text, { key: 'bisect-candidate-loading', dimColor: true }, truncate$1(' loading commit detail…', width - 4)));
28681
+ }
28682
+ else if (candidateDetail) {
28683
+ const { author, date, message, body, stats, files } = candidateDetail;
28684
+ lines.push(h(Text, { key: 'bisect-candidate-subject' }, truncate$1(` ${message}`, width - 4)));
28685
+ lines.push(h(Text, { key: 'bisect-candidate-author', dimColor: true }, truncate$1(` ${author} · ${date}`, width - 4)));
28686
+ // Body line — first non-empty line of the commit body, truncated.
28687
+ // Skip the noisy preamble (subject + blank line) by taking the
28688
+ // first paragraph after the title; body===subject is common for
28689
+ // single-line commits and we filter that out.
28690
+ const firstBodyLine = (body || '')
28691
+ .split('\n')
28692
+ .map((line) => line.trim())
28693
+ .find((line) => line.length > 0 && line !== message);
28694
+ if (firstBodyLine) {
28695
+ lines.push(h(Text, { key: 'bisect-candidate-body', dimColor: true }, truncate$1(` ${firstBodyLine}`, width - 4)));
28696
+ }
28697
+ // Stats summary: total file count + +/- numbers, then a few
28698
+ // file names so the user sees scope at a glance. Cap the
28699
+ // file-name list at 3 entries to keep the section bounded.
28700
+ lines.push(h(Text, { key: 'bisect-candidate-stats' }, truncate$1(` ${stats.filesChanged} file${stats.filesChanged === 1 ? '' : 's'} · +${stats.insertions} / -${stats.deletions}`, width - 4)));
28701
+ const sampleFiles = files.slice(0, 3).map((file) => file.path);
28702
+ if (sampleFiles.length > 0) {
28703
+ const overflow = files.length > sampleFiles.length ? ` (+${files.length - sampleFiles.length} more)` : '';
28704
+ lines.push(h(Text, { key: 'bisect-candidate-files', dimColor: true }, truncate$1(` ${sampleFiles.join(', ')}${overflow}`, width - 4)));
28705
+ }
28706
+ }
28707
+ // Spacer separates the candidate section from decisions.
28618
28708
  lines.push(h(Text, { key: 'bisect-active-spacer' }, ''));
28619
28709
  const decisions = bisect.log.filter((entry) => entry.kind === 'good' || entry.kind === 'bad' || entry.kind === 'skip');
28620
28710
  if (decisions.length === 0) {
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.46.0";
81
+ const BUILD_VERSION = "0.47.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -26212,6 +26212,35 @@ function LogInkApp(deps) {
26212
26212
  })();
26213
26213
  return () => { active = false; };
26214
26214
  }, [git, state.activeView, state.diffSource, state.stashDiffRef]);
26215
+ // #879 (item 2) — load commit detail for the active bisect's
26216
+ // current candidate so the bisect surface can show "what changed
26217
+ // here" alongside the decision keys. Mirrors the history-detail
26218
+ // loader's shape but keyed on `bisect.currentSha` and only fires
26219
+ // when the bisect view is active. Best-effort: any failure leaves
26220
+ // the surface in its non-detail mode (decision log only) — never
26221
+ // crash the workstation because git couldn't resolve a sha.
26222
+ const [bisectCandidateDetail, setBisectCandidateDetail] = React.useState(undefined);
26223
+ const [bisectCandidateLoading, setBisectCandidateLoading] = React.useState(false);
26224
+ const bisectCandidateSha = state.activeView === 'bisect' && context.bisect?.active
26225
+ ? context.bisect.currentSha
26226
+ : '';
26227
+ React.useEffect(() => {
26228
+ if (!bisectCandidateSha) {
26229
+ setBisectCandidateDetail(undefined);
26230
+ setBisectCandidateLoading(false);
26231
+ return;
26232
+ }
26233
+ let active = true;
26234
+ setBisectCandidateLoading(true);
26235
+ void (async () => {
26236
+ const next = await safe(getCommitDetail(git, bisectCandidateSha));
26237
+ if (active) {
26238
+ setBisectCandidateDetail(next);
26239
+ setBisectCandidateLoading(false);
26240
+ }
26241
+ })();
26242
+ return () => { active = false; };
26243
+ }, [git, bisectCandidateSha]);
26215
26244
  // #779 — load `git diff <base>..<head>` once the diff view becomes
26216
26245
  // active with diffSource='compare'. Mirrors the stash loader's
26217
26246
  // shape; the surface renders the lines via the same +/-/@@ coloring
@@ -27615,7 +27644,7 @@ function LogInkApp(deps) {
27615
27644
  if (showOnboarding) {
27616
27645
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
27617
27646
  }
27618
- return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
27647
+ return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
27619
27648
  }
27620
27649
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
27621
27650
  const { Box, Text } = components;
@@ -27878,7 +27907,7 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
27878
27907
  : []),
27879
27908
  ];
27880
27909
  }
27881
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
27910
+ function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
27882
27911
  if (state.activeView === 'status') {
27883
27912
  return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27884
27913
  }
@@ -27898,7 +27927,7 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
27898
27927
  return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27899
27928
  }
27900
27929
  if (state.activeView === 'bisect') {
27901
- return renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27930
+ return renderBisectSurface(h, components, state, context, contextStatus, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme);
27902
27931
  }
27903
27932
  if (state.activeView === 'stash') {
27904
27933
  return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
@@ -28611,7 +28640,7 @@ function renderReflogSurface(h, components, state, context, contextStatus, bodyR
28611
28640
  * navigable so the user can read the documentation before starting
28612
28641
  * — they can't break anything from here.
28613
28642
  */
28614
- function renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
28643
+ function renderBisectSurface(h, components, state, context, contextStatus, candidateDetail, candidateLoading, bodyRows, width, theme) {
28615
28644
  const { Box, Text } = components;
28616
28645
  const focused = state.focus === 'commits';
28617
28646
  const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
@@ -28622,23 +28651,84 @@ function renderBisectSurface(h, components, state, context, contextStatus, bodyR
28622
28651
  lines.push(h(Text, { key: 'bisect-loading', dimColor: true }, truncate$1('· Loading bisect status…', width - 4)));
28623
28652
  }
28624
28653
  else if (!bisect?.active) {
28625
- // No bisect active. Surface the CLI on-ramp — starting from the
28626
- // TUI is intentionally out of scope for this PR (#784 follow-up).
28627
- // The user is expected to enter via `git bisect start <bad> <good>`
28628
- // and re-open `coco ui`; once bisect is active this view drives
28629
- // the rest.
28630
- lines.push(h(Text, { key: 'bisect-empty-1', bold: true }, truncate$1('No bisect in progress.', width - 4)));
28631
- lines.push(h(Text, { key: 'bisect-empty-2' }, ''));
28632
- lines.push(h(Text, { key: 'bisect-empty-3' }, truncate$1('Start one from the shell with:', width - 4)));
28633
- lines.push(h(Text, { key: 'bisect-empty-4', color: accent }, truncate$1(' git bisect start <bad-ref> <good-ref>', width - 4)));
28634
- lines.push(h(Text, { key: 'bisect-empty-5' }, ''));
28635
- lines.push(h(Text, { key: 'bisect-empty-6', dimColor: true }, truncate$1('coco will pick up the active bisect on the next refresh — actions will become available here.', width - 4)));
28654
+ // Empty-state explainer (#879). Teaches the bisect workflow in
28655
+ // ~30 seconds: what it is, how it works, how to start one (CLI
28656
+ // entry remains the supported on-ramp until the in-TUI start
28657
+ // child item lands), and a tip about picking the good anchor.
28658
+ // Bisect is a rarely-used feature even for experienced users —
28659
+ // shipping it with terse copy assumes muscle memory the median
28660
+ // user doesn't have.
28661
+ const empty = [
28662
+ { key: 'title', text: 'Bisect find the commit that introduced a bug.', opts: { bold: true } },
28663
+ { key: 'spacer-1', text: '' },
28664
+ { key: 'how-h', text: 'How it works', opts: { bold: true } },
28665
+ { key: 'how-1', text: ' Binary search through history. You mark commits as "good" (bug' },
28666
+ { key: 'how-2', text: ' not present) or "bad" (bug present); git narrows the range until' },
28667
+ { key: 'how-3', text: ' it identifies the first bad commit.' },
28668
+ { key: 'spacer-2', text: '' },
28669
+ { key: 'start-h', text: 'How to start', opts: { bold: true } },
28670
+ { key: 'start-1', text: ' From your shell:' },
28671
+ { key: 'start-2', text: ' git bisect start <bad-ref> <good-ref>', opts: { accent: true } },
28672
+ { key: 'start-3', text: ' Then come back here — coco picks up the active bisect and gives' },
28673
+ { key: 'start-4', text: ' you single-keystroke controls:' },
28674
+ { key: 'start-5', text: ' g mark good s skip (e.g. doesn\'t build)', opts: { accent: true } },
28675
+ { key: 'start-6', text: ' b mark bad x reset / cancel', opts: { accent: true } },
28676
+ { key: 'spacer-3', text: '' },
28677
+ { key: 'tip-h', text: 'Tip', opts: { bold: true } },
28678
+ { key: 'tip-1', text: ' Pick a recent release tag as your "good" anchor if you don\'t' },
28679
+ { key: 'tip-2', text: ' remember when the bug appeared. Tags are visible from the tags' },
28680
+ { key: 'tip-3', text: ' view (g t).' },
28681
+ ];
28682
+ for (const row of empty) {
28683
+ lines.push(h(Text, {
28684
+ key: `bisect-empty-${row.key}`,
28685
+ bold: row.opts?.bold,
28686
+ dimColor: row.opts?.dim,
28687
+ color: row.opts?.accent ? accent : undefined,
28688
+ }, truncate$1(row.text, width - 4)));
28689
+ }
28636
28690
  }
28637
28691
  else {
28638
- // Active bisect. Two-section body: current candidate, recent
28639
- // decisions. Action keys live in the footer.
28692
+ // Active bisect. Three-section body: current candidate (sha +
28693
+ // commit summary so the user can judge the diff at a glance),
28694
+ // recent decisions, action hints. Action keys live in the footer.
28640
28695
  const headerSha = bisect.currentSha ? bisect.currentSha.slice(0, 8) : '<unknown>';
28641
28696
  lines.push(h(Text, { key: 'bisect-active-title', bold: true }, truncate$1(`Bisecting · current candidate ${headerSha}`, width - 4)));
28697
+ // #879 (item 2) — render commit detail for the current candidate.
28698
+ // Lets the user judge "does this look like it would cause the bug?"
28699
+ // before they run their tests, instead of dropping to shell to
28700
+ // git show. Loading is brief (one git show invocation) and the
28701
+ // surface falls back to just the sha header when the detail
28702
+ // hasn't arrived yet (or git rejected the lookup).
28703
+ if (candidateLoading) {
28704
+ lines.push(h(Text, { key: 'bisect-candidate-loading', dimColor: true }, truncate$1(' loading commit detail…', width - 4)));
28705
+ }
28706
+ else if (candidateDetail) {
28707
+ const { author, date, message, body, stats, files } = candidateDetail;
28708
+ lines.push(h(Text, { key: 'bisect-candidate-subject' }, truncate$1(` ${message}`, width - 4)));
28709
+ lines.push(h(Text, { key: 'bisect-candidate-author', dimColor: true }, truncate$1(` ${author} · ${date}`, width - 4)));
28710
+ // Body line — first non-empty line of the commit body, truncated.
28711
+ // Skip the noisy preamble (subject + blank line) by taking the
28712
+ // first paragraph after the title; body===subject is common for
28713
+ // single-line commits and we filter that out.
28714
+ const firstBodyLine = (body || '')
28715
+ .split('\n')
28716
+ .map((line) => line.trim())
28717
+ .find((line) => line.length > 0 && line !== message);
28718
+ if (firstBodyLine) {
28719
+ lines.push(h(Text, { key: 'bisect-candidate-body', dimColor: true }, truncate$1(` ${firstBodyLine}`, width - 4)));
28720
+ }
28721
+ // Stats summary: total file count + +/- numbers, then a few
28722
+ // file names so the user sees scope at a glance. Cap the
28723
+ // file-name list at 3 entries to keep the section bounded.
28724
+ lines.push(h(Text, { key: 'bisect-candidate-stats' }, truncate$1(` ${stats.filesChanged} file${stats.filesChanged === 1 ? '' : 's'} · +${stats.insertions} / -${stats.deletions}`, width - 4)));
28725
+ const sampleFiles = files.slice(0, 3).map((file) => file.path);
28726
+ if (sampleFiles.length > 0) {
28727
+ const overflow = files.length > sampleFiles.length ? ` (+${files.length - sampleFiles.length} more)` : '';
28728
+ lines.push(h(Text, { key: 'bisect-candidate-files', dimColor: true }, truncate$1(` ${sampleFiles.join(', ')}${overflow}`, width - 4)));
28729
+ }
28730
+ }
28731
+ // Spacer separates the candidate section from decisions.
28642
28732
  lines.push(h(Text, { key: 'bisect-active-spacer' }, ''));
28643
28733
  const decisions = bisect.log.filter((entry) => entry.kind === 'good' || entry.kind === 'bad' || entry.kind === 'skip');
28644
28734
  if (decisions.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-coco",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "zero-effort git commits with coco.",
5
5
  "author": "gfargo <ghfargo@gmail.com>",
6
6
  "license": "MIT",