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.
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.45.0";
81
+ const BUILD_VERSION = "0.47.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -14545,7 +14545,7 @@ const builder$3 = (yargs) => {
14545
14545
  return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
14546
14546
  };
14547
14547
 
14548
- const FIELD_SEPARATOR$2 = '\x1f';
14548
+ const FIELD_SEPARATOR$3 = '\x1f';
14549
14549
  // `%P` (parent hashes, space-separated) lets the TUI distinguish
14550
14550
  // merge commits (parents.length > 1) from regular commits without a
14551
14551
  // second round-trip to git. See #791 stage 3 — merge glyph + HEAD ring.
@@ -14598,13 +14598,13 @@ function parseLogOutput(output) {
14598
14598
  .map((line) => line.trimEnd())
14599
14599
  .filter(Boolean)
14600
14600
  .map((line) => {
14601
- if (!line.includes(FIELD_SEPARATOR$2)) {
14601
+ if (!line.includes(FIELD_SEPARATOR$3)) {
14602
14602
  return {
14603
14603
  type: 'graph',
14604
14604
  graph: line,
14605
14605
  };
14606
14606
  }
14607
- const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$2);
14607
+ const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$3);
14608
14608
  return {
14609
14609
  type: 'commit',
14610
14610
  graph: graph.trimEnd(),
@@ -14679,7 +14679,7 @@ function parseNameStatus(output, numstat = []) {
14679
14679
  function parseCommitDetail(metadata, files, numstatOutput = '') {
14680
14680
  const [hash, shortHash, parentsStr, date, author, refs, message, body = ''] = metadata
14681
14681
  .trimEnd()
14682
- .split(FIELD_SEPARATOR$2);
14682
+ .split(FIELD_SEPARATOR$3);
14683
14683
  const numstat = parseNumstat(numstatOutput);
14684
14684
  return {
14685
14685
  shortHash,
@@ -14819,14 +14819,14 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
14819
14819
  };
14820
14820
  }
14821
14821
 
14822
- const FIELD_SEPARATOR$1 = '\x1f';
14822
+ const FIELD_SEPARATOR$2 = '\x1f';
14823
14823
  function parseBranchRefs(output) {
14824
14824
  return output
14825
14825
  .split('\n')
14826
14826
  .map((line) => line.trimEnd())
14827
14827
  .filter(Boolean)
14828
14828
  .map((line) => {
14829
- const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$1);
14829
+ const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$2);
14830
14830
  if (!refName || !shortName) {
14831
14831
  return undefined;
14832
14832
  }
@@ -14866,7 +14866,7 @@ async function getBranchOverview(git) {
14866
14866
  const [branchOutput, statusOutput, currentBranchOutput] = await Promise.all([
14867
14867
  git.raw([
14868
14868
  'for-each-ref',
14869
- `--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)`,
14869
+ `--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)`,
14870
14870
  'refs/heads',
14871
14871
  'refs/remotes',
14872
14872
  ]),
@@ -15416,10 +15416,12 @@ async function runCommitDraftWorkflow(input = {}) {
15416
15416
  }
15417
15417
 
15418
15418
  const LOG_INK_CONTEXT_KEYS = [
15419
+ 'bisect',
15419
15420
  'branches',
15420
15421
  'operation',
15421
15422
  'provider',
15422
15423
  'pullRequest',
15424
+ 'reflog',
15423
15425
  'stashes',
15424
15426
  'tags',
15425
15427
  'worktree',
@@ -16139,6 +16141,45 @@ function getLogInkWorkflowActions() {
16139
16141
  kind: 'normal',
16140
16142
  requiresConfirmation: false,
16141
16143
  },
16144
+ {
16145
+ // #784 — bisect workflow actions. All four are scoped per-view in
16146
+ // inkInput (active only when activeView === 'bisect') so the
16147
+ // single-letter keys stay free elsewhere. Empty `key` keeps them
16148
+ // palette-discoverable. Reset is the only destructive one — it
16149
+ // throws away the bisect state — so it routes through y-confirm;
16150
+ // good / bad / skip are recoverable via `git bisect log` and run
16151
+ // immediately.
16152
+ id: 'bisect-good',
16153
+ key: '',
16154
+ label: 'Bisect: mark good',
16155
+ description: 'Mark the current bisect candidate as good and advance to the next one.',
16156
+ kind: 'normal',
16157
+ requiresConfirmation: false,
16158
+ },
16159
+ {
16160
+ id: 'bisect-bad',
16161
+ key: '',
16162
+ label: 'Bisect: mark bad',
16163
+ description: 'Mark the current bisect candidate as bad and advance to the next one.',
16164
+ kind: 'normal',
16165
+ requiresConfirmation: false,
16166
+ },
16167
+ {
16168
+ id: 'bisect-skip',
16169
+ key: '',
16170
+ label: 'Bisect: skip candidate',
16171
+ description: 'Skip the current bisect candidate (e.g. it does not build) and advance.',
16172
+ kind: 'normal',
16173
+ requiresConfirmation: false,
16174
+ },
16175
+ {
16176
+ id: 'bisect-reset',
16177
+ key: '',
16178
+ label: 'Bisect: reset',
16179
+ description: 'End the bisect session and restore HEAD. Discards in-progress bisect state.',
16180
+ kind: 'destructive',
16181
+ requiresConfirmation: true,
16182
+ },
16142
16183
  {
16143
16184
  id: 'ai-commit-summary',
16144
16185
  key: 'I',
@@ -16375,6 +16416,27 @@ const LOG_INK_KEY_BINDINGS = [
16375
16416
  description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
16376
16417
  contexts: ['normal'],
16377
16418
  },
16419
+ {
16420
+ id: 'navigateReflog',
16421
+ keys: ['gr'],
16422
+ label: 'reflog',
16423
+ description: 'Push the reflog browser view — chronological recovery log.',
16424
+ contexts: ['normal'],
16425
+ },
16426
+ {
16427
+ id: 'navigateBisect',
16428
+ keys: ['gB'],
16429
+ label: 'bisect',
16430
+ 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.',
16431
+ contexts: ['normal'],
16432
+ },
16433
+ {
16434
+ id: 'markForCompare',
16435
+ keys: ['m'],
16436
+ label: 'mark compare',
16437
+ 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.',
16438
+ contexts: ['commits'],
16439
+ },
16378
16440
  {
16379
16441
  id: 'navigateBack',
16380
16442
  keys: ['<', 'esc'],
@@ -16505,6 +16567,8 @@ const GLOBAL_BINDING_IDS = [
16505
16567
  'navigateWorktrees',
16506
16568
  'navigatePullRequest',
16507
16569
  'navigateConflicts',
16570
+ 'navigateReflog',
16571
+ 'navigateBisect',
16508
16572
  'navigateBack',
16509
16573
  ];
16510
16574
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -16632,6 +16696,15 @@ function getLogInkFooterHints(options) {
16632
16696
  global: NORMAL_GLOBAL_HINTS,
16633
16697
  };
16634
16698
  }
16699
+ if (options.diffSource === 'compare') {
16700
+ // Compare-two-refs (#779): read-only diff with no per-file
16701
+ // cherry-pick or hunk apply (those don't make sense across
16702
+ // arbitrary refs). Just scroll + back out.
16703
+ return {
16704
+ contextual: ['j/k lines', splitToggleHint, 'esc back'],
16705
+ global: NORMAL_GLOBAL_HINTS,
16706
+ };
16707
+ }
16635
16708
  return {
16636
16709
  contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'y yank'],
16637
16710
  global: NORMAL_GLOBAL_HINTS,
@@ -16644,14 +16717,26 @@ function getLogInkFooterHints(options) {
16644
16717
  };
16645
16718
  }
16646
16719
  if (options.activeView === 'branches') {
16720
+ if (options.compareBaseSet) {
16721
+ return {
16722
+ contextual: ['↑/↓ branches', 'enter compare', 'm clear', 'esc back'],
16723
+ global: NORMAL_GLOBAL_HINTS,
16724
+ };
16725
+ }
16647
16726
  return {
16648
- contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 's sort', 'y yank'],
16727
+ contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 'm compare', 's sort', 'y yank'],
16649
16728
  global: NORMAL_GLOBAL_HINTS,
16650
16729
  };
16651
16730
  }
16652
16731
  if (options.activeView === 'tags') {
16732
+ if (options.compareBaseSet) {
16733
+ return {
16734
+ contextual: ['↑/↓ tags', 'enter compare', 'm clear', 'esc back'],
16735
+ global: NORMAL_GLOBAL_HINTS,
16736
+ };
16737
+ }
16653
16738
  return {
16654
- contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 's sort', 'y yank'],
16739
+ contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 'm compare', 's sort', 'y yank'],
16655
16740
  global: NORMAL_GLOBAL_HINTS,
16656
16741
  };
16657
16742
  }
@@ -16683,6 +16768,28 @@ function getLogInkFooterHints(options) {
16683
16768
  global: NORMAL_GLOBAL_HINTS,
16684
16769
  };
16685
16770
  }
16771
+ if (options.activeView === 'reflog') {
16772
+ return {
16773
+ contextual: ['↑/↓ entries', 'enter inspect', 'esc back'],
16774
+ global: NORMAL_GLOBAL_HINTS,
16775
+ };
16776
+ }
16777
+ if (options.activeView === 'bisect') {
16778
+ return {
16779
+ contextual: ['g good', 'b bad', 's skip', 'x reset', 'esc back'],
16780
+ global: NORMAL_GLOBAL_HINTS,
16781
+ };
16782
+ }
16783
+ if (options.compareBaseSet) {
16784
+ // History view with a compare base set — Enter is overridden to
16785
+ // open the compare diff; show the override + the bail-out key.
16786
+ // Mutate / new chips are dropped so the footer doesn't compete
16787
+ // with the active workflow.
16788
+ return {
16789
+ contextual: ['↑/↓ move', 'enter compare', 'm clear', 'esc back'],
16790
+ global: NORMAL_GLOBAL_HINTS,
16791
+ };
16792
+ }
16686
16793
  return {
16687
16794
  // History view default hints. Mutating ops (`c` cherry-pick, `R`
16688
16795
  // revert, `Z` reset, `i` interactive-rebase) all route through a
@@ -16692,7 +16799,7 @@ function getLogInkFooterHints(options) {
16692
16799
  // Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
16693
16800
  // the footer stays scannable; full descriptions live in `?` help
16694
16801
  // and the palette.
16695
- contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
16802
+ contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'm compare', 'y/Y yank', '/ search'],
16696
16803
  global: NORMAL_GLOBAL_HINTS,
16697
16804
  };
16698
16805
  }
@@ -17271,6 +17378,7 @@ function withPushedView(state, value) {
17271
17378
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17272
17379
  diffSource: value === 'diff' ? state.diffSource : undefined,
17273
17380
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
17381
+ compareHead: value === 'diff' ? state.compareHead : undefined,
17274
17382
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
17275
17383
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
17276
17384
  pendingKey: undefined,
@@ -17282,6 +17390,13 @@ function withPoppedView(state) {
17282
17390
  }
17283
17391
  const viewStack = state.viewStack.slice(0, -1);
17284
17392
  const next = topOfStack(viewStack);
17393
+ // #779 — compareBase is "cleared when the diff view is popped." We
17394
+ // detect that case by checking if the *previous* top was 'diff'.
17395
+ // The compare workflow ends when the user backs out of the compare
17396
+ // diff; on the next mark they re-set the base. Other view pops
17397
+ // preserve compareBase so the user can move between branches / tags /
17398
+ // history while hunting for a head ref.
17399
+ const wasOnDiff = state.activeView === 'diff';
17285
17400
  return {
17286
17401
  ...state,
17287
17402
  activeView: next,
@@ -17294,6 +17409,8 @@ function withPoppedView(state) {
17294
17409
  selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17295
17410
  diffSource: next === 'diff' ? state.diffSource : undefined,
17296
17411
  stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
17412
+ compareBase: wasOnDiff ? undefined : state.compareBase,
17413
+ compareHead: next === 'diff' ? state.compareHead : undefined,
17297
17414
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
17298
17415
  statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
17299
17416
  pendingKey: undefined,
@@ -17312,6 +17429,7 @@ function withReplacedView(state, value) {
17312
17429
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17313
17430
  diffSource: value === 'diff' ? state.diffSource : undefined,
17314
17431
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
17432
+ compareHead: value === 'diff' ? state.compareHead : undefined,
17315
17433
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
17316
17434
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
17317
17435
  pendingKey: undefined,
@@ -17332,6 +17450,11 @@ function withFilter$1(state, filter, promotedSelections) {
17332
17450
  (filterChanged ? 0 : state.selectedTagIndex);
17333
17451
  const stashIndex = promotedSelections?.stashIndex ??
17334
17452
  (filterChanged ? 0 : state.selectedStashIndex);
17453
+ // Reflog (#781) snaps to 0 on filter change rather than rectifying.
17454
+ // The list is chronological and the user is unlikely to be tracking
17455
+ // a specific entry through filter changes — the simpler reset
17456
+ // matches the "find recovery target by typing" interaction.
17457
+ const reflogIndex = filterChanged ? 0 : state.selectedReflogIndex;
17335
17458
  return {
17336
17459
  ...state,
17337
17460
  filter,
@@ -17341,6 +17464,7 @@ function withFilter$1(state, filter, promotedSelections) {
17341
17464
  selectedBranchIndex: branchIndex,
17342
17465
  selectedTagIndex: tagIndex,
17343
17466
  selectedStashIndex: stashIndex,
17467
+ selectedReflogIndex: reflogIndex,
17344
17468
  diffPreviewOffset: 0,
17345
17469
  pendingKey: undefined,
17346
17470
  };
@@ -17430,6 +17554,7 @@ function createLogInkState(rows, options = {}) {
17430
17554
  selectedStashIndex: 0,
17431
17555
  selectedWorktreeListIndex: 0,
17432
17556
  selectedConflictFileIndex: 0,
17557
+ selectedReflogIndex: 0,
17433
17558
  branchSort: DEFAULT_BRANCH_SORT_MODE,
17434
17559
  tagSort: DEFAULT_TAG_SORT_MODE,
17435
17560
  paletteFilter: '',
@@ -17677,6 +17802,12 @@ function applyLogInkAction(state, action) {
17677
17802
  selectedStashIndex: clampIndex$1(state.selectedStashIndex + action.delta, action.count),
17678
17803
  pendingKey: undefined,
17679
17804
  };
17805
+ case 'moveReflog':
17806
+ return {
17807
+ ...state,
17808
+ selectedReflogIndex: clampIndex$1(state.selectedReflogIndex + action.delta, action.count),
17809
+ pendingKey: undefined,
17810
+ };
17680
17811
  case 'moveWorktreeListEntry':
17681
17812
  return {
17682
17813
  ...state,
@@ -17901,6 +18032,31 @@ function applyLogInkAction(state, action) {
17901
18032
  worktreeDiffOffset: 0,
17902
18033
  };
17903
18034
  }
18035
+ case 'navigateOpenDiffForCompare': {
18036
+ const next = withPushedView(state, 'diff');
18037
+ return {
18038
+ ...next,
18039
+ diffSource: 'compare',
18040
+ compareBase: action.base,
18041
+ compareHead: action.head,
18042
+ // Reset scroll offset so the compare patch always opens at
18043
+ // the top — same reasoning as the stash branch above.
18044
+ diffPreviewOffset: 0,
18045
+ worktreeDiffOffset: 0,
18046
+ };
18047
+ }
18048
+ case 'setCompareBase':
18049
+ return {
18050
+ ...state,
18051
+ compareBase: action.value,
18052
+ pendingKey: undefined,
18053
+ };
18054
+ case 'clearCompareBase':
18055
+ return {
18056
+ ...state,
18057
+ compareBase: undefined,
18058
+ pendingKey: undefined,
18059
+ };
17904
18060
  case 'navigateOpenComposeForFile': {
17905
18061
  const next = withPushedView(state, 'status');
17906
18062
  return {
@@ -18256,10 +18412,66 @@ function isStashActionTarget(state) {
18256
18412
  return (state.activeView === 'stash' && state.focus === 'commits') ||
18257
18413
  (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
18258
18414
  }
18415
+ /**
18416
+ * Reflog has no sidebar tab — only the dedicated promoted view (#781).
18417
+ * The condition stays as a single helper anyway so navigation handlers
18418
+ * can read it the same way they do for the other promoted views.
18419
+ */
18420
+ function isReflogActionTarget(state) {
18421
+ return state.activeView === 'reflog' && state.focus === 'commits';
18422
+ }
18259
18423
  function isWorktreeActionTarget(state) {
18260
18424
  return (state.activeView === 'worktrees' && state.focus === 'commits') ||
18261
18425
  (state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
18262
18426
  }
18427
+ /**
18428
+ * Compare-flow target views (#779). The `m` mark + Enter-as-compare
18429
+ * overrides only fire on rows that represent a single ref the user
18430
+ * could pass to `git diff <ref>..<ref>` — branches, tags, and history
18431
+ * commits. The reflog view is intentionally excluded because reflog
18432
+ * entries are *moves* of HEAD, not refs a user typically diffs against.
18433
+ */
18434
+ function isCompareFlowTarget(state) {
18435
+ if (state.focus !== 'commits')
18436
+ return false;
18437
+ return state.activeView === 'branches' ||
18438
+ state.activeView === 'tags' ||
18439
+ state.activeView === 'history';
18440
+ }
18441
+ /**
18442
+ * Resolve the cursored ref for the compare flow (#779). Pulls the
18443
+ * concrete ref + label off context for branches / tags, and reads the
18444
+ * commit row from state for history. Returns undefined when no usable
18445
+ * ref is under the cursor (e.g., the views are empty, or the focus is
18446
+ * on the synthetic "(+) new commit" row).
18447
+ */
18448
+ function getCursoredCompareRef(state, context) {
18449
+ if (state.activeView === 'branches' && context.branchSelectedShortName) {
18450
+ return {
18451
+ kind: 'branch',
18452
+ ref: context.branchSelectedShortName,
18453
+ label: context.branchSelectedShortName,
18454
+ };
18455
+ }
18456
+ if (state.activeView === 'tags' && context.tagSelectedName) {
18457
+ return {
18458
+ kind: 'tag',
18459
+ ref: context.tagSelectedName,
18460
+ label: context.tagSelectedName,
18461
+ };
18462
+ }
18463
+ if (state.activeView === 'history' && !state.pendingCommitFocused) {
18464
+ const commit = state.filteredCommits[state.selectedIndex];
18465
+ if (commit) {
18466
+ return {
18467
+ kind: 'commit',
18468
+ ref: commit.hash,
18469
+ label: `${commit.shortHash} ${commit.message}`.trim(),
18470
+ };
18471
+ }
18472
+ }
18473
+ return undefined;
18474
+ }
18263
18475
  /**
18264
18476
  * Item count for the active sidebar tab — used by the generic
18265
18477
  * sidebar-Enter handler to decide whether to defer to the per-entity
@@ -18359,6 +18571,20 @@ function getLogInkPaletteExecuteEvents(command, state) {
18359
18571
  return [action({ type: 'pushView', value: 'pull-request' })];
18360
18572
  case 'navigateConflicts':
18361
18573
  return [action({ type: 'pushView', value: 'conflicts' })];
18574
+ case 'navigateReflog':
18575
+ return [action({ type: 'pushView', value: 'reflog' })];
18576
+ case 'navigateBisect':
18577
+ return [action({ type: 'pushView', value: 'bisect' })];
18578
+ case 'markForCompare':
18579
+ // Palette context can't reach the cursored ref (filtered branch /
18580
+ // tag lists live in runtime state, not the reducer). Surface a
18581
+ // hint and let the user press `m` directly on the row. The
18582
+ // inline keypress handler further down in this file does the
18583
+ // actual work and has access to the necessary context.
18584
+ return [action({
18585
+ type: 'setStatus',
18586
+ value: 'open branches / tags / history and press m on the cursored ref',
18587
+ })];
18362
18588
  case 'navigateBack':
18363
18589
  return [action({ type: 'popView' })];
18364
18590
  case 'openSelected': {
@@ -18836,6 +19062,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18836
19062
  action({ type: 'setStatus', value: 'jumped to conflicts' }),
18837
19063
  ];
18838
19064
  }
19065
+ // `gr` chord: jump to the reflog browser (#781). Recovery view —
19066
+ // chronological list of reflog entries with Enter to drill into the
19067
+ // commit-diff for the entry's hash. Loaded lazily by the runtime.
19068
+ if (state.pendingKey === 'g' && inputValue === 'r') {
19069
+ return [
19070
+ action({ type: 'pushView', value: 'reflog' }),
19071
+ action({ type: 'setStatus', value: 'jumped to reflog' }),
19072
+ ];
19073
+ }
19074
+ // `gB` chord: jump to the bisect workflow view (#784). Capital B
19075
+ // disambiguates from `gb` (branches). Always navigates — even when
19076
+ // bisect is inactive — so the user can see the empty-state hint and
19077
+ // know how to start one. The view's surface tells them the next step.
19078
+ if (state.pendingKey === 'g' && inputValue === 'B') {
19079
+ return [
19080
+ action({ type: 'pushView', value: 'bisect' }),
19081
+ action({ type: 'setStatus', value: 'jumped to bisect' }),
19082
+ ];
19083
+ }
18839
19084
  // `gH` chord: apply the cursored hunk to the index (`git apply
18840
19085
  // --cached`). Sibling of bare `H` which targets the worktree.
18841
19086
  // Discoverable via the footer hint on diff views and the help
@@ -18875,6 +19120,29 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18875
19120
  action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
18876
19121
  ];
18877
19122
  }
19123
+ // #784 — bisect view action keys. Scoped to `state.activeView ===
19124
+ // 'bisect' && state.focus === 'commits'` so the single-letter keys
19125
+ // stay free everywhere else. `g` and `b` collide with the global
19126
+ // chord prefix and the `gb` continuation respectively — placed
19127
+ // BEFORE the bare-`g` chord trigger below so a `g` keystroke on
19128
+ // the bisect view marks good rather than entering chord mode. The
19129
+ // user's path back out of bisect is `<` / `esc`, never a chord;
19130
+ // the in-bisect view itself can't navigate elsewhere via `g`-prefix
19131
+ // chords until the user exits with `esc` first.
19132
+ if (state.activeView === 'bisect' && state.focus === 'commits') {
19133
+ if (inputValue === 'g' && state.pendingKey !== 'g') {
19134
+ return [{ type: 'runWorkflowAction', id: 'bisect-good' }];
19135
+ }
19136
+ if (inputValue === 'b' && state.pendingKey !== 'g') {
19137
+ return [{ type: 'runWorkflowAction', id: 'bisect-bad' }];
19138
+ }
19139
+ if (inputValue === 's') {
19140
+ return [{ type: 'runWorkflowAction', id: 'bisect-skip' }];
19141
+ }
19142
+ if (inputValue === 'x') {
19143
+ return [action({ type: 'setPendingConfirmation', value: 'bisect-reset' })];
19144
+ }
19145
+ }
18878
19146
  if (inputValue === 'g') {
18879
19147
  if (state.pendingKey === 'g') {
18880
19148
  return [
@@ -19142,6 +19410,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19142
19410
  if (isStashActionTarget(state) && context.stashCount) {
19143
19411
  return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
19144
19412
  }
19413
+ if (isReflogActionTarget(state) && context.reflogCount) {
19414
+ return [action({ type: 'moveReflog', delta: -1, count: context.reflogCount })];
19415
+ }
19145
19416
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
19146
19417
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
19147
19418
  }
@@ -19223,6 +19494,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19223
19494
  if (isStashActionTarget(state) && context.stashCount) {
19224
19495
  return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
19225
19496
  }
19497
+ if (isReflogActionTarget(state) && context.reflogCount) {
19498
+ return [action({ type: 'moveReflog', delta: 1, count: context.reflogCount })];
19499
+ }
19226
19500
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
19227
19501
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
19228
19502
  }
@@ -19294,6 +19568,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19294
19568
  action({ type: 'setStatus', value: 'staging worktree changes' }),
19295
19569
  ];
19296
19570
  }
19571
+ // Compare-flow Enter override (#779). When `compareBase` is set and
19572
+ // the user presses Enter on a branch / tag / history commit row, we
19573
+ // open the compare diff (base..head) instead of the row's normal
19574
+ // action (checkout / drill-in / diff). Scoped to compare-flow
19575
+ // targets so non-flow views keep their Enter intact. Runs BEFORE
19576
+ // the per-row Enter handlers below so the override wins, including
19577
+ // before the history-row drill-in.
19578
+ if (key.return && state.compareBase && isCompareFlowTarget(state)) {
19579
+ const head = getCursoredCompareRef(state, context);
19580
+ if (!head) {
19581
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
19582
+ }
19583
+ if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
19584
+ return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
19585
+ }
19586
+ return [
19587
+ action({
19588
+ type: 'navigateOpenDiffForCompare',
19589
+ base: state.compareBase,
19590
+ head,
19591
+ }),
19592
+ action({ type: 'setStatus', value: `Comparing ${state.compareBase.label} → ${head.label}` }),
19593
+ ];
19594
+ }
19297
19595
  if (key.return &&
19298
19596
  state.activeView === 'history' &&
19299
19597
  state.focus === 'commits' &&
@@ -19310,6 +19608,28 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19310
19608
  ];
19311
19609
  }
19312
19610
  }
19611
+ // Enter on a reflog row drills into the diff for that entry's hash
19612
+ // (#781). Reuses `navigateOpenDiffForCommit`, which finds the commit
19613
+ // by hash in `state.filteredCommits` first and falls back to
19614
+ // `commitIndex` only when the hash isn't present. Reflog hashes that
19615
+ // exist in the loaded history (the common case) drill in cleanly;
19616
+ // dangling-commit hashes fall back to the index. The `commitIndex`
19617
+ // we pass is best-effort — index in `state.commits` if found, else
19618
+ // `state.selectedIndex` so the cursor stays sane on the diff view.
19619
+ if (key.return &&
19620
+ isReflogActionTarget(state) &&
19621
+ context.reflogSelectedHash) {
19622
+ const sha = context.reflogSelectedHash;
19623
+ const fallbackIndex = state.commits.findIndex((commit) => commit.hash === sha);
19624
+ return [
19625
+ action({
19626
+ type: 'navigateOpenDiffForCommit',
19627
+ sha,
19628
+ commitIndex: fallbackIndex >= 0 ? fallbackIndex : state.selectedIndex,
19629
+ }),
19630
+ action({ type: 'setStatus', value: `viewing diff for ${sha.slice(0, 7)}` }),
19631
+ ];
19632
+ }
19313
19633
  // Inspector Actions tab: Enter on the cursored action fires its
19314
19634
  // associated event (cherry-pick / revert / yank / etc.). Wins over
19315
19635
  // the file-list Enter below when the user has [/]-toggled to the
@@ -19489,6 +19809,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19489
19809
  if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
19490
19810
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
19491
19811
  }
19812
+ // `m` marks (or un-marks) the cursored ref as the compare base
19813
+ // (#779). Scoped to compare-flow targets so it doesn't collide with
19814
+ // the `m` PR-merge handler further down. The toggle behavior — `m`
19815
+ // again on the same ref clears the base — gives the user a way to
19816
+ // bail out without remembering a separate cancel key.
19817
+ if (inputValue === 'm' && isCompareFlowTarget(state)) {
19818
+ const ref = getCursoredCompareRef(state, context);
19819
+ if (!ref) {
19820
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
19821
+ }
19822
+ if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
19823
+ return [
19824
+ action({ type: 'clearCompareBase' }),
19825
+ action({ type: 'setStatus', value: `Cleared compare base ${ref.label}` }),
19826
+ ];
19827
+ }
19828
+ return [
19829
+ action({ type: 'setCompareBase', value: ref }),
19830
+ action({ type: 'setStatus', value: `Compare base: ${ref.label} — press enter on another ref to diff` }),
19831
+ ];
19832
+ }
19492
19833
  // Per-view worktree action: `D` removes the worktree AND deletes
19493
19834
  // the branch it was tracking (#838). Scoped to the worktrees
19494
19835
  // surface so it intercepts BEFORE the global workflow-by-key
@@ -20867,6 +21208,12 @@ function formatLogInkStatusEmpty({ hasChanges }) {
20867
21208
  }
20868
21209
  return 'Worktree clean. Press gh for history, gb for branches, gz for stash.';
20869
21210
  }
21211
+ function formatLogInkReflogEmpty({ filter }) {
21212
+ if (filter.trim()) {
21213
+ return `No reflog entries match filter '${filter}'. Press ctrl+u to clear.`;
21214
+ }
21215
+ return 'No reflog entries. Activity in this repo will appear here over time.';
21216
+ }
20870
21217
  function formatLogInkComposeEmpty({ hasStaged }) {
20871
21218
  if (hasStaged) {
20872
21219
  return undefined;
@@ -22496,14 +22843,14 @@ function deleteRemoteTag(git, tagName) {
22496
22843
  return runAction$2(() => git.raw(['push', 'origin', `:${tagName}`]), `Deleted remote tag ${tagName}`);
22497
22844
  }
22498
22845
 
22499
- const FIELD_SEPARATOR = '\x1f';
22846
+ const FIELD_SEPARATOR$1 = '\x1f';
22500
22847
  function parseTagRefs(output) {
22501
22848
  return output
22502
22849
  .split('\n')
22503
22850
  .map((line) => line.trimEnd())
22504
22851
  .filter(Boolean)
22505
22852
  .map((line) => {
22506
- const [name, hash, date, subject] = line.split(FIELD_SEPARATOR);
22853
+ const [name, hash, date, subject] = line.split(FIELD_SEPARATOR$1);
22507
22854
  return {
22508
22855
  name,
22509
22856
  hash,
@@ -22515,7 +22862,7 @@ function parseTagRefs(output) {
22515
22862
  async function getTagOverview(git) {
22516
22863
  const output = await git.raw([
22517
22864
  'for-each-ref',
22518
- `--format=%(refname:short)${FIELD_SEPARATOR}%(objectname:short)${FIELD_SEPARATOR}%(creatordate:short)${FIELD_SEPARATOR}%(subject)`,
22865
+ `--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
22519
22866
  '--sort=-creatordate',
22520
22867
  'refs/tags',
22521
22868
  ]);
@@ -24774,6 +25121,231 @@ function formatPullRequestStateLine(pr) {
24774
25121
  return parts.join(' · ');
24775
25122
  }
24776
25123
 
25124
+ const EMPTY_STATUS = {
25125
+ active: false,
25126
+ currentSha: '',
25127
+ log: [],
25128
+ };
25129
+ async function bisectIsActive(git) {
25130
+ try {
25131
+ const path = (await git.revparse(['--git-path', 'BISECT_LOG'])).trim();
25132
+ return path.length > 0 && fs.existsSync(path);
25133
+ }
25134
+ catch {
25135
+ return false;
25136
+ }
25137
+ }
25138
+ /**
25139
+ * Parse the output of `git bisect log` into structured entries. Each
25140
+ * entry corresponds to one user decision (start / good / bad / skip)
25141
+ * or the "# bad: [<sha>] <subject>" comment lines git emits for
25142
+ * traceability. Comment lines without a recognized prefix are dropped
25143
+ * — they're informational headers ("# status: ..."), not actions
25144
+ * the user took.
25145
+ */
25146
+ function parseBisectLog(output) {
25147
+ const entries = [];
25148
+ for (const rawLine of output.split('\n')) {
25149
+ const line = rawLine.trimEnd();
25150
+ if (!line)
25151
+ continue;
25152
+ // Comment rows: "# good: [sha] subject" / "# bad: [sha] subject" /
25153
+ // "# first bad commit: ..." / "# status: ...". The first two carry
25154
+ // the most user-relevant info (which commits were marked) so we
25155
+ // promote them to typed entries; the rest fall through as raw
25156
+ // lines tagged 'unknown' so the renderer can dim them or hide
25157
+ // entirely.
25158
+ if (line.startsWith('#')) {
25159
+ const commentMatch = line.match(/^#\s+(good|bad|skip):\s+\[([^\]]+)\]\s*(.*)$/);
25160
+ if (commentMatch) {
25161
+ entries.push({
25162
+ kind: commentMatch[1],
25163
+ sha: commentMatch[2],
25164
+ subject: commentMatch[3] || undefined,
25165
+ raw: line,
25166
+ });
25167
+ continue;
25168
+ }
25169
+ entries.push({ kind: 'unknown', raw: line });
25170
+ continue;
25171
+ }
25172
+ // Command rows: "git bisect start", "git bisect good <sha>",
25173
+ // "git bisect bad <sha>", "git bisect skip <sha>".
25174
+ const commandMatch = line.match(/^git\s+bisect\s+(start|good|bad|skip)\s*(.*)$/);
25175
+ if (commandMatch) {
25176
+ const sha = commandMatch[2]?.trim().split(/\s+/)[0] || undefined;
25177
+ entries.push({
25178
+ kind: commandMatch[1],
25179
+ sha: sha || undefined,
25180
+ raw: line,
25181
+ });
25182
+ continue;
25183
+ }
25184
+ entries.push({ kind: 'unknown', raw: line });
25185
+ }
25186
+ return entries;
25187
+ }
25188
+ /**
25189
+ * Load the live bisect status. Best-effort — when bisect isn't
25190
+ * active the empty-status sentinel returns immediately so callers
25191
+ * don't pay for a `git bisect log` round-trip on every refresh.
25192
+ */
25193
+ async function getBisectStatus(git) {
25194
+ if (!(await bisectIsActive(git))) {
25195
+ return EMPTY_STATUS;
25196
+ }
25197
+ let log = [];
25198
+ try {
25199
+ const output = await git.raw(['bisect', 'log']);
25200
+ log = parseBisectLog(output);
25201
+ }
25202
+ catch {
25203
+ // bisect log can fail on a freshly-started bisect with no decisions.
25204
+ // Treat the absence of a parseable log as "active but empty" rather
25205
+ // than non-active, so the surface still routes to the bisect view
25206
+ // and the user can see the badge.
25207
+ log = [];
25208
+ }
25209
+ let currentSha = '';
25210
+ try {
25211
+ currentSha = (await git.revparse(['HEAD'])).trim();
25212
+ }
25213
+ catch {
25214
+ currentSha = '';
25215
+ }
25216
+ return { active: true, currentSha, log };
25217
+ }
25218
+
25219
+ /**
25220
+ * Thin wrappers around `git bisect <verb>` for the TUI's in-bisect
25221
+ * action keys (#784). Each returns the raw stdout so the surface can
25222
+ * surface git's own "Bisecting: N revisions left to test after this
25223
+ * (roughly K steps)" hint as a status message — that wording is the
25224
+ * single most useful piece of feedback git emits during bisect, and
25225
+ * mirroring it keeps the TUI's status line authoritative.
25226
+ *
25227
+ * No try/catch here — git itself returns non-zero on user errors
25228
+ * (already-bisecting, no good ref, etc.) and `simple-git` surfaces
25229
+ * those as rejections. The runtime catches them and routes to the
25230
+ * status line.
25231
+ */
25232
+ async function bisectGood(git, ref) {
25233
+ const args = ['bisect', 'good'];
25234
+ return git.raw(args);
25235
+ }
25236
+ async function bisectBad(git, ref) {
25237
+ const args = ['bisect', 'bad'];
25238
+ return git.raw(args);
25239
+ }
25240
+ async function bisectSkip(git, ref) {
25241
+ const args = ['bisect', 'skip'];
25242
+ return git.raw(args);
25243
+ }
25244
+ async function bisectReset(git) {
25245
+ return git.raw(['bisect', 'reset']);
25246
+ }
25247
+ /**
25248
+ * Pull the user-facing remaining-revisions hint out of `git bisect`
25249
+ * stdout. Looks for the canonical line:
25250
+ *
25251
+ * `Bisecting: N revisions left to test after this (roughly K steps)`
25252
+ *
25253
+ * Returns undefined when the line isn't present (e.g. the run
25254
+ * finished and git emitted a "<sha> is the first bad commit" line
25255
+ * instead). Callers fall back to an empty status update in that case.
25256
+ */
25257
+ function extractBisectRemainingHint(stdout) {
25258
+ for (const line of stdout.split('\n').reverse()) {
25259
+ const trimmed = line.trim();
25260
+ if (trimmed.startsWith('Bisecting:'))
25261
+ return trimmed;
25262
+ if (trimmed.includes('is the first bad commit'))
25263
+ return trimmed;
25264
+ }
25265
+ return undefined;
25266
+ }
25267
+
25268
+ /**
25269
+ * Compare two refs (branches / tags / commits) and return the unified
25270
+ * patch as line-split string output (#779).
25271
+ *
25272
+ * Mirrors the stash-diff loader's contract — emits `string[]` so the
25273
+ * existing diff surface can render the lines through its standard
25274
+ * +/-/@@ coloring path. Two-dot syntax (`base..head`) gives the
25275
+ * "what changed on head, relative to base" view that's natural for
25276
+ * branch reviews and pre-merge sanity checks.
25277
+ *
25278
+ * Defensive about input — both refs are passed as-is to git, so the
25279
+ * caller is responsible for providing a git-resolvable form
25280
+ * (branch shortName, tag name, or commit hash). On any git error
25281
+ * (unknown ref, etc.) the runtime's `safe()` wrapper at the call
25282
+ * site catches the throw and the surface falls back to a "no diff"
25283
+ * hint.
25284
+ */
25285
+ async function getCompareDiff(git, base, head) {
25286
+ return (await git.raw(['diff', `${base}..${head}`]))
25287
+ .split('\n')
25288
+ .map((line) => line.replace(/\r$/, ''));
25289
+ }
25290
+
25291
+ const FIELD_SEPARATOR = '\x1f';
25292
+ /**
25293
+ * Default fetch limit. 200 entries is enough to span weeks of normal
25294
+ * activity for an active repo while keeping the load fast — `git reflog`
25295
+ * is local-only so even 1000+ entries is sub-second, but 200 keeps the
25296
+ * rendered list bounded for terminals.
25297
+ */
25298
+ const DEFAULT_REFLOG_LIMIT = 200;
25299
+ function parseReflogOverview(output) {
25300
+ return output
25301
+ .split('\n')
25302
+ .map((line) => line.trimEnd())
25303
+ .filter(Boolean)
25304
+ .map((line) => {
25305
+ const [selector, hash, relativeDate, subject] = line.split(FIELD_SEPARATOR);
25306
+ return {
25307
+ selector: selector || '',
25308
+ hash: hash || '',
25309
+ relativeDate: relativeDate || '',
25310
+ subject: subject || '',
25311
+ };
25312
+ });
25313
+ }
25314
+ async function getReflogOverview(git, limit = DEFAULT_REFLOG_LIMIT) {
25315
+ const output = await git.raw([
25316
+ 'reflog',
25317
+ `--max-count=${limit}`,
25318
+ `--pretty=format:%gd${FIELD_SEPARATOR}%h${FIELD_SEPARATOR}%cr${FIELD_SEPARATOR}%gs`,
25319
+ ]);
25320
+ return {
25321
+ entries: parseReflogOverview(output),
25322
+ };
25323
+ }
25324
+ /**
25325
+ * Pull the action prefix off a reflog subject. Reflog subjects follow
25326
+ * a `<verb>[ qualifier]: <message>` pattern emitted by git itself —
25327
+ * "commit: ...", "commit (amend): ...", "checkout: moving from main
25328
+ * to feature", "merge feature: ...", "reset: moving to HEAD~1", etc.
25329
+ *
25330
+ * For display we want the verb (and any parenthetical qualifier) on
25331
+ * its own so the view can render a fixed-width `action` column and
25332
+ * keep the rest of the message left-aligned.
25333
+ *
25334
+ * Defensive: if the subject has no colon, the whole string is treated
25335
+ * as the action and the message is empty. This keeps the renderer
25336
+ * from crashing on a malformed entry.
25337
+ */
25338
+ function splitReflogSubject(subject) {
25339
+ const colonIndex = subject.indexOf(':');
25340
+ if (colonIndex === -1) {
25341
+ return { action: subject.trim(), message: '' };
25342
+ }
25343
+ return {
25344
+ action: subject.slice(0, colonIndex).trim(),
25345
+ message: subject.slice(colonIndex + 1).trim(),
25346
+ };
25347
+ }
25348
+
24777
25349
  function sectionLines(title, diff) {
24778
25350
  const lines = diff.split('\n').map((line) => line.trimEnd());
24779
25351
  return [
@@ -24886,7 +25458,7 @@ async function safe(promise) {
24886
25458
  }
24887
25459
  }
24888
25460
  async function loadLogInkContext(git) {
24889
- const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider] = await Promise.all([
25461
+ const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider, reflog, bisect] = await Promise.all([
24890
25462
  safe(getBranchOverview(git)),
24891
25463
  safe(getPullRequestOverview(git)),
24892
25464
  safe(getTagOverview(git)),
@@ -24895,12 +25467,16 @@ async function loadLogInkContext(git) {
24895
25467
  safe(getWorktreeListOverview(git)),
24896
25468
  safe(getGitOperationOverview(git)),
24897
25469
  safe(getProviderOverview(git)),
25470
+ safe(getReflogOverview(git)),
25471
+ safe(getBisectStatus(git)),
24898
25472
  ]);
24899
25473
  return {
25474
+ bisect,
24900
25475
  branches,
24901
25476
  operation,
24902
25477
  provider,
24903
25478
  pullRequest,
25479
+ reflog,
24904
25480
  stashes,
24905
25481
  tags,
24906
25482
  worktree,
@@ -24926,6 +25502,14 @@ function loadLogInkContextEntries(git) {
24926
25502
  key: 'tags',
24927
25503
  load: () => safe(getTagOverview(git)),
24928
25504
  },
25505
+ {
25506
+ key: 'reflog',
25507
+ load: () => safe(getReflogOverview(git)),
25508
+ },
25509
+ {
25510
+ key: 'bisect',
25511
+ load: () => safe(getBisectStatus(git)),
25512
+ },
24929
25513
  {
24930
25514
  key: 'worktree',
24931
25515
  load: () => safe(getWorktreeOverview(git)),
@@ -25314,6 +25898,10 @@ function LogInkApp(deps) {
25314
25898
  // colors match the commit-diff path.
25315
25899
  const [stashDiffLines, setStashDiffLines] = React.useState(undefined);
25316
25900
  const [stashDiffLoading, setStashDiffLoading] = React.useState(false);
25901
+ // #779 — compare-two-refs diff state. Loaded lazily when the diff
25902
+ // view becomes active with `diffSource === 'compare'`.
25903
+ const [compareDiffLines, setCompareDiffLines] = React.useState(undefined);
25904
+ const [compareDiffLoading, setCompareDiffLoading] = React.useState(false);
25317
25905
  const [hasMoreCommits, setHasMoreCommits] = React.useState(() => (Boolean(logArgv?.interactive && !logArgv.limit) &&
25318
25906
  getCommitRows(rows).length >= LOG_INTERACTIVE_DEFAULT_LIMIT));
25319
25907
  const [loadingMoreCommits, setLoadingMoreCommits] = React.useState(false);
@@ -25408,6 +25996,12 @@ function LogInkApp(deps) {
25408
25996
  return all;
25409
25997
  return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
25410
25998
  }, [context.worktreeList?.worktrees, state.filter]);
25999
+ const filteredReflogList = React.useMemo(() => {
26000
+ const all = context.reflog?.entries || [];
26001
+ if (!state.filter)
26002
+ return all;
26003
+ return all.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter));
26004
+ }, [context.reflog?.entries, state.filter]);
25411
26005
  const dispatch = React.useCallback((action) => {
25412
26006
  setState((current) => applyLogInkAction(current, action));
25413
26007
  }, []);
@@ -25618,6 +26212,70 @@ function LogInkApp(deps) {
25618
26212
  })();
25619
26213
  return () => { active = false; };
25620
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]);
26244
+ // #779 — load `git diff <base>..<head>` once the diff view becomes
26245
+ // active with diffSource='compare'. Mirrors the stash loader's
26246
+ // shape; the surface renders the lines via the same +/-/@@ coloring
26247
+ // path. On unknown ref / git error, `safe()` swallows and the
26248
+ // surface falls back to a "no diff" hint.
26249
+ const compareBaseRef = state.compareBase?.ref;
26250
+ const compareHeadRef = state.compareHead?.ref;
26251
+ React.useEffect(() => {
26252
+ if (state.activeView !== 'diff' ||
26253
+ state.diffSource !== 'compare' ||
26254
+ !compareBaseRef ||
26255
+ !compareHeadRef) {
26256
+ return;
26257
+ }
26258
+ let active = true;
26259
+ setCompareDiffLoading(true);
26260
+ void (async () => {
26261
+ const lines = await safe(getCompareDiff(git, compareBaseRef, compareHeadRef));
26262
+ if (active) {
26263
+ setCompareDiffLines(lines || []);
26264
+ setCompareDiffLoading(false);
26265
+ }
26266
+ })();
26267
+ return () => { active = false; };
26268
+ }, [git, state.activeView, state.diffSource, compareBaseRef, compareHeadRef]);
26269
+ // Reset compare-diff state whenever the diff view exits. Without
26270
+ // this, opening a new compare immediately after closing one would
26271
+ // briefly show the previous comparison's lines while the new
26272
+ // loader runs.
26273
+ React.useEffect(() => {
26274
+ if (state.diffSource !== 'compare') {
26275
+ setCompareDiffLines(undefined);
26276
+ setCompareDiffLoading(false);
26277
+ }
26278
+ }, [state.diffSource]);
25621
26279
  React.useEffect(() => {
25622
26280
  let active = true;
25623
26281
  async function loadWorktreeHunks() {
@@ -26128,6 +26786,50 @@ function LogInkApp(deps) {
26128
26786
  return { ok: false, message: 'No stash selected' };
26129
26787
  return popStash(git, stash);
26130
26788
  },
26789
+ 'bisect-good': async () => {
26790
+ if (!context.bisect?.active)
26791
+ return { ok: false, message: 'No bisect in progress' };
26792
+ try {
26793
+ const stdout = await bisectGood(git);
26794
+ return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked good' };
26795
+ }
26796
+ catch (error) {
26797
+ return { ok: false, message: `Bisect good failed: ${error.message}` };
26798
+ }
26799
+ },
26800
+ 'bisect-bad': async () => {
26801
+ if (!context.bisect?.active)
26802
+ return { ok: false, message: 'No bisect in progress' };
26803
+ try {
26804
+ const stdout = await bisectBad(git);
26805
+ return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked bad' };
26806
+ }
26807
+ catch (error) {
26808
+ return { ok: false, message: `Bisect bad failed: ${error.message}` };
26809
+ }
26810
+ },
26811
+ 'bisect-skip': async () => {
26812
+ if (!context.bisect?.active)
26813
+ return { ok: false, message: 'No bisect in progress' };
26814
+ try {
26815
+ const stdout = await bisectSkip(git);
26816
+ return { ok: true, message: extractBisectRemainingHint(stdout) || 'Skipped' };
26817
+ }
26818
+ catch (error) {
26819
+ return { ok: false, message: `Bisect skip failed: ${error.message}` };
26820
+ }
26821
+ },
26822
+ 'bisect-reset': async () => {
26823
+ if (!context.bisect?.active)
26824
+ return { ok: false, message: 'No bisect in progress' };
26825
+ try {
26826
+ await bisectReset(git);
26827
+ return { ok: true, message: 'Bisect reset' };
26828
+ }
26829
+ catch (error) {
26830
+ return { ok: false, message: `Bisect reset failed: ${error.message}` };
26831
+ }
26832
+ },
26131
26833
  'checkout-file-from-stash': async () => {
26132
26834
  const path = payload?.trim();
26133
26835
  const ref = state.stashDiffRef;
@@ -26800,9 +27502,13 @@ function LogInkApp(deps) {
26800
27502
  // perf pass) — reading them here is O(1) instead of O(branches +
26801
27503
  // tags + stashes + worktrees) per keystroke.
26802
27504
  const branchVisibleCount = filteredBranchList.length;
27505
+ const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
26803
27506
  const tagVisibleCount = filteredTagList.length;
27507
+ const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
26804
27508
  const stashVisibleCount = filteredStashList.length;
26805
27509
  const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
27510
+ const reflogVisibleCount = filteredReflogList.length;
27511
+ const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
26806
27512
  const worktreeVisibleCount = filteredWorktreeList.length;
26807
27513
  // When the diff view is showing a stash patch, swap the previewLineCount
26808
27514
  // to the stash diff length so the existing pageDetailPreview path
@@ -26827,8 +27533,12 @@ function LogInkApp(deps) {
26827
27533
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
26828
27534
  commitDiffHunkOffsets,
26829
27535
  branchCount: branchVisibleCount,
27536
+ branchSelectedShortName,
26830
27537
  tagCount: tagVisibleCount,
27538
+ tagSelectedName,
26831
27539
  stashCount: stashVisibleCount,
27540
+ reflogCount: reflogVisibleCount,
27541
+ reflogSelectedHash,
26832
27542
  stashSelectedRef,
26833
27543
  stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
26834
27544
  stashDiffSelectedPath,
@@ -26934,12 +27644,15 @@ function LogInkApp(deps) {
26934
27644
  if (showOnboarding) {
26935
27645
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
26936
27646
  }
26937
- 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));
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));
26938
27648
  }
26939
27649
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
26940
27650
  const { Box, Text } = components;
26941
27651
  const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
26942
- const dirty = context.branches?.dirty ? 'dirty' : 'clean';
27652
+ // #784 surface bisect-in-progress in the title bar so users entering
27653
+ // the TUI mid-bisect see it immediately, before they navigate to gB.
27654
+ const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
27655
+ const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
26943
27656
  const repo = context.provider?.repository.owner && context.provider.repository.name
26944
27657
  ? `${context.provider.repository.owner}/${context.provider.repository.name}`
26945
27658
  : 'local repository';
@@ -27194,12 +27907,12 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
27194
27907
  : []),
27195
27908
  ];
27196
27909
  }
27197
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, 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) {
27198
27911
  if (state.activeView === 'status') {
27199
27912
  return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27200
27913
  }
27201
27914
  if (state.activeView === 'diff') {
27202
- return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme);
27915
+ return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme);
27203
27916
  }
27204
27917
  if (state.activeView === 'compose') {
27205
27918
  return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
@@ -27210,6 +27923,12 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
27210
27923
  if (state.activeView === 'tags') {
27211
27924
  return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27212
27925
  }
27926
+ if (state.activeView === 'reflog') {
27927
+ return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27928
+ }
27929
+ if (state.activeView === 'bisect') {
27930
+ return renderBisectSurface(h, components, state, context, contextStatus, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme);
27931
+ }
27213
27932
  if (state.activeView === 'stash') {
27214
27933
  return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27215
27934
  }
@@ -27837,6 +28556,211 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
27837
28556
  width,
27838
28557
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
27839
28558
  }
28559
+ /**
28560
+ * Promoted reflog browser (#781). Mirrors `renderTagsSurface` visually
28561
+ * — same header / filter affordance / footer hint conventions — but
28562
+ * lays out four columns per row: relative date, action prefix, short
28563
+ * hash, and message. Filtering matches against all four (so typing
28564
+ * "checkout" narrows to checkout entries, "abc" narrows to a hash).
28565
+ *
28566
+ * Per-row layout uses fixed column widths derived from the visible
28567
+ * window so short-action rows don't leave a wide gutter and long
28568
+ * actions don't push the message off-screen. The cap mirrors the
28569
+ * tags surface's name-column treatment.
28570
+ */
28571
+ function renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
28572
+ const { Box, Text } = components;
28573
+ const focused = state.focus === 'commits';
28574
+ const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
28575
+ const allEntries = context.reflog?.entries || [];
28576
+ const entries = state.filter
28577
+ ? allEntries.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter))
28578
+ : allEntries;
28579
+ const selected = Math.max(0, Math.min(state.selectedReflogIndex, Math.max(0, entries.length - 1)));
28580
+ const listRows = Math.max(4, bodyRows - 4);
28581
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
28582
+ const visible = entries.slice(startIndex, startIndex + listRows);
28583
+ const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
28584
+ const headerRight = loading
28585
+ ? 'loading reflog'
28586
+ : `${entries.length}/${allEntries.length} entries${filterLabel}`;
28587
+ const emptyLabel = formatLogInkReflogEmpty({ filter: state.filter });
28588
+ const loadingLabel = formatLogInkLoading({ resource: 'reflog' });
28589
+ // Column widths derived from the visible window. The hash column is
28590
+ // fixed (short SHA is always 7 chars) and the date column caps so
28591
+ // "X minutes ago" / "Y hours ago" stays readable without dominating
28592
+ // the row. Action column scales to the longest visible action so
28593
+ // commit / checkout / merge align cleanly.
28594
+ const splitVisible = visible.map((entry) => ({
28595
+ entry,
28596
+ parts: splitReflogSubject(entry.subject),
28597
+ }));
28598
+ const dateColWidth = splitVisible.length === 0
28599
+ ? 16
28600
+ : Math.min(20, Math.max(6, ...splitVisible.map(({ entry }) => entry.relativeDate.length)));
28601
+ const actionColWidth = splitVisible.length === 0
28602
+ ? 12
28603
+ : Math.min(24, Math.max(6, ...splitVisible.map(({ parts }) => parts.action.length)));
28604
+ const hashColWidth = 8;
28605
+ const lines = loading
28606
+ ? [h(Text, { key: 'reflog-loading', dimColor: true }, loadingLabel)]
28607
+ : entries.length === 0
28608
+ ? [h(Text, { key: 'reflog-empty', dimColor: true }, emptyLabel)]
28609
+ : splitVisible.map(({ entry, parts }, offset) => {
28610
+ const index = startIndex + offset;
28611
+ const isSelected = index === selected;
28612
+ const cursor = isSelected ? '>' : ' ';
28613
+ const datePadded = truncate$1(entry.relativeDate, dateColWidth).padEnd(dateColWidth);
28614
+ const actionPadded = truncate$1(parts.action, actionColWidth).padEnd(actionColWidth);
28615
+ const hashPadded = truncate$1(entry.hash, hashColWidth).padEnd(hashColWidth);
28616
+ const message = parts.message || entry.subject;
28617
+ const lineText = truncate$1(`${cursor} ${datePadded} ${actionPadded} ${hashPadded} ${message}`, Math.max(20, width - 4));
28618
+ return h(Text, {
28619
+ key: `reflog-${index}`,
28620
+ bold: isSelected,
28621
+ dimColor: !isSelected,
28622
+ }, lineText);
28623
+ });
28624
+ return h(Box, {
28625
+ borderColor: focusBorderColor(theme, focused),
28626
+ borderStyle: theme.borderStyle,
28627
+ flexDirection: 'column',
28628
+ flexShrink: 0,
28629
+ paddingX: 1,
28630
+ width,
28631
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Reflog', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
28632
+ }
28633
+ /**
28634
+ * Bisect workflow surface (#784). Shows the current candidate commit
28635
+ * (HEAD), a parsed view of recent decisions from `git bisect log`, and
28636
+ * the four action keys (g good, b bad, s skip, x reset).
28637
+ *
28638
+ * When bisect is inactive, the surface renders an empty-state hint
28639
+ * pointing the user at the CLI to start one. The view stays
28640
+ * navigable so the user can read the documentation before starting
28641
+ * — they can't break anything from here.
28642
+ */
28643
+ function renderBisectSurface(h, components, state, context, contextStatus, candidateDetail, candidateLoading, bodyRows, width, theme) {
28644
+ const { Box, Text } = components;
28645
+ const focused = state.focus === 'commits';
28646
+ const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
28647
+ const bisect = context.bisect;
28648
+ const accent = theme.noColor ? undefined : theme.colors.accent;
28649
+ const lines = [];
28650
+ if (loading) {
28651
+ lines.push(h(Text, { key: 'bisect-loading', dimColor: true }, truncate$1('· Loading bisect status…', width - 4)));
28652
+ }
28653
+ else if (!bisect?.active) {
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
+ }
28690
+ }
28691
+ else {
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.
28695
+ const headerSha = bisect.currentSha ? bisect.currentSha.slice(0, 8) : '<unknown>';
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.
28732
+ lines.push(h(Text, { key: 'bisect-active-spacer' }, ''));
28733
+ const decisions = bisect.log.filter((entry) => entry.kind === 'good' || entry.kind === 'bad' || entry.kind === 'skip');
28734
+ if (decisions.length === 0) {
28735
+ 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)));
28736
+ }
28737
+ else {
28738
+ lines.push(h(Text, { key: 'bisect-decisions-header', bold: true }, truncate$1(`Decisions (${decisions.length}):`, width - 4)));
28739
+ const recent = decisions.slice(-Math.max(4, bodyRows - 8));
28740
+ for (const entry of recent) {
28741
+ const kindLabel = entry.kind.toUpperCase().padEnd(5);
28742
+ const sha = (entry.sha || '<unknown>').padEnd(8);
28743
+ const subject = entry.subject || '';
28744
+ const text = ` ${kindLabel} ${sha} ${subject}`;
28745
+ lines.push(h(Text, {
28746
+ key: `bisect-entry-${entry.raw}`,
28747
+ dimColor: entry.kind === 'skip',
28748
+ bold: entry.kind === 'bad',
28749
+ }, truncate$1(text, width - 4)));
28750
+ }
28751
+ }
28752
+ lines.push(h(Text, { key: 'bisect-action-spacer' }, ''));
28753
+ lines.push(h(Text, { key: 'bisect-action-hint', dimColor: true }, truncate$1('Actions: g good · b bad · s skip · x reset', width - 4)));
28754
+ }
28755
+ return h(Box, {
28756
+ borderColor: focusBorderColor(theme, focused),
28757
+ borderStyle: theme.borderStyle,
28758
+ flexDirection: 'column',
28759
+ flexShrink: 0,
28760
+ paddingX: 1,
28761
+ width,
28762
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Bisect', focused)), h(Text, { dimColor: true }, bisect?.active ? 'BISECTING' : 'inactive')), ...lines);
28763
+ }
27840
28764
  function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
27841
28765
  const { Box, Text } = components;
27842
28766
  const focused = state.focus === 'commits';
@@ -28042,7 +28966,7 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
28042
28966
  h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
28043
28967
  ];
28044
28968
  }
28045
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme) {
28969
+ function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme) {
28046
28970
  const { Box, Text } = components;
28047
28971
  const focused = state.focus === 'commits';
28048
28972
  const worktree = context.worktree;
@@ -28133,6 +29057,50 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
28133
29057
  dimColor: index > 0,
28134
29058
  }, truncate$1(line, width - 4))), ...stashBodyNodes);
28135
29059
  }
29060
+ // Compare-two-refs branch (#779). Mirrors the stash diff above but
29061
+ // sourced from `git diff <base>..<head>`. No per-file cherry-pick or
29062
+ // hunk apply — comparing arbitrary refs doesn't have a sensible
29063
+ // mutate-from-here flow, so the surface is read-only navigation.
29064
+ if (state.diffSource === 'compare') {
29065
+ const lines = compareDiffLines || [];
29066
+ const splitActive = isSplitDiffViable(state, width);
29067
+ const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
29068
+ const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
29069
+ const baseLabel = state.compareBase?.label || state.compareBase?.ref || '<base>';
29070
+ const headLabel = state.compareHead?.label || state.compareHead?.ref || '<head>';
29071
+ const compareTitle = `${baseLabel} → ${headLabel}`;
29072
+ const baseHeaderLines = compareDiffLoading
29073
+ ? [`Loading diff for ${compareTitle}...`]
29074
+ : lines.length && (lines.length > 1 || lines[0])
29075
+ ? [
29076
+ compareTitle,
29077
+ `Lines ${Math.min(state.diffPreviewOffset + 1, lines.length)}-${Math.min(state.diffPreviewOffset + visibleLines.length, lines.length)}/${lines.length}`,
29078
+ '',
29079
+ ]
29080
+ : ['No diff to display — refs may resolve to the same tree.'];
29081
+ const headerLines = splitRequestedButTooNarrow
29082
+ ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
29083
+ : baseHeaderLines;
29084
+ const compareBodyNodes = compareDiffLoading || !lines.length || (lines.length === 1 && !lines[0])
29085
+ ? []
29086
+ : splitActive
29087
+ ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'compare-diff-split')
29088
+ : visibleLines.map((line, index) => h(Text, {
29089
+ key: `compare-diff-line-${state.diffPreviewOffset + index}`,
29090
+ ...diffLineProps(line, theme),
29091
+ }, truncate$1(line, width - 4)));
29092
+ return h(Box, {
29093
+ borderColor: focusBorderColor(theme, focused),
29094
+ borderStyle: theme.borderStyle,
29095
+ flexDirection: 'column',
29096
+ flexShrink: 0,
29097
+ paddingX: 1,
29098
+ width,
29099
+ }, 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, {
29100
+ key: `compare-diff-header-${index}`,
29101
+ dimColor: index > 0,
29102
+ }, truncate$1(line, width - 4))), ...compareBodyNodes);
29103
+ }
28136
29104
  // diffSource disambiguates: 'commit' was set when the user opened the
28137
29105
  // diff via history → Enter (read-only commit-diff explore), 'worktree'
28138
29106
  // was set when they came from status → Enter (stage / hunk / revert).
@@ -28991,6 +29959,7 @@ function renderFooter(h, components, state, context, theme, idleTip) {
28991
29959
  showHelp: state.showHelp,
28992
29960
  sidebarTab: state.sidebarTab,
28993
29961
  sidebarItemCount,
29962
+ compareBaseSet: Boolean(state.compareBase),
28994
29963
  });
28995
29964
  // Real status messages always win; idle tips only fill the slot when it
28996
29965
  // would otherwise be empty.