git-coco 0.45.0 → 0.46.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.46.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,41 @@ function LogInkApp(deps) {
25594
26188
  })();
25595
26189
  return () => { active = false; };
25596
26190
  }, [git, state.activeView, state.diffSource, state.stashDiffRef]);
26191
+ // #779 — load `git diff <base>..<head>` once the diff view becomes
26192
+ // active with diffSource='compare'. Mirrors the stash loader's
26193
+ // shape; the surface renders the lines via the same +/-/@@ coloring
26194
+ // path. On unknown ref / git error, `safe()` swallows and the
26195
+ // surface falls back to a "no diff" hint.
26196
+ const compareBaseRef = state.compareBase?.ref;
26197
+ const compareHeadRef = state.compareHead?.ref;
26198
+ React.useEffect(() => {
26199
+ if (state.activeView !== 'diff' ||
26200
+ state.diffSource !== 'compare' ||
26201
+ !compareBaseRef ||
26202
+ !compareHeadRef) {
26203
+ return;
26204
+ }
26205
+ let active = true;
26206
+ setCompareDiffLoading(true);
26207
+ void (async () => {
26208
+ const lines = await safe(getCompareDiff(git, compareBaseRef, compareHeadRef));
26209
+ if (active) {
26210
+ setCompareDiffLines(lines || []);
26211
+ setCompareDiffLoading(false);
26212
+ }
26213
+ })();
26214
+ return () => { active = false; };
26215
+ }, [git, state.activeView, state.diffSource, compareBaseRef, compareHeadRef]);
26216
+ // Reset compare-diff state whenever the diff view exits. Without
26217
+ // this, opening a new compare immediately after closing one would
26218
+ // briefly show the previous comparison's lines while the new
26219
+ // loader runs.
26220
+ React.useEffect(() => {
26221
+ if (state.diffSource !== 'compare') {
26222
+ setCompareDiffLines(undefined);
26223
+ setCompareDiffLoading(false);
26224
+ }
26225
+ }, [state.diffSource]);
25597
26226
  React.useEffect(() => {
25598
26227
  let active = true;
25599
26228
  async function loadWorktreeHunks() {
@@ -26104,6 +26733,50 @@ function LogInkApp(deps) {
26104
26733
  return { ok: false, message: 'No stash selected' };
26105
26734
  return popStash(git, stash);
26106
26735
  },
26736
+ 'bisect-good': async () => {
26737
+ if (!context.bisect?.active)
26738
+ return { ok: false, message: 'No bisect in progress' };
26739
+ try {
26740
+ const stdout = await bisectGood(git);
26741
+ return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked good' };
26742
+ }
26743
+ catch (error) {
26744
+ return { ok: false, message: `Bisect good failed: ${error.message}` };
26745
+ }
26746
+ },
26747
+ 'bisect-bad': async () => {
26748
+ if (!context.bisect?.active)
26749
+ return { ok: false, message: 'No bisect in progress' };
26750
+ try {
26751
+ const stdout = await bisectBad(git);
26752
+ return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked bad' };
26753
+ }
26754
+ catch (error) {
26755
+ return { ok: false, message: `Bisect bad failed: ${error.message}` };
26756
+ }
26757
+ },
26758
+ 'bisect-skip': async () => {
26759
+ if (!context.bisect?.active)
26760
+ return { ok: false, message: 'No bisect in progress' };
26761
+ try {
26762
+ const stdout = await bisectSkip(git);
26763
+ return { ok: true, message: extractBisectRemainingHint(stdout) || 'Skipped' };
26764
+ }
26765
+ catch (error) {
26766
+ return { ok: false, message: `Bisect skip failed: ${error.message}` };
26767
+ }
26768
+ },
26769
+ 'bisect-reset': async () => {
26770
+ if (!context.bisect?.active)
26771
+ return { ok: false, message: 'No bisect in progress' };
26772
+ try {
26773
+ await bisectReset(git);
26774
+ return { ok: true, message: 'Bisect reset' };
26775
+ }
26776
+ catch (error) {
26777
+ return { ok: false, message: `Bisect reset failed: ${error.message}` };
26778
+ }
26779
+ },
26107
26780
  'checkout-file-from-stash': async () => {
26108
26781
  const path = payload?.trim();
26109
26782
  const ref = state.stashDiffRef;
@@ -26776,9 +27449,13 @@ function LogInkApp(deps) {
26776
27449
  // perf pass) — reading them here is O(1) instead of O(branches +
26777
27450
  // tags + stashes + worktrees) per keystroke.
26778
27451
  const branchVisibleCount = filteredBranchList.length;
27452
+ const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
26779
27453
  const tagVisibleCount = filteredTagList.length;
27454
+ const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
26780
27455
  const stashVisibleCount = filteredStashList.length;
26781
27456
  const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
27457
+ const reflogVisibleCount = filteredReflogList.length;
27458
+ const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
26782
27459
  const worktreeVisibleCount = filteredWorktreeList.length;
26783
27460
  // When the diff view is showing a stash patch, swap the previewLineCount
26784
27461
  // to the stash diff length so the existing pageDetailPreview path
@@ -26803,8 +27480,12 @@ function LogInkApp(deps) {
26803
27480
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
26804
27481
  commitDiffHunkOffsets,
26805
27482
  branchCount: branchVisibleCount,
27483
+ branchSelectedShortName,
26806
27484
  tagCount: tagVisibleCount,
27485
+ tagSelectedName,
26807
27486
  stashCount: stashVisibleCount,
27487
+ reflogCount: reflogVisibleCount,
27488
+ reflogSelectedHash,
26808
27489
  stashSelectedRef,
26809
27490
  stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
26810
27491
  stashDiffSelectedPath,
@@ -26910,12 +27591,15 @@ function LogInkApp(deps) {
26910
27591
  if (showOnboarding) {
26911
27592
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
26912
27593
  }
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));
27594
+ return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
26914
27595
  }
26915
27596
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
26916
27597
  const { Box, Text } = components;
26917
27598
  const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
26918
- const dirty = context.branches?.dirty ? 'dirty' : 'clean';
27599
+ // #784 surface bisect-in-progress in the title bar so users entering
27600
+ // the TUI mid-bisect see it immediately, before they navigate to gB.
27601
+ const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
27602
+ const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
26919
27603
  const repo = context.provider?.repository.owner && context.provider.repository.name
26920
27604
  ? `${context.provider.repository.owner}/${context.provider.repository.name}`
26921
27605
  : 'local repository';
@@ -27170,12 +27854,12 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
27170
27854
  : []),
27171
27855
  ];
27172
27856
  }
27173
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
27857
+ function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
27174
27858
  if (state.activeView === 'status') {
27175
27859
  return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27176
27860
  }
27177
27861
  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);
