git-coco 0.45.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.45.0";
57
+ const BUILD_VERSION = "0.47.0";
58
58
 
59
59
  const isInteractive = (config) => {
60
60
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -14521,7 +14521,7 @@ const builder$3 = (yargs) => {
14521
14521
  return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
14522
14522
  };
14523
14523
 
14524
- const FIELD_SEPARATOR$2 = '\x1f';
14524
+ const FIELD_SEPARATOR$3 = '\x1f';
14525
14525
  // `%P` (parent hashes, space-separated) lets the TUI distinguish
14526
14526
  // merge commits (parents.length > 1) from regular commits without a
14527
14527
  // second round-trip to git. See #791 stage 3 — merge glyph + HEAD ring.
@@ -14574,13 +14574,13 @@ function parseLogOutput(output) {
14574
14574
  .map((line) => line.trimEnd())
14575
14575
  .filter(Boolean)
14576
14576
  .map((line) => {
14577
- if (!line.includes(FIELD_SEPARATOR$2)) {
14577
+ if (!line.includes(FIELD_SEPARATOR$3)) {
14578
14578
  return {
14579
14579
  type: 'graph',
14580
14580
  graph: line,
14581
14581
  };
14582
14582
  }
14583
- const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$2);
14583
+ const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$3);
14584
14584
  return {
14585
14585
  type: 'commit',
14586
14586
  graph: graph.trimEnd(),
@@ -14655,7 +14655,7 @@ function parseNameStatus(output, numstat = []) {
14655
14655
  function parseCommitDetail(metadata, files, numstatOutput = '') {
14656
14656
  const [hash, shortHash, parentsStr, date, author, refs, message, body = ''] = metadata
14657
14657
  .trimEnd()
14658
- .split(FIELD_SEPARATOR$2);
14658
+ .split(FIELD_SEPARATOR$3);
14659
14659
  const numstat = parseNumstat(numstatOutput);
14660
14660
  return {
14661
14661
  shortHash,
@@ -14795,14 +14795,14 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
14795
14795
  };
14796
14796
  }
14797
14797
 
14798
- const FIELD_SEPARATOR$1 = '\x1f';
14798
+ const FIELD_SEPARATOR$2 = '\x1f';
14799
14799
  function parseBranchRefs(output) {
14800
14800
  return output
14801
14801
  .split('\n')
14802
14802
  .map((line) => line.trimEnd())
14803
14803
  .filter(Boolean)
14804
14804
  .map((line) => {
14805
- const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$1);
14805
+ const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$2);
14806
14806
  if (!refName || !shortName) {
14807
14807
  return undefined;
14808
14808
  }
@@ -14842,7 +14842,7 @@ async function getBranchOverview(git) {
14842
14842
  const [branchOutput, statusOutput, currentBranchOutput] = await Promise.all([
14843
14843
  git.raw([
14844
14844
  'for-each-ref',
14845
- `--format=%(refname)${FIELD_SEPARATOR$1}%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(upstream:short)${FIELD_SEPARATOR$1}%(HEAD)${FIELD_SEPARATOR$1}%(committerdate:short)${FIELD_SEPARATOR$1}%(contents:subject)`,
14845
+ `--format=%(refname)${FIELD_SEPARATOR$2}%(refname:short)${FIELD_SEPARATOR$2}%(objectname:short)${FIELD_SEPARATOR$2}%(upstream:short)${FIELD_SEPARATOR$2}%(HEAD)${FIELD_SEPARATOR$2}%(committerdate:short)${FIELD_SEPARATOR$2}%(contents:subject)`,
14846
14846
  'refs/heads',
14847
14847
  'refs/remotes',
14848
14848
  ]),
@@ -15392,10 +15392,12 @@ async function runCommitDraftWorkflow(input = {}) {
15392
15392
  }
15393
15393
 
15394
15394
  const LOG_INK_CONTEXT_KEYS = [
15395
+ 'bisect',
15395
15396
  'branches',
15396
15397
  'operation',
15397
15398
  'provider',
15398
15399
  'pullRequest',
15400
+ 'reflog',
15399
15401
  'stashes',
15400
15402
  'tags',
15401
15403
  'worktree',
@@ -16115,6 +16117,45 @@ function getLogInkWorkflowActions() {
16115
16117
  kind: 'normal',
16116
16118
  requiresConfirmation: false,
16117
16119
  },
16120
+ {
16121
+ // #784 — bisect workflow actions. All four are scoped per-view in
16122
+ // inkInput (active only when activeView === 'bisect') so the
16123
+ // single-letter keys stay free elsewhere. Empty `key` keeps them
16124
+ // palette-discoverable. Reset is the only destructive one — it
16125
+ // throws away the bisect state — so it routes through y-confirm;
16126
+ // good / bad / skip are recoverable via `git bisect log` and run
16127
+ // immediately.
16128
+ id: 'bisect-good',
16129
+ key: '',
16130
+ label: 'Bisect: mark good',
16131
+ description: 'Mark the current bisect candidate as good and advance to the next one.',
16132
+ kind: 'normal',
16133
+ requiresConfirmation: false,
16134
+ },
16135
+ {
16136
+ id: 'bisect-bad',
16137
+ key: '',
16138
+ label: 'Bisect: mark bad',
16139
+ description: 'Mark the current bisect candidate as bad and advance to the next one.',
16140
+ kind: 'normal',
16141
+ requiresConfirmation: false,
16142
+ },
16143
+ {
16144
+ id: 'bisect-skip',
16145
+ key: '',
16146
+ label: 'Bisect: skip candidate',
16147
+ description: 'Skip the current bisect candidate (e.g. it does not build) and advance.',
16148
+ kind: 'normal',
16149
+ requiresConfirmation: false,
16150
+ },
16151
+ {
16152
+ id: 'bisect-reset',
16153
+ key: '',
16154
+ label: 'Bisect: reset',
16155
+ description: 'End the bisect session and restore HEAD. Discards in-progress bisect state.',
16156
+ kind: 'destructive',
16157
+ requiresConfirmation: true,
16158
+ },
16118
16159
  {
16119
16160
  id: 'ai-commit-summary',
16120
16161
  key: 'I',
@@ -16351,6 +16392,27 @@ const LOG_INK_KEY_BINDINGS = [
16351
16392
  description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
16352
16393
  contexts: ['normal'],
16353
16394
  },
16395
+ {
16396
+ id: 'navigateReflog',
16397
+ keys: ['gr'],
16398
+ label: 'reflog',
16399
+ description: 'Push the reflog browser view — chronological recovery log.',
16400
+ contexts: ['normal'],
16401
+ },
16402
+ {
16403
+ id: 'navigateBisect',
16404
+ keys: ['gB'],
16405
+ label: 'bisect',
16406
+ description: 'Push the bisect workflow view (#784). Capital B disambiguates from gb (branches). Available whenever a bisect is in progress; surfaces the current candidate and the good / bad / skip / reset action keys.',
16407
+ contexts: ['normal'],
16408
+ },
16409
+ {
16410
+ id: 'markForCompare',
16411
+ keys: ['m'],
16412
+ label: 'mark compare',
16413
+ description: 'Mark the cursored ref (branch / tag / commit) as the base for a compare-two-refs diff (#779). Press again on the same ref to clear; with a base set, Enter on another ref opens the compare diff.',
16414
+ contexts: ['commits'],
16415
+ },
16354
16416
  {
16355
16417
  id: 'navigateBack',
16356
16418
  keys: ['<', 'esc'],
@@ -16481,6 +16543,8 @@ const GLOBAL_BINDING_IDS = [
16481
16543
  'navigateWorktrees',
16482
16544
  'navigatePullRequest',
16483
16545
  'navigateConflicts',
16546
+ 'navigateReflog',
16547
+ 'navigateBisect',
16484
16548
  'navigateBack',
16485
16549
  ];
16486
16550
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -16608,6 +16672,15 @@ function getLogInkFooterHints(options) {
16608
16672
  global: NORMAL_GLOBAL_HINTS,
16609
16673
  };
16610
16674
  }
16675
+ if (options.diffSource === 'compare') {
16676
+ // Compare-two-refs (#779): read-only diff with no per-file
16677
+ // cherry-pick or hunk apply (those don't make sense across
16678
+ // arbitrary refs). Just scroll + back out.
16679
+ return {
16680
+ contextual: ['j/k lines', splitToggleHint, 'esc back'],
16681
+ global: NORMAL_GLOBAL_HINTS,
16682
+ };
16683
+ }
16611
16684
  return {
16612
16685
  contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'y yank'],
16613
16686
  global: NORMAL_GLOBAL_HINTS,
@@ -16620,14 +16693,26 @@ function getLogInkFooterHints(options) {
16620
16693
  };
16621
16694
  }
16622
16695
  if (options.activeView === 'branches') {
16696
+ if (options.compareBaseSet) {
16697
+ return {
16698
+ contextual: ['↑/↓ branches', 'enter compare', 'm clear', 'esc back'],
16699
+ global: NORMAL_GLOBAL_HINTS,
16700
+ };
16701
+ }
16623
16702
  return {
16624
- contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 's sort', 'y yank'],
16703
+ contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 'm compare', 's sort', 'y yank'],
16625
16704
  global: NORMAL_GLOBAL_HINTS,
16626
16705
  };
16627
16706
  }
16628
16707
  if (options.activeView === 'tags') {
16708
+ if (options.compareBaseSet) {
16709
+ return {
16710
+ contextual: ['↑/↓ tags', 'enter compare', 'm clear', 'esc back'],
16711
+ global: NORMAL_GLOBAL_HINTS,
16712
+ };
16713
+ }
16629
16714
  return {
16630
- contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 's sort', 'y yank'],
16715
+ contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 'm compare', 's sort', 'y yank'],
16631
16716
  global: NORMAL_GLOBAL_HINTS,
16632
16717
  };
16633
16718
  }
@@ -16659,6 +16744,28 @@ function getLogInkFooterHints(options) {
16659
16744
  global: NORMAL_GLOBAL_HINTS,
16660
16745
  };
16661
16746
  }
16747
+ if (options.activeView === 'reflog') {
16748
+ return {
16749
+ contextual: ['↑/↓ entries', 'enter inspect', 'esc back'],
16750
+ global: NORMAL_GLOBAL_HINTS,
16751
+ };
16752
+ }
16753
+ if (options.activeView === 'bisect') {
16754
+ return {
16755
+ contextual: ['g good', 'b bad', 's skip', 'x reset', 'esc back'],
16756
+ global: NORMAL_GLOBAL_HINTS,
16757
+ };
16758
+ }
16759
+ if (options.compareBaseSet) {
16760
+ // History view with a compare base set — Enter is overridden to
16761
+ // open the compare diff; show the override + the bail-out key.
16762
+ // Mutate / new chips are dropped so the footer doesn't compete
16763
+ // with the active workflow.
16764
+ return {
16765
+ contextual: ['↑/↓ move', 'enter compare', 'm clear', 'esc back'],
16766
+ global: NORMAL_GLOBAL_HINTS,
16767
+ };
16768
+ }
16662
16769
  return {
16663
16770
  // History view default hints. Mutating ops (`c` cherry-pick, `R`
16664
16771
  // revert, `Z` reset, `i` interactive-rebase) all route through a
@@ -16668,7 +16775,7 @@ function getLogInkFooterHints(options) {
16668
16775
  // Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
16669
16776
  // the footer stays scannable; full descriptions live in `?` help
16670
16777
  // and the palette.
16671
- contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
16778
+ contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'm compare', 'y/Y yank', '/ search'],
16672
16779
  global: NORMAL_GLOBAL_HINTS,
16673
16780
  };
16674
16781
  }
@@ -17247,6 +17354,7 @@ function withPushedView(state, value) {
17247
17354
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17248
17355
  diffSource: value === 'diff' ? state.diffSource : undefined,
17249
17356
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
17357
+ compareHead: value === 'diff' ? state.compareHead : undefined,
17250
17358
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
17251
17359
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
17252
17360
  pendingKey: undefined,
@@ -17258,6 +17366,13 @@ function withPoppedView(state) {
17258
17366
  }
17259
17367
  const viewStack = state.viewStack.slice(0, -1);
17260
17368
  const next = topOfStack(viewStack);
17369
+ // #779 — compareBase is "cleared when the diff view is popped." We
17370
+ // detect that case by checking if the *previous* top was 'diff'.
17371
+ // The compare workflow ends when the user backs out of the compare
17372
+ // diff; on the next mark they re-set the base. Other view pops
17373
+ // preserve compareBase so the user can move between branches / tags /
17374
+ // history while hunting for a head ref.
17375
+ const wasOnDiff = state.activeView === 'diff';
17261
17376
  return {
17262
17377
  ...state,
17263
17378
  activeView: next,
@@ -17270,6 +17385,8 @@ function withPoppedView(state) {
17270
17385
  selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17271
17386
  diffSource: next === 'diff' ? state.diffSource : undefined,
17272
17387
  stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
17388
+ compareBase: wasOnDiff ? undefined : state.compareBase,
17389
+ compareHead: next === 'diff' ? state.compareHead : undefined,
17273
17390
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
17274
17391
  statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
17275
17392
  pendingKey: undefined,
@@ -17288,6 +17405,7 @@ function withReplacedView(state, value) {
17288
17405
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17289
17406
  diffSource: value === 'diff' ? state.diffSource : undefined,
17290
17407
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
17408
+ compareHead: value === 'diff' ? state.compareHead : undefined,
17291
17409
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
17292
17410
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
17293
17411
  pendingKey: undefined,
@@ -17308,6 +17426,11 @@ function withFilter$1(state, filter, promotedSelections) {
17308
17426
  (filterChanged ? 0 : state.selectedTagIndex);
17309
17427
  const stashIndex = promotedSelections?.stashIndex ??
17310
17428
  (filterChanged ? 0 : state.selectedStashIndex);
17429
+ // Reflog (#781) snaps to 0 on filter change rather than rectifying.
17430
+ // The list is chronological and the user is unlikely to be tracking
17431
+ // a specific entry through filter changes — the simpler reset
17432
+ // matches the "find recovery target by typing" interaction.
17433
+ const reflogIndex = filterChanged ? 0 : state.selectedReflogIndex;
17311
17434
  return {
17312
17435
  ...state,
17313
17436
  filter,
@@ -17317,6 +17440,7 @@ function withFilter$1(state, filter, promotedSelections) {
17317
17440
  selectedBranchIndex: branchIndex,
17318
17441
  selectedTagIndex: tagIndex,
17319
17442
  selectedStashIndex: stashIndex,
17443
+ selectedReflogIndex: reflogIndex,
17320
17444
  diffPreviewOffset: 0,
17321
17445
  pendingKey: undefined,
17322
17446
  };
@@ -17406,6 +17530,7 @@ function createLogInkState(rows, options = {}) {
17406
17530
  selectedStashIndex: 0,
17407
17531
  selectedWorktreeListIndex: 0,
17408
17532
  selectedConflictFileIndex: 0,
17533
+ selectedReflogIndex: 0,
17409
17534
  branchSort: DEFAULT_BRANCH_SORT_MODE,
17410
17535
  tagSort: DEFAULT_TAG_SORT_MODE,
17411
17536
  paletteFilter: '',
@@ -17653,6 +17778,12 @@ function applyLogInkAction(state, action) {
17653
17778
  selectedStashIndex: clampIndex$1(state.selectedStashIndex + action.delta, action.count),
17654
17779
  pendingKey: undefined,
17655
17780
  };
17781
+ case 'moveReflog':
17782
+ return {
17783
+ ...state,
17784
+ selectedReflogIndex: clampIndex$1(state.selectedReflogIndex + action.delta, action.count),
17785
+ pendingKey: undefined,
17786
+ };
17656
17787
  case 'moveWorktreeListEntry':
17657
17788
  return {
17658
17789
  ...state,
@@ -17877,6 +18008,31 @@ function applyLogInkAction(state, action) {
17877
18008
  worktreeDiffOffset: 0,
17878
18009
  };
17879
18010
  }
18011
+ case 'navigateOpenDiffForCompare': {
18012
+ const next = withPushedView(state, 'diff');
18013
+ return {
18014
+ ...next,
18015
+ diffSource: 'compare',
18016
+ compareBase: action.base,
18017
+ compareHead: action.head,
18018
+ // Reset scroll offset so the compare patch always opens at
18019
+ // the top — same reasoning as the stash branch above.
18020
+ diffPreviewOffset: 0,
18021
+ worktreeDiffOffset: 0,
18022
+ };
18023
+ }
18024
+ case 'setCompareBase':
18025
+ return {
18026
+ ...state,
18027
+ compareBase: action.value,
18028
+ pendingKey: undefined,
18029
+ };
18030
+ case 'clearCompareBase':
18031
+ return {
18032
+ ...state,
18033
+ compareBase: undefined,
18034
+ pendingKey: undefined,
18035
+ };
17880
18036
  case 'navigateOpenComposeForFile': {
17881
18037
  const next = withPushedView(state, 'status');
17882
18038
  return {
@@ -18232,10 +18388,66 @@ function isStashActionTarget(state) {
18232
18388
  return (state.activeView === 'stash' && state.focus === 'commits') ||
18233
18389
  (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
18234
18390
  }
18391
+ /**
18392
+ * Reflog has no sidebar tab — only the dedicated promoted view (#781).
18393
+ * The condition stays as a single helper anyway so navigation handlers
18394
+ * can read it the same way they do for the other promoted views.
18395
+ */
18396
+ function isReflogActionTarget(state) {
18397
+ return state.activeView === 'reflog' && state.focus === 'commits';
18398
+ }
18235
18399
  function isWorktreeActionTarget(state) {
18236
18400
  return (state.activeView === 'worktrees' && state.focus === 'commits') ||
18237
18401
  (state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
18238
18402
  }
18403
+ /**
18404
+ * Compare-flow target views (#779). The `m` mark + Enter-as-compare
18405
+ * overrides only fire on rows that represent a single ref the user
18406
+ * could pass to `git diff <ref>..<ref>` — branches, tags, and history
18407
+ * commits. The reflog view is intentionally excluded because reflog
18408
+ * entries are *moves* of HEAD, not refs a user typically diffs against.
18409
+ */
18410
+ function isCompareFlowTarget(state) {
18411
+ if (state.focus !== 'commits')
18412
+ return false;
18413
+ return state.activeView === 'branches' ||
18414
+ state.activeView === 'tags' ||
18415
+ state.activeView === 'history';
18416
+ }
18417
+ /**
18418
+ * Resolve the cursored ref for the compare flow (#779). Pulls the
18419
+ * concrete ref + label off context for branches / tags, and reads the
18420
+ * commit row from state for history. Returns undefined when no usable
18421
+ * ref is under the cursor (e.g., the views are empty, or the focus is
18422
+ * on the synthetic "(+) new commit" row).
18423
+ */
18424
+ function getCursoredCompareRef(state, context) {
18425
+ if (state.activeView === 'branches' && context.branchSelectedShortName) {
18426
+ return {
18427
+ kind: 'branch',
18428
+ ref: context.branchSelectedShortName,
18429
+ label: context.branchSelectedShortName,
18430
+ };
18431
+ }
18432
+ if (state.activeView === 'tags' && context.tagSelectedName) {
18433
+ return {
18434
+ kind: 'tag',
18435
+ ref: context.tagSelectedName,
18436
+ label: context.tagSelectedName,
18437
+ };
18438
+ }
18439
+ if (state.activeView === 'history' && !state.pendingCommitFocused) {
18440
+ const commit = state.filteredCommits[state.selectedIndex];
18441
+ if (commit) {
18442
+ return {
18443
+ kind: 'commit',
18444
+ ref: commit.hash,
18445
+ label: `${commit.shortHash} ${commit.message}`.trim(),
18446
+ };
18447
+ }
18448
+ }
18449
+ return undefined;
18450
+ }
18239
18451
  /**
18240
18452
  * Item count for the active sidebar tab — used by the generic
18241
18453
  * sidebar-Enter handler to decide whether to defer to the per-entity
@@ -18335,6 +18547,20 @@ function getLogInkPaletteExecuteEvents(command, state) {
18335
18547
  return [action({ type: 'pushView', value: 'pull-request' })];
18336
18548
  case 'navigateConflicts':
18337
18549
  return [action({ type: 'pushView', value: 'conflicts' })];
18550
+ case 'navigateReflog':
18551
+ return [action({ type: 'pushView', value: 'reflog' })];
18552
+ case 'navigateBisect':
18553
+ return [action({ type: 'pushView', value: 'bisect' })];
18554
+ case 'markForCompare':
18555
+ // Palette context can't reach the cursored ref (filtered branch /
18556
+ // tag lists live in runtime state, not the reducer). Surface a
18557
+ // hint and let the user press `m` directly on the row. The
18558
+ // inline keypress handler further down in this file does the
18559
+ // actual work and has access to the necessary context.
18560
+ return [action({
18561
+ type: 'setStatus',
18562
+ value: 'open branches / tags / history and press m on the cursored ref',
18563
+ })];
18338
18564
  case 'navigateBack':
18339
18565
  return [action({ type: 'popView' })];
18340
18566
  case 'openSelected': {
@@ -18812,6 +19038,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18812
19038
  action({ type: 'setStatus', value: 'jumped to conflicts' }),
18813
19039
  ];
18814
19040
  }
19041
+ // `gr` chord: jump to the reflog browser (#781). Recovery view —
19042
+ // chronological list of reflog entries with Enter to drill into the
19043
+ // commit-diff for the entry's hash. Loaded lazily by the runtime.
19044
+ if (state.pendingKey === 'g' && inputValue === 'r') {
19045
+ return [
19046
+ action({ type: 'pushView', value: 'reflog' }),
19047
+ action({ type: 'setStatus', value: 'jumped to reflog' }),
19048
+ ];
19049
+ }
19050
+ // `gB` chord: jump to the bisect workflow view (#784). Capital B
19051
+ // disambiguates from `gb` (branches). Always navigates — even when
19052
+ // bisect is inactive — so the user can see the empty-state hint and
19053
+ // know how to start one. The view's surface tells them the next step.
19054
+ if (state.pendingKey === 'g' && inputValue === 'B') {
19055
+ return [
19056
+ action({ type: 'pushView', value: 'bisect' }),
19057
+ action({ type: 'setStatus', value: 'jumped to bisect' }),
19058
+ ];
19059
+ }
18815
19060
  // `gH` chord: apply the cursored hunk to the index (`git apply
18816
19061
  // --cached`). Sibling of bare `H` which targets the worktree.
18817
19062
  // Discoverable via the footer hint on diff views and the help
@@ -18851,6 +19096,29 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18851
19096
  action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
18852
19097
  ];
18853
19098
  }
19099
+ // #784 — bisect view action keys. Scoped to `state.activeView ===
19100
+ // 'bisect' && state.focus === 'commits'` so the single-letter keys
19101
+ // stay free everywhere else. `g` and `b` collide with the global
19102
+ // chord prefix and the `gb` continuation respectively — placed
19103
+ // BEFORE the bare-`g` chord trigger below so a `g` keystroke on
19104
+ // the bisect view marks good rather than entering chord mode. The
19105
+ // user's path back out of bisect is `<` / `esc`, never a chord;
19106
+ // the in-bisect view itself can't navigate elsewhere via `g`-prefix
19107
+ // chords until the user exits with `esc` first.
19108
+ if (state.activeView === 'bisect' && state.focus === 'commits') {
19109
+ if (inputValue === 'g' && state.pendingKey !== 'g') {
19110
+ return [{ type: 'runWorkflowAction', id: 'bisect-good' }];
19111
+ }
19112
+ if (inputValue === 'b' && state.pendingKey !== 'g') {
19113
+ return [{ type: 'runWorkflowAction', id: 'bisect-bad' }];
19114
+ }
19115
+ if (inputValue === 's') {
19116
+ return [{ type: 'runWorkflowAction', id: 'bisect-skip' }];
19117
+ }
19118
+ if (inputValue === 'x') {
19119
+ return [action({ type: 'setPendingConfirmation', value: 'bisect-reset' })];
19120
+ }
19121
+ }
18854
19122
  if (inputValue === 'g') {
18855
19123
  if (state.pendingKey === 'g') {
18856
19124
  return [
@@ -19118,6 +19386,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19118
19386
  if (isStashActionTarget(state) && context.stashCount) {
19119
19387
  return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
19120
19388
  }
19389
+ if (isReflogActionTarget(state) && context.reflogCount) {
19390
+ return [action({ type: 'moveReflog', delta: -1, count: context.reflogCount })];
19391
+ }
19121
19392
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
19122
19393
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
19123
19394
  }
@@ -19199,6 +19470,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19199
19470
  if (isStashActionTarget(state) && context.stashCount) {
19200
19471
  return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
19201
19472
  }
19473
+ if (isReflogActionTarget(state) && context.reflogCount) {
19474
+ return [action({ type: 'moveReflog', delta: 1, count: context.reflogCount })];
19475
+ }
19202
19476
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
19203
19477
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
19204
19478
  }
@@ -19270,6 +19544,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19270
19544
  action({ type: 'setStatus', value: 'staging worktree changes' }),
19271
19545
  ];
19272
19546
  }
19547
+ // Compare-flow Enter override (#779). When `compareBase` is set and
19548
+ // the user presses Enter on a branch / tag / history commit row, we
19549
+ // open the compare diff (base..head) instead of the row's normal
19550
+ // action (checkout / drill-in / diff). Scoped to compare-flow
19551
+ // targets so non-flow views keep their Enter intact. Runs BEFORE
19552
+ // the per-row Enter handlers below so the override wins, including
19553
+ // before the history-row drill-in.
19554
+ if (key.return && state.compareBase && isCompareFlowTarget(state)) {
19555
+ const head = getCursoredCompareRef(state, context);
19556
+ if (!head) {
19557
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
19558
+ }
19559
+ if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
19560
+ return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
19561
+ }
19562
+ return [
19563
+ action({
19564
+ type: 'navigateOpenDiffForCompare',
19565
+ base: state.compareBase,
19566
+ head,
19567
+ }),
19568
+ action({ type: 'setStatus', value: `Comparing ${state.compareBase.label} → ${head.label}` }),
19569
+ ];
19570
+ }
19273
19571
  if (key.return &&
19274
19572
  state.activeView === 'history' &&
19275
19573
  state.focus === 'commits' &&
@@ -19286,6 +19584,28 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19286
19584
  ];
19287
19585
  }
19288
19586
  }
19587
+ // Enter on a reflog row drills into the diff for that entry's hash
19588
+ // (#781). Reuses `navigateOpenDiffForCommit`, which finds the commit
19589
+ // by hash in `state.filteredCommits` first and falls back to
19590
+ // `commitIndex` only when the hash isn't present. Reflog hashes that
19591
+ // exist in the loaded history (the common case) drill in cleanly;
19592
+ // dangling-commit hashes fall back to the index. The `commitIndex`
19593
+ // we pass is best-effort — index in `state.commits` if found, else
19594
+ // `state.selectedIndex` so the cursor stays sane on the diff view.
19595
+ if (key.return &&
19596
+ isReflogActionTarget(state) &&
19597
+ context.reflogSelectedHash) {
19598
+ const sha = context.reflogSelectedHash;
19599
+ const fallbackIndex = state.commits.findIndex((commit) => commit.hash === sha);
19600
+ return [
19601
+ action({
19602
+ type: 'navigateOpenDiffForCommit',
19603
+ sha,
19604
+ commitIndex: fallbackIndex >= 0 ? fallbackIndex : state.selectedIndex,
19605
+ }),
19606
+ action({ type: 'setStatus', value: `viewing diff for ${sha.slice(0, 7)}` }),
19607
+ ];
19608
+ }
19289
19609
  // Inspector Actions tab: Enter on the cursored action fires its
19290
19610
  // associated event (cherry-pick / revert / yank / etc.). Wins over
19291
19611
  // the file-list Enter below when the user has [/]-toggled to the
@@ -19465,6 +19785,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19465
19785
  if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
19466
19786
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
19467
19787
  }
19788
+ // `m` marks (or un-marks) the cursored ref as the compare base
19789
+ // (#779). Scoped to compare-flow targets so it doesn't collide with
19790
+ // the `m` PR-merge handler further down. The toggle behavior — `m`
19791
+ // again on the same ref clears the base — gives the user a way to
19792
+ // bail out without remembering a separate cancel key.
19793
+ if (inputValue === 'm' && isCompareFlowTarget(state)) {
19794
+ const ref = getCursoredCompareRef(state, context);
19795
+ if (!ref) {
19796
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
19797
+ }
19798
+ if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
19799
+ return [
19800
+ action({ type: 'clearCompareBase' }),
19801
+ action({ type: 'setStatus', value: `Cleared compare base ${ref.label}` }),
19802
+ ];
19803
+ }
19804
+ return [
19805
+ action({ type: 'setCompareBase', value: ref }),
19806
+ action({ type: 'setStatus', value: `Compare base: ${ref.label} — press enter on another ref to diff` }),
19807
+ ];
19808
+ }
19468
19809
  // Per-view worktree action: `D` removes the worktree AND deletes
19469
19810
  // the branch it was tracking (#838). Scoped to the worktrees
19470
19811
  // surface so it intercepts BEFORE the global workflow-by-key
@@ -20843,6 +21184,12 @@ function formatLogInkStatusEmpty({ hasChanges }) {
20843
21184
  }
20844
21185
  return 'Worktree clean. Press gh for history, gb for branches, gz for stash.';
20845
21186
  }
21187
+ function formatLogInkReflogEmpty({ filter }) {
21188
+ if (filter.trim()) {
21189
+ return `No reflog entries match filter '${filter}'. Press ctrl+u to clear.`;
21190
+ }
21191
+ return 'No reflog entries. Activity in this repo will appear here over time.';
21192
+ }
20846
21193
  function formatLogInkComposeEmpty({ hasStaged }) {
20847
21194
  if (hasStaged) {
20848
21195
  return undefined;
@@ -22472,14 +22819,14 @@ function deleteRemoteTag(git, tagName) {
22472
22819
  return runAction$2(() => git.raw(['push', 'origin', `:${tagName}`]), `Deleted remote tag ${tagName}`);
22473
22820
  }
22474
22821
 
22475
- const FIELD_SEPARATOR = '\x1f';
22822
+ const FIELD_SEPARATOR$1 = '\x1f';
22476
22823
  function parseTagRefs(output) {
22477
22824
  return output
22478
22825
  .split('\n')
22479
22826
  .map((line) => line.trimEnd())
22480
22827
  .filter(Boolean)
22481
22828
  .map((line) => {
22482
- const [name, hash, date, subject] = line.split(FIELD_SEPARATOR);
22829
+ const [name, hash, date, subject] = line.split(FIELD_SEPARATOR$1);
22483
22830
  return {
22484
22831
  name,
22485
22832
  hash,
@@ -22491,7 +22838,7 @@ function parseTagRefs(output) {
22491
22838
  async function getTagOverview(git) {
22492
22839
  const output = await git.raw([
22493
22840
  'for-each-ref',
22494
- `--format=%(refname:short)${FIELD_SEPARATOR}%(objectname:short)${FIELD_SEPARATOR}%(creatordate:short)${FIELD_SEPARATOR}%(subject)`,
22841
+ `--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
22495
22842
  '--sort=-creatordate',
22496
22843
  'refs/tags',
22497
22844
  ]);
@@ -24750,6 +25097,231 @@ function formatPullRequestStateLine(pr) {
24750
25097
  return parts.join(' · ');
24751
25098
  }
24752
25099
 
25100
+ const EMPTY_STATUS = {
25101
+ active: false,
25102
+ currentSha: '',
25103
+ log: [],
25104
+ };
25105
+ async function bisectIsActive(git) {
25106
+ try {
25107
+ const path = (await git.revparse(['--git-path', 'BISECT_LOG'])).trim();
25108
+ return path.length > 0 && existsSync(path);
25109
+ }
25110
+ catch {
25111
+ return false;
25112
+ }
25113
+ }
25114
+ /**
25115
+ * Parse the output of `git bisect log` into structured entries. Each
25116
+ * entry corresponds to one user decision (start / good / bad / skip)
25117
+ * or the "# bad: [<sha>] <subject>" comment lines git emits for
25118
+ * traceability. Comment lines without a recognized prefix are dropped
25119
+ * — they're informational headers ("# status: ..."), not actions
25120
+ * the user took.
25121
+ */
25122
+ function parseBisectLog(output) {
25123
+ const entries = [];
25124
+ for (const rawLine of output.split('\n')) {
25125
+ const line = rawLine.trimEnd();
25126
+ if (!line)
25127
+ continue;
25128
+ // Comment rows: "# good: [sha] subject" / "# bad: [sha] subject" /
25129
+ // "# first bad commit: ..." / "# status: ...". The first two carry
25130
+ // the most user-relevant info (which commits were marked) so we
25131
+ // promote them to typed entries; the rest fall through as raw
25132
+ // lines tagged 'unknown' so the renderer can dim them or hide
25133
+ // entirely.
25134
+ if (line.startsWith('#')) {
25135
+ const commentMatch = line.match(/^#\s+(good|bad|skip):\s+\[([^\]]+)\]\s*(.*)$/);
25136
+ if (commentMatch) {
25137
+ entries.push({
25138
+ kind: commentMatch[1],
25139
+ sha: commentMatch[2],
25140
+ subject: commentMatch[3] || undefined,
25141
+ raw: line,
25142
+ });
25143
+ continue;
25144
+ }
25145
+ entries.push({ kind: 'unknown', raw: line });
25146
+ continue;
25147
+ }
25148
+ // Command rows: "git bisect start", "git bisect good <sha>",
25149
+ // "git bisect bad <sha>", "git bisect skip <sha>".
25150
+ const commandMatch = line.match(/^git\s+bisect\s+(start|good|bad|skip)\s*(.*)$/);
25151
+ if (commandMatch) {
25152
+ const sha = commandMatch[2]?.trim().split(/\s+/)[0] || undefined;
25153
+ entries.push({
25154
+ kind: commandMatch[1],
25155
+ sha: sha || undefined,
25156
+ raw: line,
25157
+ });
25158
+ continue;
25159
+ }
25160
+ entries.push({ kind: 'unknown', raw: line });
25161
+ }
25162
+ return entries;
25163
+ }
25164
+ /**
25165
+ * Load the live bisect status. Best-effort — when bisect isn't
25166
+ * active the empty-status sentinel returns immediately so callers
25167
+ * don't pay for a `git bisect log` round-trip on every refresh.
25168
+ */
25169
+ async function getBisectStatus(git) {
25170
+ if (!(await bisectIsActive(git))) {
25171
+ return EMPTY_STATUS;
25172
+ }
25173
+ let log = [];
25174
+ try {
25175
+ const output = await git.raw(['bisect', 'log']);
25176
+ log = parseBisectLog(output);
25177
+ }
25178
+ catch {
25179
+ // bisect log can fail on a freshly-started bisect with no decisions.
25180
+ // Treat the absence of a parseable log as "active but empty" rather
25181
+ // than non-active, so the surface still routes to the bisect view
25182
+ // and the user can see the badge.
25183
+ log = [];
25184
+ }
25185
+ let currentSha = '';
25186
+ try {
25187
+ currentSha = (await git.revparse(['HEAD'])).trim();
25188
+ }
25189
+ catch {
25190
+ currentSha = '';
25191
+ }
25192
+ return { active: true, currentSha, log };
25193
+ }
25194
+
25195
+ /**
25196
+ * Thin wrappers around `git bisect <verb>` for the TUI's in-bisect
25197
+ * action keys (#784). Each returns the raw stdout so the surface can
25198
+ * surface git's own "Bisecting: N revisions left to test after this
25199
+ * (roughly K steps)" hint as a status message — that wording is the
25200
+ * single most useful piece of feedback git emits during bisect, and
25201
+ * mirroring it keeps the TUI's status line authoritative.
25202
+ *
25203
+ * No try/catch here — git itself returns non-zero on user errors
25204
+ * (already-bisecting, no good ref, etc.) and `simple-git` surfaces
25205
+ * those as rejections. The runtime catches them and routes to the
25206
+ * status line.
25207
+ */
25208
+ async function bisectGood(git, ref) {
25209
+ const args = ['bisect', 'good'];
25210
+ return git.raw(args);
25211
+ }
25212
+ async function bisectBad(git, ref) {
25213
+ const args = ['bisect', 'bad'];
25214
+ return git.raw(args);
25215
+ }
25216
+ async function bisectSkip(git, ref) {
25217
+ const args = ['bisect', 'skip'];
25218
+ return git.raw(args);
25219
+ }
25220
+ async function bisectReset(git) {
25221
+ return git.raw(['bisect', 'reset']);
25222
+ }
25223
+ /**
25224
+ * Pull the user-facing remaining-revisions hint out of `git bisect`
25225
+ * stdout. Looks for the canonical line:
25226
+ *
25227
+ * `Bisecting: N revisions left to test after this (roughly K steps)`
25228
+ *
25229
+ * Returns undefined when the line isn't present (e.g. the run
25230
+ * finished and git emitted a "<sha> is the first bad commit" line
25231
+ * instead). Callers fall back to an empty status update in that case.
25232
+ */
25233
+ function extractBisectRemainingHint(stdout) {
25234
+ for (const line of stdout.split('\n').reverse()) {
25235
+ const trimmed = line.trim();
25236
+ if (trimmed.startsWith('Bisecting:'))
25237
+ return trimmed;
25238
+ if (trimmed.includes('is the first bad commit'))
25239
+ return trimmed;
25240
+ }
25241
+ return undefined;
25242
+ }
25243
+
25244
+ /**
25245
+ * Compare two refs (branches / tags / commits) and return the unified
25246
+ * patch as line-split string output (#779).
25247
+ *
25248
+ * Mirrors the stash-diff loader's contract — emits `string[]` so the
25249
+ * existing diff surface can render the lines through its standard
25250
+ * +/-/@@ coloring path. Two-dot syntax (`base..head`) gives the
25251
+ * "what changed on head, relative to base" view that's natural for
25252
+ * branch reviews and pre-merge sanity checks.
25253
+ *
25254
+ * Defensive about input — both refs are passed as-is to git, so the
25255
+ * caller is responsible for providing a git-resolvable form
25256
+ * (branch shortName, tag name, or commit hash). On any git error
25257
+ * (unknown ref, etc.) the runtime's `safe()` wrapper at the call
25258
+ * site catches the throw and the surface falls back to a "no diff"
25259
+ * hint.
25260
+ */
25261
+ async function getCompareDiff(git, base, head) {
25262
+ return (await git.raw(['diff', `${base}..${head}`]))
25263
+ .split('\n')
25264
+ .map((line) => line.replace(/\r$/, ''));
25265
+ }
25266
+
25267
+ const FIELD_SEPARATOR = '\x1f';
25268
+ /**
25269
+ * Default fetch limit. 200 entries is enough to span weeks of normal
25270
+ * activity for an active repo while keeping the load fast — `git reflog`
25271
+ * is local-only so even 1000+ entries is sub-second, but 200 keeps the
25272
+ * rendered list bounded for terminals.
25273
+ */
25274
+ const DEFAULT_REFLOG_LIMIT = 200;
25275
+ function parseReflogOverview(output) {
25276
+ return output
25277
+ .split('\n')
25278
+ .map((line) => line.trimEnd())
25279
+ .filter(Boolean)
25280
+ .map((line) => {
25281
+ const [selector, hash, relativeDate, subject] = line.split(FIELD_SEPARATOR);
25282
+ return {
25283
+ selector: selector || '',
25284
+ hash: hash || '',
25285
+ relativeDate: relativeDate || '',
25286
+ subject: subject || '',
25287
+ };
25288
+ });
25289
+ }
25290
+ async function getReflogOverview(git, limit = DEFAULT_REFLOG_LIMIT) {
25291
+ const output = await git.raw([
25292
+ 'reflog',
25293
+ `--max-count=${limit}`,
25294
+ `--pretty=format:%gd${FIELD_SEPARATOR}%h${FIELD_SEPARATOR}%cr${FIELD_SEPARATOR}%gs`,
25295
+ ]);
25296
+ return {
25297
+ entries: parseReflogOverview(output),
25298
+ };
25299
+ }
25300
+ /**
25301
+ * Pull the action prefix off a reflog subject. Reflog subjects follow
25302
+ * a `<verb>[ qualifier]: <message>` pattern emitted by git itself —
25303
+ * "commit: ...", "commit (amend): ...", "checkout: moving from main
25304
+ * to feature", "merge feature: ...", "reset: moving to HEAD~1", etc.
25305
+ *
25306
+ * For display we want the verb (and any parenthetical qualifier) on
25307
+ * its own so the view can render a fixed-width `action` column and
25308
+ * keep the rest of the message left-aligned.
25309
+ *
25310
+ * Defensive: if the subject has no colon, the whole string is treated
25311
+ * as the action and the message is empty. This keeps the renderer
25312
+ * from crashing on a malformed entry.
25313
+ */
25314
+ function splitReflogSubject(subject) {
25315
+ const colonIndex = subject.indexOf(':');
25316
+ if (colonIndex === -1) {
25317
+ return { action: subject.trim(), message: '' };
25318
+ }
25319
+ return {
25320
+ action: subject.slice(0, colonIndex).trim(),
25321
+ message: subject.slice(colonIndex + 1).trim(),
25322
+ };
25323
+ }
25324
+
24753
25325
  function sectionLines(title, diff) {
24754
25326
  const lines = diff.split('\n').map((line) => line.trimEnd());
24755
25327
  return [
@@ -24862,7 +25434,7 @@ async function safe(promise) {
24862
25434
  }
24863
25435
  }
24864
25436
  async function loadLogInkContext(git) {
24865
- const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider] = await Promise.all([
25437
+ const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider, reflog, bisect] = await Promise.all([
24866
25438
  safe(getBranchOverview(git)),
24867
25439
  safe(getPullRequestOverview(git)),
24868
25440
  safe(getTagOverview(git)),
@@ -24871,12 +25443,16 @@ async function loadLogInkContext(git) {
24871
25443
  safe(getWorktreeListOverview(git)),
24872
25444
  safe(getGitOperationOverview(git)),
24873
25445
  safe(getProviderOverview(git)),
25446
+ safe(getReflogOverview(git)),
25447
+ safe(getBisectStatus(git)),
24874
25448
  ]);
24875
25449
  return {
25450
+ bisect,
24876
25451
  branches,
24877
25452
  operation,
24878
25453
  provider,
24879
25454
  pullRequest,
25455
+ reflog,
24880
25456
  stashes,
24881
25457
  tags,
24882
25458
  worktree,
@@ -24902,6 +25478,14 @@ function loadLogInkContextEntries(git) {
24902
25478
  key: 'tags',
24903
25479
  load: () => safe(getTagOverview(git)),
24904
25480
  },
25481
+ {
25482
+ key: 'reflog',
25483
+ load: () => safe(getReflogOverview(git)),
25484
+ },
25485
+ {
25486
+ key: 'bisect',
25487
+ load: () => safe(getBisectStatus(git)),
25488
+ },
24905
25489
  {
24906
25490
  key: 'worktree',
24907
25491
  load: () => safe(getWorktreeOverview(git)),
@@ -25290,6 +25874,10 @@ function LogInkApp(deps) {
25290
25874
  // colors match the commit-diff path.
25291
25875
  const [stashDiffLines, setStashDiffLines] = React.useState(undefined);
25292
25876
  const [stashDiffLoading, setStashDiffLoading] = React.useState(false);
25877
+ // #779 — compare-two-refs diff state. Loaded lazily when the diff
25878
+ // view becomes active with `diffSource === 'compare'`.
25879
+ const [compareDiffLines, setCompareDiffLines] = React.useState(undefined);
25880
+ const [compareDiffLoading, setCompareDiffLoading] = React.useState(false);
25293
25881
  const [hasMoreCommits, setHasMoreCommits] = React.useState(() => (Boolean(logArgv?.interactive && !logArgv.limit) &&
25294
25882
  getCommitRows(rows).length >= LOG_INTERACTIVE_DEFAULT_LIMIT));
25295
25883
  const [loadingMoreCommits, setLoadingMoreCommits] = React.useState(false);
@@ -25384,6 +25972,12 @@ function LogInkApp(deps) {
25384
25972
  return all;
25385
25973
  return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
25386
25974
  }, [context.worktreeList?.worktrees, state.filter]);
25975
+ const filteredReflogList = React.useMemo(() => {
25976
+ const all = context.reflog?.entries || [];
25977
+ if (!state.filter)
25978
+ return all;
25979
+ return all.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter));
25980
+ }, [context.reflog?.entries, state.filter]);
25387
25981
  const dispatch = React.useCallback((action) => {
25388
25982
  setState((current) => applyLogInkAction(current, action));
25389
25983
  }, []);
@@ -25594,6 +26188,70 @@ function LogInkApp(deps) {
25594
26188
  })();
25595
26189
  return () => { active = false; };
25596
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]);
26220
+ // #779 — load `git diff <base>..<head>` once the diff view becomes
26221
+ // active with diffSource='compare'. Mirrors the stash loader's
26222
+ // shape; the surface renders the lines via the same +/-/@@ coloring
26223
+ // path. On unknown ref / git error, `safe()` swallows and the
26224
+ // surface falls back to a "no diff" hint.
26225
+ const compareBaseRef = state.compareBase?.ref;
26226
+ const compareHeadRef = state.compareHead?.ref;
26227
+ React.useEffect(() => {
26228
+ if (state.activeView !== 'diff' ||
26229
+ state.diffSource !== 'compare' ||
26230
+ !compareBaseRef ||
26231
+ !compareHeadRef) {
26232
+ return;
26233
+ }
26234
+ let active = true;
26235
+ setCompareDiffLoading(true);
26236
+ void (async () => {
26237
+ const lines = await safe(getCompareDiff(git, compareBaseRef, compareHeadRef));
26238
+ if (active) {
26239
+ setCompareDiffLines(lines || []);
26240
+ setCompareDiffLoading(false);
26241
+ }
26242
+ })();
26243
+ return () => { active = false; };
26244
+ }, [git, state.activeView, state.diffSource, compareBaseRef, compareHeadRef]);
26245
+ // Reset compare-diff state whenever the diff view exits. Without
26246
+ // this, opening a new compare immediately after closing one would
26247
+ // briefly show the previous comparison's lines while the new
26248
+ // loader runs.
26249
+ React.useEffect(() => {
26250
+ if (state.diffSource !== 'compare') {
26251
+ setCompareDiffLines(undefined);
26252
+ setCompareDiffLoading(false);
26253
+ }
26254
+ }, [state.diffSource]);
25597
26255
  React.useEffect(() => {
25598
26256
  let active = true;
25599
26257
  async function loadWorktreeHunks() {
@@ -26104,6 +26762,50 @@ function LogInkApp(deps) {
26104
26762
  return { ok: false, message: 'No stash selected' };
26105
26763
  return popStash(git, stash);
26106
26764
  },
26765
+ 'bisect-good': async () => {
26766
+ if (!context.bisect?.active)
26767
+ return { ok: false, message: 'No bisect in progress' };
26768
+ try {
26769
+ const stdout = await bisectGood(git);
26770
+ return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked good' };
26771
+ }
26772
+ catch (error) {
26773
+ return { ok: false, message: `Bisect good failed: ${error.message}` };
26774
+ }
26775
+ },
26776
+ 'bisect-bad': async () => {
26777
+ if (!context.bisect?.active)
26778
+ return { ok: false, message: 'No bisect in progress' };
26779
+ try {
26780
+ const stdout = await bisectBad(git);
26781
+ return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked bad' };
26782
+ }
26783
+ catch (error) {
26784
+ return { ok: false, message: `Bisect bad failed: ${error.message}` };
26785
+ }
26786
+ },
26787
+ 'bisect-skip': async () => {
26788
+ if (!context.bisect?.active)
26789
+ return { ok: false, message: 'No bisect in progress' };
26790
+ try {
26791
+ const stdout = await bisectSkip(git);
26792
+ return { ok: true, message: extractBisectRemainingHint(stdout) || 'Skipped' };
26793
+ }
26794
+ catch (error) {
26795
+ return { ok: false, message: `Bisect skip failed: ${error.message}` };
26796
+ }
26797
+ },
26798
+ 'bisect-reset': async () => {
26799
+ if (!context.bisect?.active)
26800
+ return { ok: false, message: 'No bisect in progress' };
26801
+ try {
26802
+ await bisectReset(git);
26803
+ return { ok: true, message: 'Bisect reset' };
26804
+ }
26805
+ catch (error) {
26806
+ return { ok: false, message: `Bisect reset failed: ${error.message}` };
26807
+ }
26808
+ },
26107
26809
  'checkout-file-from-stash': async () => {
26108
26810
  const path = payload?.trim();
26109
26811
  const ref = state.stashDiffRef;
@@ -26776,9 +27478,13 @@ function LogInkApp(deps) {
26776
27478
  // perf pass) — reading them here is O(1) instead of O(branches +
26777
27479
  // tags + stashes + worktrees) per keystroke.
26778
27480
  const branchVisibleCount = filteredBranchList.length;
27481
+ const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
26779
27482
  const tagVisibleCount = filteredTagList.length;
27483
+ const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
26780
27484
  const stashVisibleCount = filteredStashList.length;
26781
27485
  const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
27486
+ const reflogVisibleCount = filteredReflogList.length;
27487
+ const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
26782
27488
  const worktreeVisibleCount = filteredWorktreeList.length;
26783
27489
  // When the diff view is showing a stash patch, swap the previewLineCount
26784
27490
  // to the stash diff length so the existing pageDetailPreview path
@@ -26803,8 +27509,12 @@ function LogInkApp(deps) {
26803
27509
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
26804
27510
  commitDiffHunkOffsets,
26805
27511
  branchCount: branchVisibleCount,
27512
+ branchSelectedShortName,
26806
27513
  tagCount: tagVisibleCount,
27514
+ tagSelectedName,
26807
27515
  stashCount: stashVisibleCount,
27516
+ reflogCount: reflogVisibleCount,
27517
+ reflogSelectedHash,
26808
27518
  stashSelectedRef,
26809
27519
  stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
26810
27520
  stashDiffSelectedPath,
@@ -26910,12 +27620,15 @@ function LogInkApp(deps) {
26910
27620
  if (showOnboarding) {
26911
27621
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
26912
27622
  }
26913
- 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, 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));
26914
27624
  }
26915
27625
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
26916
27626
  const { Box, Text } = components;
26917
27627
  const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
26918
- const dirty = context.branches?.dirty ? 'dirty' : 'clean';
27628
+ // #784 surface bisect-in-progress in the title bar so users entering
27629
+ // the TUI mid-bisect see it immediately, before they navigate to gB.
27630
+ const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
27631
+ const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
26919
27632
  const repo = context.provider?.repository.owner && context.provider.repository.name
26920
27633
  ? `${context.provider.repository.owner}/${context.provider.repository.name}`
26921
27634
  : 'local repository';
@@ -27170,12 +27883,12 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
27170
27883
  : []),
27171
27884
  ];
27172
27885
  }
27173
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, 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) {
27174
27887
  if (state.activeView === 'status') {
27175
27888
  return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27176
27889
  }
27177
27890
  if (state.activeView === 'diff') {
27178
- return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme);
27891
+ return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme);
27179
27892
  }
27180
27893
  if (state.activeView === 'compose') {
27181
27894
  return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
@@ -27186,6 +27899,12 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
27186
27899
  if (state.activeView === 'tags') {
27187
27900
  return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27188
27901
  }
27902
+ if (state.activeView === 'reflog') {
27903
+ return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27904
+ }
27905
+ if (state.activeView === 'bisect') {
27906
+ return renderBisectSurface(h, components, state, context, contextStatus, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme);
27907
+ }
27189
27908
  if (state.activeView === 'stash') {
27190
27909
  return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27191
27910
  }
@@ -27813,6 +28532,211 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
27813
28532
  width,
27814
28533
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
27815
28534
  }
28535
+ /**
28536
+ * Promoted reflog browser (#781). Mirrors `renderTagsSurface` visually
28537
+ * — same header / filter affordance / footer hint conventions — but
28538
+ * lays out four columns per row: relative date, action prefix, short
28539
+ * hash, and message. Filtering matches against all four (so typing
28540
+ * "checkout" narrows to checkout entries, "abc" narrows to a hash).
28541
+ *
28542
+ * Per-row layout uses fixed column widths derived from the visible
28543
+ * window so short-action rows don't leave a wide gutter and long
28544
+ * actions don't push the message off-screen. The cap mirrors the
28545
+ * tags surface's name-column treatment.
28546
+ */
28547
+ function renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
28548
+ const { Box, Text } = components;
28549
+ const focused = state.focus === 'commits';
28550
+ const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
28551
+ const allEntries = context.reflog?.entries || [];
28552
+ const entries = state.filter
28553
+ ? allEntries.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter))
28554
+ : allEntries;
28555
+ const selected = Math.max(0, Math.min(state.selectedReflogIndex, Math.max(0, entries.length - 1)));
28556
+ const listRows = Math.max(4, bodyRows - 4);
28557
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
28558
+ const visible = entries.slice(startIndex, startIndex + listRows);
28559
+ const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
28560
+ const headerRight = loading
28561
+ ? 'loading reflog'
28562
+ : `${entries.length}/${allEntries.length} entries${filterLabel}`;
28563
+ const emptyLabel = formatLogInkReflogEmpty({ filter: state.filter });
28564
+ const loadingLabel = formatLogInkLoading({ resource: 'reflog' });
28565
+ // Column widths derived from the visible window. The hash column is
28566
+ // fixed (short SHA is always 7 chars) and the date column caps so
28567
+ // "X minutes ago" / "Y hours ago" stays readable without dominating
28568
+ // the row. Action column scales to the longest visible action so
28569
+ // commit / checkout / merge align cleanly.
28570
+ const splitVisible = visible.map((entry) => ({
28571
+ entry,
28572
+ parts: splitReflogSubject(entry.subject),
28573
+ }));
28574
+ const dateColWidth = splitVisible.length === 0
28575
+ ? 16
28576
+ : Math.min(20, Math.max(6, ...splitVisible.map(({ entry }) => entry.relativeDate.length)));
28577
+ const actionColWidth = splitVisible.length === 0
28578
+ ? 12
28579
+ : Math.min(24, Math.max(6, ...splitVisible.map(({ parts }) => parts.action.length)));
28580
+ const hashColWidth = 8;
28581
+ const lines = loading
28582
+ ? [h(Text, { key: 'reflog-loading', dimColor: true }, loadingLabel)]
28583
+ : entries.length === 0
28584
+ ? [h(Text, { key: 'reflog-empty', dimColor: true }, emptyLabel)]
28585
+ : splitVisible.map(({ entry, parts }, offset) => {
28586
+ const index = startIndex + offset;
28587
+ const isSelected = index === selected;
28588
+ const cursor = isSelected ? '>' : ' ';
28589
+ const datePadded = truncate$1(entry.relativeDate, dateColWidth).padEnd(dateColWidth);
28590
+ const actionPadded = truncate$1(parts.action, actionColWidth).padEnd(actionColWidth);
28591
+ const hashPadded = truncate$1(entry.hash, hashColWidth).padEnd(hashColWidth);
28592
+ const message = parts.message || entry.subject;
28593
+ const lineText = truncate$1(`${cursor} ${datePadded} ${actionPadded} ${hashPadded} ${message}`, Math.max(20, width - 4));
28594
+ return h(Text, {
28595
+ key: `reflog-${index}`,
28596
+ bold: isSelected,
28597
+ dimColor: !isSelected,
28598
+ }, lineText);
28599
+ });
28600
+ return h(Box, {
28601
+ borderColor: focusBorderColor(theme, focused),
28602
+ borderStyle: theme.borderStyle,
28603
+ flexDirection: 'column',
28604
+ flexShrink: 0,
28605
+ paddingX: 1,
28606
+ width,
28607
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Reflog', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
28608
+ }
28609
+ /**
28610
+ * Bisect workflow surface (#784). Shows the current candidate commit
28611
+ * (HEAD), a parsed view of recent decisions from `git bisect log`, and
28612
+ * the four action keys (g good, b bad, s skip, x reset).
28613
+ *
28614
+ * When bisect is inactive, the surface renders an empty-state hint
28615
+ * pointing the user at the CLI to start one. The view stays
28616
+ * navigable so the user can read the documentation before starting
28617
+ * — they can't break anything from here.
28618
+ */
28619
+ function renderBisectSurface(h, components, state, context, contextStatus, candidateDetail, candidateLoading, bodyRows, width, theme) {
28620
+ const { Box, Text } = components;
28621
+ const focused = state.focus === 'commits';
28622
+ const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
28623
+ const bisect = context.bisect;
28624
+ const accent = theme.noColor ? undefined : theme.colors.accent;
28625
+ const lines = [];
28626
+ if (loading) {
28627
+ lines.push(h(Text, { key: 'bisect-loading', dimColor: true }, truncate$1('· Loading bisect status…', width - 4)));
28628
+ }
28629
+ else if (!bisect?.active) {
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
+ }
28666
+ }
28667
+ else {
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.
28671
+ const headerSha = bisect.currentSha ? bisect.currentSha.slice(0, 8) : '<unknown>';
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.
28708
+ lines.push(h(Text, { key: 'bisect-active-spacer' }, ''));
28709
+ const decisions = bisect.log.filter((entry) => entry.kind === 'good' || entry.kind === 'bad' || entry.kind === 'skip');
28710
+ if (decisions.length === 0) {
28711
+ lines.push(h(Text, { key: 'bisect-no-decisions', dimColor: true }, truncate$1('No decisions logged yet — press g (good) or b (bad) to record one.', width - 4)));
28712
+ }
28713
+ else {
28714
+ lines.push(h(Text, { key: 'bisect-decisions-header', bold: true }, truncate$1(`Decisions (${decisions.length}):`, width - 4)));
28715
+ const recent = decisions.slice(-Math.max(4, bodyRows - 8));
28716
+ for (const entry of recent) {
28717
+ const kindLabel = entry.kind.toUpperCase().padEnd(5);
28718
+ const sha = (entry.sha || '<unknown>').padEnd(8);
28719
+ const subject = entry.subject || '';
28720
+ const text = ` ${kindLabel} ${sha} ${subject}`;
28721
+ lines.push(h(Text, {
28722
+ key: `bisect-entry-${entry.raw}`,
28723
+ dimColor: entry.kind === 'skip',
28724
+ bold: entry.kind === 'bad',
28725
+ }, truncate$1(text, width - 4)));
28726
+ }
28727
+ }
28728
+ lines.push(h(Text, { key: 'bisect-action-spacer' }, ''));
28729
+ lines.push(h(Text, { key: 'bisect-action-hint', dimColor: true }, truncate$1('Actions: g good · b bad · s skip · x reset', width - 4)));
28730
+ }
28731
+ return h(Box, {
28732
+ borderColor: focusBorderColor(theme, focused),
28733
+ borderStyle: theme.borderStyle,
28734
+ flexDirection: 'column',
28735
+ flexShrink: 0,
28736
+ paddingX: 1,
28737
+ width,
28738
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Bisect', focused)), h(Text, { dimColor: true }, bisect?.active ? 'BISECTING' : 'inactive')), ...lines);
28739
+ }
27816
28740
  function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
27817
28741
  const { Box, Text } = components;
27818
28742
  const focused = state.focus === 'commits';
@@ -28018,7 +28942,7 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
28018
28942
  h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
28019
28943
  ];
28020
28944
  }
28021
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme) {
28945
+ function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme) {
28022
28946
  const { Box, Text } = components;
28023
28947
  const focused = state.focus === 'commits';
28024
28948
  const worktree = context.worktree;
@@ -28109,6 +29033,50 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
28109
29033
  dimColor: index > 0,
28110
29034
  }, truncate$1(line, width - 4))), ...stashBodyNodes);
28111
29035
  }
29036
+ // Compare-two-refs branch (#779). Mirrors the stash diff above but
29037
+ // sourced from `git diff <base>..<head>`. No per-file cherry-pick or
29038
+ // hunk apply — comparing arbitrary refs doesn't have a sensible
29039
+ // mutate-from-here flow, so the surface is read-only navigation.
29040
+ if (state.diffSource === 'compare') {
29041
+ const lines = compareDiffLines || [];
29042
+ const splitActive = isSplitDiffViable(state, width);
29043
+ const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
29044
+ const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
29045
+ const baseLabel = state.compareBase?.label || state.compareBase?.ref || '<base>';
29046
+ const headLabel = state.compareHead?.label || state.compareHead?.ref || '<head>';
29047
+ const compareTitle = `${baseLabel} → ${headLabel}`;
29048
+ const baseHeaderLines = compareDiffLoading
29049
+ ? [`Loading diff for ${compareTitle}...`]
29050
+ : lines.length && (lines.length > 1 || lines[0])
29051
+ ? [
29052
+ compareTitle,
29053
+ `Lines ${Math.min(state.diffPreviewOffset + 1, lines.length)}-${Math.min(state.diffPreviewOffset + visibleLines.length, lines.length)}/${lines.length}`,
29054
+ '',
29055
+ ]
29056
+ : ['No diff to display — refs may resolve to the same tree.'];
29057
+ const headerLines = splitRequestedButTooNarrow
29058
+ ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
29059
+ : baseHeaderLines;
29060
+ const compareBodyNodes = compareDiffLoading || !lines.length || (lines.length === 1 && !lines[0])
29061
+ ? []
29062
+ : splitActive
29063
+ ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'compare-diff-split')
29064
+ : visibleLines.map((line, index) => h(Text, {
29065
+ key: `compare-diff-line-${state.diffPreviewOffset + index}`,
29066
+ ...diffLineProps(line, theme),
29067
+ }, truncate$1(line, width - 4)));
29068
+ return h(Box, {
29069
+ borderColor: focusBorderColor(theme, focused),
29070
+ borderStyle: theme.borderStyle,
29071
+ flexDirection: 'column',
29072
+ flexShrink: 0,
29073
+ paddingX: 1,
29074
+ width,
29075
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(splitActive ? 'Compare (split)' : 'Compare', focused)), h(Text, { dimColor: true }, truncate$1(compareTitle, Math.max(20, Math.floor(width / 2))))), ...headerLines.map((line, index) => h(Text, {
29076
+ key: `compare-diff-header-${index}`,
29077
+ dimColor: index > 0,
29078
+ }, truncate$1(line, width - 4))), ...compareBodyNodes);
29079
+ }
28112
29080
  // diffSource disambiguates: 'commit' was set when the user opened the
28113
29081
  // diff via history → Enter (read-only commit-diff explore), 'worktree'
28114
29082
  // was set when they came from status → Enter (stage / hunk / revert).
@@ -28967,6 +29935,7 @@ function renderFooter(h, components, state, context, theme, idleTip) {
28967
29935
  showHelp: state.showHelp,
28968
29936
  sidebarTab: state.sidebarTab,
28969
29937
  sidebarItemCount,
29938
+ compareBaseSet: Boolean(state.compareBase),
28970
29939
  });
28971
29940
  // Real status messages always win; idle tips only fill the slot when it
28972
29941
  // would otherwise be empty.