27862
+ return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme);
27179
27863
  }
27180
27864
  if (state.activeView === 'compose') {
27181
27865
  return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
@@ -27186,6 +27870,12 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
27186
27870
  if (state.activeView === 'tags') {
27187
27871
  return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27188
27872
  }
27873
+ if (state.activeView === 'reflog') {
27874
+ return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27875
+ }
27876
+ if (state.activeView === 'bisect') {
27877
+ return renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27878
+ }
27189
27879
  if (state.activeView === 'stash') {
27190
27880
  return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27191
27881
  }
@@ -27813,6 +28503,150 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
27813
28503
  width,
27814
28504
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
27815
28505
  }
28506
+ /**
28507
+ * Promoted reflog browser (#781). Mirrors `renderTagsSurface` visually
28508
+ * — same header / filter affordance / footer hint conventions — but
28509
+ * lays out four columns per row: relative date, action prefix, short
28510
+ * hash, and message. Filtering matches against all four (so typing
28511
+ * "checkout" narrows to checkout entries, "abc" narrows to a hash).
28512
+ *
28513
+ * Per-row layout uses fixed column widths derived from the visible
28514
+ * window so short-action rows don't leave a wide gutter and long
28515
+ * actions don't push the message off-screen. The cap mirrors the
28516
+ * tags surface's name-column treatment.
28517
+ */
28518
+ function renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
28519
+ const { Box, Text } = components;
28520
+ const focused = state.focus === 'commits';
28521
+ const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
28522
+ const allEntries = context.reflog?.entries || [];
28523
+ const entries = state.filter
28524
+ ? allEntries.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter))
28525
+ : allEntries;
28526
+ const selected = Math.max(0, Math.min(state.selectedReflogIndex, Math.max(0, entries.length - 1)));
28527
+ const listRows = Math.max(4, bodyRows - 4);
28528
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
28529
+ const visible = entries.slice(startIndex, startIndex + listRows);
28530
+ const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
28531
+ const headerRight = loading
28532
+ ? 'loading reflog'
28533
+ : `${entries.length}/${allEntries.length} entries${filterLabel}`;
28534
+ const emptyLabel = formatLogInkReflogEmpty({ filter: state.filter });
28535
+ const loadingLabel = formatLogInkLoading({ resource: 'reflog' });
28536
+ // Column widths derived from the visible window. The hash column is
28537
+ // fixed (short SHA is always 7 chars) and the date column caps so
28538
+ // "X minutes ago" / "Y hours ago" stays readable without dominating
28539
+ // the row. Action column scales to the longest visible action so
28540
+ // commit / checkout / merge align cleanly.
28541
+ const splitVisible = visible.map((entry) => ({
28542
+ entry,
28543
+ parts: splitReflogSubject(entry.subject),
28544
+ }));
28545
+ const dateColWidth = splitVisible.length === 0
28546
+ ? 16
28547
+ : Math.min(20, Math.max(6, ...splitVisible.map(({ entry }) => entry.relativeDate.length)));
28548
+ const actionColWidth = splitVisible.length === 0
28549
+ ? 12
28550
+ : Math.min(24, Math.max(6, ...splitVisible.map(({ parts }) => parts.action.length)));
28551
+ const hashColWidth = 8;
28552
+ const lines = loading
28553
+ ? [h(Text, { key: 'reflog-loading', dimColor: true }, loadingLabel)]
28554
+ : entries.length === 0
28555
+ ? [h(Text, { key: 'reflog-empty', dimColor: true }, emptyLabel)]
28556
+ : splitVisible.map(({ entry, parts }, offset) => {
28557
+ const index = startIndex + offset;
28558
+ const isSelected = index === selected;
28559
+ const cursor = isSelected ? '>' : ' ';
28560
+ const datePadded = truncate$1(entry.relativeDate, dateColWidth).padEnd(dateColWidth);
28561
+ const actionPadded = truncate$1(parts.action, actionColWidth).padEnd(actionColWidth);
28562
+ const hashPadded = truncate$1(entry.hash, hashColWidth).padEnd(hashColWidth);
28563
+ const message = parts.message || entry.subject;
28564
+ const lineText = truncate$1(`${cursor} ${datePadded} ${actionPadded} ${hashPadded} ${message}`, Math.max(20, width - 4));
28565
+ return h(Text, {
28566
+ key: `reflog-${index}`,
28567
+ bold: isSelected,
28568
+ dimColor: !isSelected,
28569
+ }, lineText);
28570
+ });
28571
+ return h(Box, {
28572
+ borderColor: focusBorderColor(theme, focused),
28573
+ borderStyle: theme.borderStyle,
28574
+ flexDirection: 'column',
28575
+ flexShrink: 0,
28576
+ paddingX: 1,
28577
+ width,
28578
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Reflog', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
28579
+ }
28580
+ /**
28581
+ * Bisect workflow surface (#784). Shows the current candidate commit
28582
+ * (HEAD), a parsed view of recent decisions from `git bisect log`, and
28583
+ * the four action keys (g good, b bad, s skip, x reset).
28584
+ *
28585
+ * When bisect is inactive, the surface renders an empty-state hint
28586
+ * pointing the user at the CLI to start one. The view stays
28587
+ * navigable so the user can read the documentation before starting
28588
+ * — they can't break anything from here.
28589
+ */
28590
+ function renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
28591
+ const { Box, Text } = components;
28592
+ const focused = state.focus === 'commits';
28593
+ const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
28594
+ const bisect = context.bisect;
28595
+ const accent = theme.noColor ? undefined : theme.colors.accent;
28596
+ const lines = [];
28597
+ if (loading) {
28598
+ lines.push(h(Text, { key: 'bisect-loading', dimColor: true }, truncate$1('· Loading bisect status…', width - 4)));
28599
+ }
28600
+ else if (!bisect?.active) {
28601
+ // No bisect active. Surface the CLI on-ramp — starting from the
28602
+ // TUI is intentionally out of scope for this PR (#784 follow-up).
28603
+ // The user is expected to enter via `git bisect start <bad> <good>`
28604
+ // and re-open `coco ui`; once bisect is active this view drives
28605
+ // the rest.
28606
+ lines.push(h(Text, { key: 'bisect-empty-1', bold: true }, truncate$1('No bisect in progress.', width - 4)));
28607
+ lines.push(h(Text, { key: 'bisect-empty-2' }, ''));
28608
+ lines.push(h(Text, { key: 'bisect-empty-3' }, truncate$1('Start one from the shell with:', width - 4)));
28609
+ lines.push(h(Text, { key: 'bisect-empty-4', color: accent }, truncate$1(' git bisect start <bad-ref> <good-ref>', width - 4)));
28610
+ lines.push(h(Text, { key: 'bisect-empty-5' }, ''));
28611
+ lines.push(h(Text, { key: 'bisect-empty-6', dimColor: true }, truncate$1('coco will pick up the active bisect on the next refresh — actions will become available here.', width - 4)));
28612
+ }
28613
+ else {
28614
+ // Active bisect. Two-section body: current candidate, recent
28615
+ // decisions. Action keys live in the footer.
28616
+ const headerSha = bisect.currentSha ? bisect.currentSha.slice(0, 8) : '<unknown>';
28617
+ lines.push(h(Text, { key: 'bisect-active-title', bold: true }, truncate$1(`Bisecting · current candidate ${headerSha}`, width - 4)));
28618
+ lines.push(h(Text, { key: 'bisect-active-spacer' }, ''));
28619
+ const decisions = bisect.log.filter((entry) => entry.kind === 'good' || entry.kind === 'bad' || entry.kind === 'skip');
28620
+ if (decisions.length === 0) {
28621
+ 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)));
28622
+ }
28623
+ else {
28624
+ lines.push(h(Text, { key: 'bisect-decisions-header', bold: true }, truncate$1(`Decisions (${decisions.length}):`, width - 4)));
28625
+ const recent = decisions.slice(-Math.max(4, bodyRows - 8));
28626
+ for (const entry of recent) {
28627
+ const kindLabel = entry.kind.toUpperCase().padEnd(5);
28628
+ const sha = (entry.sha || '<unknown>').padEnd(8);
28629
+ const subject = entry.subject || '';
28630
+ const text = ` ${kindLabel} ${sha} ${subject}`;
28631
+ lines.push(h(Text, {
28632
+ key: `bisect-entry-${entry.raw}`,
28633
+ dimColor: entry.kind === 'skip',
28634
+ bold: entry.kind === 'bad',
28635
+ }, truncate$1(text, width - 4)));
28636
+ }
28637
+ }
28638
+ lines.push(h(Text, { key: 'bisect-action-spacer' }, ''));
28639
+ lines.push(h(Text, { key: 'bisect-action-hint', dimColor: true }, truncate$1('Actions: g good · b bad · s skip · x reset', width - 4)));
28640
+ }
28641
+ return h(Box, {
28642
+ borderColor: focusBorderColor(theme, focused),
28643
+ borderStyle: theme.borderStyle,
28644
+ flexDirection: 'column',
28645
+ flexShrink: 0,
28646
+ paddingX: 1,
28647
+ width,
28648
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Bisect', focused)), h(Text, { dimColor: true }, bisect?.active ? 'BISECTING' : 'inactive')), ...lines);
28649
+ }
27816
28650
  function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
27817
28651
  const { Box, Text } = components;
27818
28652
  const focused = state.focus === 'commits';
@@ -28018,7 +28852,7 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
28018
28852
  h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
28019
28853
  ];
28020
28854
  }
28021
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme) {
28855
+ function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme) {
28022
28856
  const { Box, Text } = components;
28023
28857
  const focused = state.focus === 'commits';
28024
28858
  const worktree = context.worktree;
@@ -28109,6 +28943,50 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
28109
28943
  dimColor: index > 0,
28110
28944
  }, truncate$1(line, width - 4))), ...stashBodyNodes);
28111
28945
  }
28946
+ // Compare-two-refs branch (#779). Mirrors the stash diff above but
28947
+ // sourced from `git diff <base>..<head>`. No per-file cherry-pick or
28948
+ // hunk apply — comparing arbitrary refs doesn't have a sensible
28949
+ // mutate-from-here flow, so the surface is read-only navigation.
28950
+ if (state.diffSource === 'compare') {
28951
+ const lines = compareDiffLines || [];
28952
+ const splitActive = isSplitDiffViable(state, width);
28953
+ const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
28954
+ const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
28955
+ const baseLabel = state.compareBase?.label || state.compareBase?.ref || '<base>';
28956
+ const headLabel = state.compareHead?.label || state.compareHead?.ref || '<head>';
28957
+ const compareTitle = `${baseLabel} → ${headLabel}`;
28958
+ const baseHeaderLines = compareDiffLoading
28959
+ ? [`Loading diff for ${compareTitle}...`]
28960
+ : lines.length && (lines.length > 1 || lines[0])
28961
+ ? [
28962
+ compareTitle,
28963
+ `Lines ${Math.min(state.diffPreviewOffset + 1, lines.length)}-${Math.min(state.diffPreviewOffset + visibleLines.length, lines.length)}/${lines.length}`,
28964
+ '',
28965
+ ]
28966
+ : ['No diff to display — refs may resolve to the same tree.'];
28967
+ const headerLines = splitRequestedButTooNarrow
28968
+ ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
28969
+ : baseHeaderLines;
28970
+ const compareBodyNodes = compareDiffLoading || !lines.length || (lines.length === 1 && !lines[0])
28971
+ ? []
28972
+ : splitActive
28973
+ ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'compare-diff-split')
28974
+ : visibleLines.map((line, index) => h(Text, {
28975
+ key: `compare-diff-line-${state.diffPreviewOffset + index}`,
28976
+ ...diffLineProps(line, theme),
28977
+ }, truncate$1(line, width - 4)));
28978
+ return h(Box, {
28979
+ borderColor: focusBorderColor(theme, focused),
28980
+ borderStyle: theme.borderStyle,
28981
+ flexDirection: 'column',
28982
+ flexShrink: 0,
28983
+ paddingX: 1,
28984
+ width,
28985
+ }, 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, {
28986
+ key: `compare-diff-header-${index}`,
28987
+ dimColor: index > 0,
28988
+ }, truncate$1(line, width - 4))), ...compareBodyNodes);
28989
+ }
28112
28990
  // diffSource disambiguates: 'commit' was set when the user opened the
28113
28991
  // diff via history → Enter (read-only commit-diff explore), 'worktree'
28114
28992
  // was set when they came from status → Enter (stage / hunk / revert).
@@ -28967,6 +29845,7 @@ function renderFooter(h, components, state, context, theme, idleTip) {
28967
29845
  showHelp: state.showHelp,
28968
29846
  sidebarTab: state.sidebarTab,
28969
29847
  sidebarItemCount,
29848
+ compareBaseSet: Boolean(state.compareBase),
28970
29849
  });
28971
29850
  // Real status messages always win; idle tips only fill the slot when it
28972
29851
  // would otherwise be empty.