git-coco 0.44.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.44.0";
57
+ const BUILD_VERSION = "0.46.0";
58
58
 
59
59
  const isInteractive = (config) => {
60
60
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -1228,6 +1228,18 @@ const schema$1 = {
1228
1228
  "$ref": "#/definitions/DynamicModelPreference",
1229
1229
  "description": "Default dynamic routing preference when model is set to \"dynamic\".",
1230
1230
  "default": "balanced"
1231
+ },
1232
+ "fastPath": {
1233
+ "type": "object",
1234
+ "properties": {
1235
+ "markdown": {
1236
+ "type": "boolean",
1237
+ "description": "Replace the LLM summary with a templated heading extract for `.md` / `.mdx` / `.markdown` modification diffs that have clear heading-level structural changes. Diffs without structural signals (paragraph-only edits) still go to the LLM regardless of this flag.\n\nBench impact (synthetic): collapses docs-update-shaped commits from ~24s cold to ~3ms (no LLM calls fire for the markdown files). Real-world wall-clock savings depend on per-call LLM latency.",
1238
+ "default": false
1239
+ }
1240
+ },
1241
+ "additionalProperties": false,
1242
+ "description": "Opt-in fast paths that trade summary detail for speed. Each flag here replaces an LLM summary call with a deterministic templated extract for a specific file shape. Off by default — when enabled, you accept that final commit messages on those file shapes may be blander than LLM-generated summaries (the templated extract names structural changes only).\n\nLossless optimizations (cache, trivial-shape skip on pure additions / deletions / renames / binary, sort discipline) ship default-on and are not configured here."
1231
1243
  }
1232
1244
  },
1233
1245
  "required": [
@@ -1641,6 +1653,18 @@ const schema$1 = {
1641
1653
  "$ref": "#/definitions/DynamicModelPreference",
1642
1654
  "description": "Default dynamic routing preference when model is set to \"dynamic\".",
1643
1655
  "default": "balanced"
1656
+ },
1657
+ "fastPath": {
1658
+ "type": "object",
1659
+ "properties": {
1660
+ "markdown": {
1661
+ "type": "boolean",
1662
+ "description": "Replace the LLM summary with a templated heading extract for `.md` / `.mdx` / `.markdown` modification diffs that have clear heading-level structural changes. Diffs without structural signals (paragraph-only edits) still go to the LLM regardless of this flag.\n\nBench impact (synthetic): collapses docs-update-shaped commits from ~24s cold to ~3ms (no LLM calls fire for the markdown files). Real-world wall-clock savings depend on per-call LLM latency.",
1663
+ "default": false
1664
+ }
1665
+ },
1666
+ "additionalProperties": false,
1667
+ "description": "Opt-in fast paths that trade summary detail for speed. Each flag here replaces an LLM summary call with a deterministic templated extract for a specific file shape. Off by default — when enabled, you accept that final commit messages on those file shapes may be blander than LLM-generated summaries (the templated extract names structural changes only).\n\nLossless optimizations (cache, trivial-shape skip on pure additions / deletions / renames / binary, sort discipline) ship default-on and are not configured here."
1644
1668
  }
1645
1669
  },
1646
1670
  "required": [
@@ -1797,6 +1821,18 @@ const schema$1 = {
1797
1821
  "$ref": "#/definitions/DynamicModelPreference",
1798
1822
  "description": "Default dynamic routing preference when model is set to \"dynamic\".",
1799
1823
  "default": "balanced"
1824
+ },
1825
+ "fastPath": {
1826
+ "type": "object",
1827
+ "properties": {
1828
+ "markdown": {
1829
+ "type": "boolean",
1830
+ "description": "Replace the LLM summary with a templated heading extract for `.md` / `.mdx` / `.markdown` modification diffs that have clear heading-level structural changes. Diffs without structural signals (paragraph-only edits) still go to the LLM regardless of this flag.\n\nBench impact (synthetic): collapses docs-update-shaped commits from ~24s cold to ~3ms (no LLM calls fire for the markdown files). Real-world wall-clock savings depend on per-call LLM latency.",
1831
+ "default": false
1832
+ }
1833
+ },
1834
+ "additionalProperties": false,
1835
+ "description": "Opt-in fast paths that trade summary detail for speed. Each flag here replaces an LLM summary call with a deterministic templated extract for a specific file shape. Off by default — when enabled, you accept that final commit messages on those file shapes may be blander than LLM-generated summaries (the templated extract names structural changes only).\n\nLossless optimizations (cache, trivial-shape skip on pure additions / deletions / renames / binary, sort discipline) ship default-on and are not configured here."
1800
1836
  }
1801
1837
  },
1802
1838
  "required": [
@@ -7890,6 +7926,109 @@ async function summarize(documents, { chain, textSplitter, options, logger, toke
7890
7926
  return res.text && res.text.trim();
7891
7927
  }
7892
7928
 
7929
+ /**
7930
+ * Markdown-aware fast path (#861, angle 5). For modification diffs to
7931
+ * `.md` / `.mdx` / `.markdown` files, build a templated summary from
7932
+ * the changed structure (added / removed / updated headings) instead
7933
+ * of paying for an LLM call. Mirrors `trivialDiff` from #845: a deterministic
7934
+ * skip when the diff's meaning is captured by its shape.
7935
+ *
7936
+ * Quality / cost trade-off, on purpose: LLM summaries of markdown edits
7937
+ * are wordier ("expanded the configuration section with new examples,
7938
+ * fixed typos in troubleshooting") but most of that detail isn't load-
7939
+ * bearing for a commit message. The templated summary names the
7940
+ * structural changes (which sections moved) plus a +/- line count, and
7941
+ * defers to the LLM only when the diff has no clear structural signals
7942
+ * (paragraph-only edits, where a templated summary would actually drop
7943
+ * useful context).
7944
+ */
7945
+ const MARKDOWN_EXTENSIONS = ['.md', '.markdown', '.mdx'];
7946
+ const MAX_HEADINGS_PER_BUCKET = 6;
7947
+ function isMarkdownFile(path) {
7948
+ const lower = path.toLowerCase();
7949
+ return MARKDOWN_EXTENSIONS.some((ext) => lower.endsWith(ext));
7950
+ }
7951
+ function summarizeMarkdownDiff(fileDiff) {
7952
+ if (!isMarkdownFile(fileDiff.file))
7953
+ return undefined;
7954
+ const addedHeadings = new Set();
7955
+ const removedHeadings = new Set();
7956
+ let addedLines = 0;
7957
+ let removedLines = 0;
7958
+ for (const line of fileDiff.diff.split('\n')) {
7959
+ if (isHeaderLine$1(line))
7960
+ continue;
7961
+ if (line.startsWith('+')) {
7962
+ addedLines++;
7963
+ const heading = parseHeading(line.slice(1));
7964
+ if (heading)
7965
+ addedHeadings.add(heading);
7966
+ }
7967
+ else if (line.startsWith('-')) {
7968
+ removedLines++;
7969
+ const heading = parseHeading(line.slice(1));
7970
+ if (heading)
7971
+ removedHeadings.add(heading);
7972
+ }
7973
+ }
7974
+ // No content change → nothing to summarize. Caller falls through.
7975
+ if (addedLines === 0 && removedLines === 0)
7976
+ return undefined;
7977
+ // No structural signal → fall through to LLM. We only fast-path
7978
+ // when the diff has heading-level changes; pure paragraph edits go
7979
+ // to the LLM so the summary keeps its detail.
7980
+ if (addedHeadings.size === 0 && removedHeadings.size === 0) {
7981
+ return undefined;
7982
+ }
7983
+ // A heading that appears in both buckets is likely an update (kept
7984
+ // around but its body changed) rather than two distinct events.
7985
+ // The naive split-by-bucket diff format used by git emits the old
7986
+ // text under `-` and the new text under `+`; an unchanged heading
7987
+ // line shouldn't show up in either bucket via the standard hunk
7988
+ // path, but defensively de-dupe in case the diff producer emits
7989
+ // surrounding context as +/-.
7990
+ const updated = new Set([...addedHeadings].filter((h) => removedHeadings.has(h)));
7991
+ const purelyAdded = [...addedHeadings].filter((h) => !updated.has(h));
7992
+ const purelyRemoved = [...removedHeadings].filter((h) => !updated.has(h));
7993
+ const parts = [`Updated markdown \`${fileDiff.file}\``];
7994
+ if (purelyAdded.length) {
7995
+ parts.push(`new sections: ${formatHeadingList(purelyAdded)}`);
7996
+ }
7997
+ if (purelyRemoved.length) {
7998
+ parts.push(`removed sections: ${formatHeadingList(purelyRemoved)}`);
7999
+ }
8000
+ if (updated.size) {
8001
+ parts.push(`updated sections: ${formatHeadingList([...updated])}`);
8002
+ }
8003
+ parts.push(`+${addedLines}/-${removedLines} lines`);
8004
+ return `${parts.join('. ')}.`;
8005
+ }
8006
+ function formatHeadingList(headings) {
8007
+ if (headings.length <= MAX_HEADINGS_PER_BUCKET) {
8008
+ return headings.join(', ');
8009
+ }
8010
+ const shown = headings.slice(0, MAX_HEADINGS_PER_BUCKET);
8011
+ const remainder = headings.length - shown.length;
8012
+ return `${shown.join(', ')} (+${remainder} more)`;
8013
+ }
8014
+ function isHeaderLine$1(line) {
8015
+ return (line.startsWith('diff --git') ||
8016
+ line.startsWith('index ') ||
8017
+ line.startsWith('--- ') ||
8018
+ line.startsWith('+++ ') ||
8019
+ line.startsWith('@@') ||
8020
+ line.startsWith('new file mode') ||
8021
+ line.startsWith('deleted file mode') ||
8022
+ line.startsWith('similarity index') ||
8023
+ line.startsWith('rename from ') ||
8024
+ line.startsWith('rename to ') ||
8025
+ line.startsWith('Binary files '));
8026
+ }
8027
+ function parseHeading(line) {
8028
+ const match = line.match(/^#{1,6}\s+(.+?)\s*$/);
8029
+ return match ? match[1].trim() : undefined;
8030
+ }
8031
+
7893
8032
  /**
7894
8033
  * Inspect a unified-diff string and report its shape, or undefined
7895
8034
  * if the diff isn't trivial (mixed +/- lines, weird headers, etc.).
@@ -8027,7 +8166,7 @@ function isCacheEnabled$1() {
8027
8166
  * synthetic summaries usually drop the directory token totals under
8028
8167
  * budget so wave consolidation skips too.
8029
8168
  */
8030
- async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, }) {
8169
+ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, fastPath, }) {
8031
8170
  const trivialSummary = summarizeTrivialDiff(fileDiff);
8032
8171
  if (trivialSummary !== undefined) {
8033
8172
  logger.verbose(` - ${fileDiff.file}: trivial-shape skip (no LLM call)`, { color: 'gray' });
@@ -8037,6 +8176,25 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
8037
8176
  tokenCount: tokenizer(trivialSummary),
8038
8177
  };
8039
8178
  }
8179
+ // Markdown fast path (#861, angle 5). Opt-in via `fastPath.markdown`
8180
+ // because it's a lossy optimization: the templated summary names
8181
+ // structural changes only and drops body-text detail that an LLM
8182
+ // summary would carry. Off by default; users who prefer summary
8183
+ // fidelity over speed (which is the safer default for commit-message
8184
+ // generation downstream) keep the LLM path. When the flag IS on, the
8185
+ // fast path still falls through to the LLM for paragraph-only edits
8186
+ // where a templated summary would lose useful context.
8187
+ if (fastPath?.markdown) {
8188
+ const markdownSummary = summarizeMarkdownDiff(fileDiff);
8189
+ if (markdownSummary !== undefined) {
8190
+ logger.verbose(` - ${fileDiff.file}: markdown fast-path skip (no LLM call)`, { color: 'gray' });
8191
+ return {
8192
+ ...fileDiff,
8193
+ diff: markdownSummary,
8194
+ tokenCount: tokenizer(markdownSummary),
8195
+ };
8196
+ }
8197
+ }
8040
8198
  // Cache lookup (#845, PR 5). Keyed on the file's literal diff
8041
8199
  // content + the active model + the summarization prompt hash.
8042
8200
  // A hit returns the prior summary instantly; on iterative
@@ -8148,7 +8306,7 @@ function createLimit$2(maxConcurrent) {
8148
8306
  * @returns Array of file diffs with large files summarized
8149
8307
  */
8150
8308
  async function summarizeLargeFiles(diffs, options) {
8151
- const { maxFileTokens, minTokensForSummary, maxConcurrent, tokenizer, logger, chain, textSplitter, metadata } = options;
8309
+ const { maxFileTokens, minTokensForSummary, maxConcurrent, maxTokens, fastPath, tokenizer, logger, chain, textSplitter, metadata, } = options;
8152
8310
  // Identify files that need summarization
8153
8311
  const filesToSummarize = [];
8154
8312
  const results = [...diffs];
@@ -8160,17 +8318,57 @@ async function summarizeLargeFiles(diffs, options) {
8160
8318
  if (filesToSummarize.length === 0) {
8161
8319
  return results;
8162
8320
  }
8163
- logger.verbose(`Pre-summarizing ${filesToSummarize.length} large file(s)...`, { color: 'blue' });
8164
- // Process large files in waves
8165
- const summarizedFiles = await processInWaves$1(filesToSummarize, async ({ diff }) => summarizeFileDiff(diff, { chain, textSplitter, tokenizer, logger, metadata }), maxConcurrent);
8166
- // Update results with summarized files
8167
- summarizedFiles.forEach((summarizedDiff, i) => {
8321
+ // Incremental termination (#861, PR 1). When the caller supplies a
8322
+ // budget, dispatch biggest-first and re-check the running total per
8323
+ // dispatch once earlier completions drop the total under maxTokens,
8324
+ // the remaining queued files skip the LLM and keep their raw diffs.
8325
+ // Mirrors the Phase 3 pattern in `summarizeDiffs.ts`. Without a
8326
+ // budget (undefined), behavior matches the prior path: every
8327
+ // eligible file is summarized regardless.
8328
+ filesToSummarize.sort((a, b) => b.diff.tokenCount - a.diff.tokenCount);
8329
+ const incrementalTermination = maxTokens !== undefined;
8330
+ let runningTotal = diffs.reduce((sum, diff) => sum + diff.tokenCount, 0);
8331
+ let summarizedCount = 0;
8332
+ let skippedCount = 0;
8333
+ logger.verbose(`Pre-summarizing up to ${filesToSummarize.length} large file(s)...`, { color: 'blue' });
8334
+ const processed = await processInWaves$1(filesToSummarize, async ({ diff }) => {
8335
+ // Re-check the budget at dispatch time when the caller supplied
8336
+ // one. Earlier completions may have already dropped the total
8337
+ // under the cap; in that case skip the LLM call entirely and
8338
+ // keep the raw diff. Without a budget, every eligible file is
8339
+ // summarized (preserves the prior behavior).
8340
+ if (incrementalTermination && runningTotal <= maxTokens) {
8341
+ return { diff, summarized: false };
8342
+ }
8343
+ const summarized = await summarizeFileDiff(diff, {
8344
+ chain,
8345
+ textSplitter,
8346
+ tokenizer,
8347
+ logger,
8348
+ metadata,
8349
+ fastPath,
8350
+ });
8351
+ const delta = diff.tokenCount - summarized.tokenCount;
8352
+ if (delta > 0) {
8353
+ runningTotal -= delta;
8354
+ }
8355
+ return { diff: summarized, summarized: true };
8356
+ }, maxConcurrent);
8357
+ processed.forEach((entry, i) => {
8168
8358
  const originalIndex = filesToSummarize[i].index;
8359
+ if (!entry.summarized) {
8360
+ skippedCount++;
8361
+ return;
8362
+ }
8363
+ summarizedCount++;
8169
8364
  const originalTokens = results[originalIndex].tokenCount;
8170
- const newTokens = summarizedDiff.tokenCount;
8171
- logger.verbose(` - ${summarizedDiff.file}: ${originalTokens} -> ${newTokens} tokens`, { color: 'magenta' });
8172
- results[originalIndex] = summarizedDiff;
8365
+ const newTokens = entry.diff.tokenCount;
8366
+ logger.verbose(` - ${entry.diff.file}: ${originalTokens} -> ${newTokens} tokens`, { color: 'magenta' });
8367
+ results[originalIndex] = entry.diff;
8173
8368
  });
8369
+ if (skippedCount > 0) {
8370
+ logger.verbose(`Skipped ${skippedCount} pre-summary call(s) — token budget already met after ${summarizedCount} earlier file(s)`, { color: 'cyan' });
8371
+ }
8174
8372
  return results;
8175
8373
  }
8176
8374
  /**
@@ -8436,7 +8634,7 @@ async function summarizeDiffs(rootDiffNode, { tokenizer, logger,
8436
8634
  // with the service defaults means a caller that omits
8437
8635
  // `maxTokens` doesn't accidentally fall into a tighter budget
8438
8636
  // than the rest of the system assumes.
8439
- maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
8637
+ maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, fastPath, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
8440
8638
  // Calculate maxFileTokens as 25% of maxTokens if not specified
8441
8639
  const effectiveMaxFileTokens = maxFileTokens ?? Math.floor(maxTokens * 0.25);
8442
8640
  // PHASE 1: Directory grouping & assessment
@@ -8460,6 +8658,13 @@ maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, t
8460
8658
  maxFileTokens: effectiveMaxFileTokens,
8461
8659
  minTokensForSummary,
8462
8660
  maxConcurrent,
8661
+ // #861, PR 1: pass the overall budget so Phase 2 can short-circuit
8662
+ // once earlier completions drop the running total under the cap.
8663
+ maxTokens,
8664
+ // #861, angle 5: opt-in markdown fast path. Off by default; when
8665
+ // enabled, markdown modification diffs with structural signals
8666
+ // resolve via a templated extract instead of an LLM call.
8667
+ fastPath,
8463
8668
  tokenizer,
8464
8669
  logger,
8465
8670
  chain,
@@ -11437,7 +11642,7 @@ for (var i = 0; i < 256; i++) {
11437
11642
  simpleEscapeMap[i] = simpleEscapeSequence(i);
11438
11643
  }
11439
11644
 
11440
- async function fileChangeParser({ changes, commit, options: { tokenizer, git, llm: model, logger, maxTokens, minTokensForSummary, maxFileTokens, maxConcurrent, metadata, }, }) {
11645
+ async function fileChangeParser({ changes, commit, options: { tokenizer, git, llm: model, logger, maxTokens, minTokensForSummary, maxFileTokens, maxConcurrent, fastPath, metadata, }, }) {
11441
11646
  const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: 10000, chunkOverlap: 250 });
11442
11647
  const summarizationChain = loadSummarizationChain(model, {
11443
11648
  type: 'map_reduce',
@@ -11469,6 +11674,7 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
11469
11674
  minTokensForSummary,
11470
11675
  maxFileTokens,
11471
11676
  maxConcurrent,
11677
+ fastPath,
11472
11678
  textSplitter,
11473
11679
  chain: summarizationChain,
11474
11680
  logger,
@@ -11488,6 +11694,7 @@ function createFileChangeParserOptions({ command, git, llm, logger, model, provi
11488
11694
  minTokensForSummary: service?.minTokensForSummary,
11489
11695
  maxFileTokens: service?.maxFileTokens,
11490
11696
  maxConcurrent: service?.maxConcurrent,
11697
+ fastPath: service?.fastPath,
11491
11698
  metadata: {
11492
11699
  command,
11493
11700
  provider,
@@ -14314,7 +14521,7 @@ const builder$3 = (yargs) => {
14314
14521
  return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
14315
14522
  };
14316
14523
 
14317
- const FIELD_SEPARATOR$2 = '\x1f';
14524
+ const FIELD_SEPARATOR$3 = '\x1f';
14318
14525
  // `%P` (parent hashes, space-separated) lets the TUI distinguish
14319
14526
  // merge commits (parents.length > 1) from regular commits without a
14320
14527
  // second round-trip to git. See #791 stage 3 — merge glyph + HEAD ring.
@@ -14367,13 +14574,13 @@ function parseLogOutput(output) {
14367
14574
  .map((line) => line.trimEnd())
14368
14575
  .filter(Boolean)
14369
14576
  .map((line) => {
14370
- if (!line.includes(FIELD_SEPARATOR$2)) {
14577
+ if (!line.includes(FIELD_SEPARATOR$3)) {
14371
14578
  return {
14372
14579
  type: 'graph',
14373
14580
  graph: line,
14374
14581
  };
14375
14582
  }
14376
- 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);
14377
14584
  return {
14378
14585
  type: 'commit',
14379
14586
  graph: graph.trimEnd(),
@@ -14448,7 +14655,7 @@ function parseNameStatus(output, numstat = []) {
14448
14655
  function parseCommitDetail(metadata, files, numstatOutput = '') {
14449
14656
  const [hash, shortHash, parentsStr, date, author, refs, message, body = ''] = metadata
14450
14657
  .trimEnd()
14451
- .split(FIELD_SEPARATOR$2);
14658
+ .split(FIELD_SEPARATOR$3);
14452
14659
  const numstat = parseNumstat(numstatOutput);
14453
14660
  return {
14454
14661
  shortHash,
@@ -14588,14 +14795,14 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
14588
14795
  };
14589
14796
  }
14590
14797
 
14591
- const FIELD_SEPARATOR$1 = '\x1f';
14798
+ const FIELD_SEPARATOR$2 = '\x1f';
14592
14799
  function parseBranchRefs(output) {
14593
14800
  return output
14594
14801
  .split('\n')
14595
14802
  .map((line) => line.trimEnd())
14596
14803
  .filter(Boolean)
14597
14804
  .map((line) => {
14598
- 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);
14599
14806
  if (!refName || !shortName) {
14600
14807
  return undefined;
14601
14808
  }
@@ -14635,7 +14842,7 @@ async function getBranchOverview(git) {
14635
14842
  const [branchOutput, statusOutput, currentBranchOutput] = await Promise.all([
14636
14843
  git.raw([
14637
14844
  'for-each-ref',
14638
- `--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)`,
14639
14846
  'refs/heads',
14640
14847
  'refs/remotes',
14641
14848
  ]),
@@ -15185,10 +15392,12 @@ async function runCommitDraftWorkflow(input = {}) {
15185
15392
  }
15186
15393
 
15187
15394
  const LOG_INK_CONTEXT_KEYS = [
15395
+ 'bisect',
15188
15396
  'branches',
15189
15397
  'operation',
15190
15398
  'provider',
15191
15399
  'pullRequest',
15400
+ 'reflog',
15192
15401
  'stashes',
15193
15402
  'tags',
15194
15403
  'worktree',
@@ -15908,6 +16117,45 @@ function getLogInkWorkflowActions() {
15908
16117
  kind: 'normal',
15909
16118
  requiresConfirmation: false,
15910
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
+ },
15911
16159
  {
15912
16160
  id: 'ai-commit-summary',
15913
16161
  key: 'I',
@@ -16144,6 +16392,27 @@ const LOG_INK_KEY_BINDINGS = [
16144
16392
  description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
16145
16393
  contexts: ['normal'],
16146
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
+ },
16147
16416
  {
16148
16417
  id: 'navigateBack',
16149
16418
  keys: ['<', 'esc'],
@@ -16274,6 +16543,8 @@ const GLOBAL_BINDING_IDS = [
16274
16543
  'navigateWorktrees',
16275
16544
  'navigatePullRequest',
16276
16545
  'navigateConflicts',
16546
+ 'navigateReflog',
16547
+ 'navigateBisect',
16277
16548
  'navigateBack',
16278
16549
  ];
16279
16550
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -16401,6 +16672,15 @@ function getLogInkFooterHints(options) {
16401
16672
  global: NORMAL_GLOBAL_HINTS,
16402
16673
  };
16403
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
+ }
16404
16684
  return {
16405
16685
  contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'y yank'],
16406
16686
  global: NORMAL_GLOBAL_HINTS,
@@ -16413,14 +16693,26 @@ function getLogInkFooterHints(options) {
16413
16693
  };
16414
16694
  }
16415
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
+ }
16416
16702
  return {
16417
- 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'],
16418
16704
  global: NORMAL_GLOBAL_HINTS,
16419
16705
  };
16420
16706
  }
16421
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
+ }
16422
16714
  return {
16423
- 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'],
16424
16716
  global: NORMAL_GLOBAL_HINTS,
16425
16717
  };
16426
16718
  }
@@ -16452,6 +16744,28 @@ function getLogInkFooterHints(options) {
16452
16744
  global: NORMAL_GLOBAL_HINTS,
16453
16745
  };
16454
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
+ }
16455
16769
  return {
16456
16770
  // History view default hints. Mutating ops (`c` cherry-pick, `R`
16457
16771
  // revert, `Z` reset, `i` interactive-rebase) all route through a
@@ -16461,7 +16775,7 @@ function getLogInkFooterHints(options) {
16461
16775
  // Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
16462
16776
  // the footer stays scannable; full descriptions live in `?` help
16463
16777
  // and the palette.
16464
- 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'],
16465
16779
  global: NORMAL_GLOBAL_HINTS,
16466
16780
  };
16467
16781
  }
@@ -17040,6 +17354,7 @@ function withPushedView(state, value) {
17040
17354
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17041
17355
  diffSource: value === 'diff' ? state.diffSource : undefined,
17042
17356
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
17357
+ compareHead: value === 'diff' ? state.compareHead : undefined,
17043
17358
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
17044
17359
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
17045
17360
  pendingKey: undefined,
@@ -17051,6 +17366,13 @@ function withPoppedView(state) {
17051
17366
  }
17052
17367
  const viewStack = state.viewStack.slice(0, -1);
17053
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';
17054
17376
  return {
17055
17377
  ...state,
17056
17378
  activeView: next,
@@ -17063,6 +17385,8 @@ function withPoppedView(state) {
17063
17385
  selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17064
17386
  diffSource: next === 'diff' ? state.diffSource : undefined,
17065
17387
  stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
17388
+ compareBase: wasOnDiff ? undefined : state.compareBase,
17389
+ compareHead: next === 'diff' ? state.compareHead : undefined,
17066
17390
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
17067
17391
  statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
17068
17392
  pendingKey: undefined,
@@ -17081,6 +17405,7 @@ function withReplacedView(state, value) {
17081
17405
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17082
17406
  diffSource: value === 'diff' ? state.diffSource : undefined,
17083
17407
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
17408
+ compareHead: value === 'diff' ? state.compareHead : undefined,
17084
17409
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
17085
17410
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
17086
17411
  pendingKey: undefined,
@@ -17101,6 +17426,11 @@ function withFilter$1(state, filter, promotedSelections) {
17101
17426
  (filterChanged ? 0 : state.selectedTagIndex);
17102
17427
  const stashIndex = promotedSelections?.stashIndex ??
17103
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;
17104
17434
  return {
17105
17435
  ...state,
17106
17436
  filter,
@@ -17110,6 +17440,7 @@ function withFilter$1(state, filter, promotedSelections) {
17110
17440
  selectedBranchIndex: branchIndex,
17111
17441
  selectedTagIndex: tagIndex,
17112
17442
  selectedStashIndex: stashIndex,
17443
+ selectedReflogIndex: reflogIndex,
17113
17444
  diffPreviewOffset: 0,
17114
17445
  pendingKey: undefined,
17115
17446
  };
@@ -17199,6 +17530,7 @@ function createLogInkState(rows, options = {}) {
17199
17530
  selectedStashIndex: 0,
17200
17531
  selectedWorktreeListIndex: 0,
17201
17532
  selectedConflictFileIndex: 0,
17533
+ selectedReflogIndex: 0,
17202
17534
  branchSort: DEFAULT_BRANCH_SORT_MODE,
17203
17535
  tagSort: DEFAULT_TAG_SORT_MODE,
17204
17536
  paletteFilter: '',
@@ -17446,6 +17778,12 @@ function applyLogInkAction(state, action) {
17446
17778
  selectedStashIndex: clampIndex$1(state.selectedStashIndex + action.delta, action.count),
17447
17779
  pendingKey: undefined,
17448
17780
  };
17781
+ case 'moveReflog':
17782
+ return {
17783
+ ...state,
17784
+ selectedReflogIndex: clampIndex$1(state.selectedReflogIndex + action.delta, action.count),
17785
+ pendingKey: undefined,
17786
+ };
17449
17787
  case 'moveWorktreeListEntry':
17450
17788
  return {
17451
17789
  ...state,
@@ -17670,6 +18008,31 @@ function applyLogInkAction(state, action) {
17670
18008
  worktreeDiffOffset: 0,
17671
18009
  };
17672
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
+ };
17673
18036
  case 'navigateOpenComposeForFile': {
17674
18037
  const next = withPushedView(state, 'status');
17675
18038
  return {
@@ -18025,10 +18388,66 @@ function isStashActionTarget(state) {
18025
18388
  return (state.activeView === 'stash' && state.focus === 'commits') ||
18026
18389
  (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
18027
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
+ }
18028
18399
  function isWorktreeActionTarget(state) {
18029
18400
  return (state.activeView === 'worktrees' && state.focus === 'commits') ||
18030
18401
  (state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
18031
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
+ }
18032
18451
  /**
18033
18452
  * Item count for the active sidebar tab — used by the generic
18034
18453
  * sidebar-Enter handler to decide whether to defer to the per-entity
@@ -18128,6 +18547,20 @@ function getLogInkPaletteExecuteEvents(command, state) {
18128
18547
  return [action({ type: 'pushView', value: 'pull-request' })];
18129
18548
  case 'navigateConflicts':
18130
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
+ })];
18131
18564
  case 'navigateBack':
18132
18565
  return [action({ type: 'popView' })];
18133
18566
  case 'openSelected': {
@@ -18605,6 +19038,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18605
19038
  action({ type: 'setStatus', value: 'jumped to conflicts' }),
18606
19039
  ];
18607
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
+ }
18608
19060
  // `gH` chord: apply the cursored hunk to the index (`git apply
18609
19061
  // --cached`). Sibling of bare `H` which targets the worktree.
18610
19062
  // Discoverable via the footer hint on diff views and the help
@@ -18644,6 +19096,29 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18644
19096
  action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
18645
19097
  ];
18646
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
+ }
18647
19122
  if (inputValue === 'g') {
18648
19123
  if (state.pendingKey === 'g') {
18649
19124
  return [
@@ -18911,6 +19386,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18911
19386
  if (isStashActionTarget(state) && context.stashCount) {
18912
19387
  return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
18913
19388
  }
19389
+ if (isReflogActionTarget(state) && context.reflogCount) {
19390
+ return [action({ type: 'moveReflog', delta: -1, count: context.reflogCount })];
19391
+ }
18914
19392
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
18915
19393
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
18916
19394
  }
@@ -18992,6 +19470,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18992
19470
  if (isStashActionTarget(state) && context.stashCount) {
18993
19471
  return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
18994
19472
  }
19473
+ if (isReflogActionTarget(state) && context.reflogCount) {
19474
+ return [action({ type: 'moveReflog', delta: 1, count: context.reflogCount })];
19475
+ }
18995
19476
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
18996
19477
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
18997
19478
  }
@@ -19063,6 +19544,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19063
19544
  action({ type: 'setStatus', value: 'staging worktree changes' }),
19064
19545
  ];
19065
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
+ }
19066
19571
  if (key.return &&
19067
19572
  state.activeView === 'history' &&
19068
19573
  state.focus === 'commits' &&
@@ -19079,6 +19584,28 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19079
19584
  ];
19080
19585
  }
19081
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
+ }
19082
19609
  // Inspector Actions tab: Enter on the cursored action fires its
19083
19610
  // associated event (cherry-pick / revert / yank / etc.). Wins over
19084
19611
  // the file-list Enter below when the user has [/]-toggled to the
@@ -19258,6 +19785,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19258
19785
  if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
19259
19786
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
19260
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
+ }
19261
19809
  // Per-view worktree action: `D` removes the worktree AND deletes
19262
19810
  // the branch it was tracking (#838). Scoped to the worktrees
19263
19811
  // surface so it intercepts BEFORE the global workflow-by-key
@@ -20636,6 +21184,12 @@ function formatLogInkStatusEmpty({ hasChanges }) {
20636
21184
  }
20637
21185
  return 'Worktree clean. Press gh for history, gb for branches, gz for stash.';
20638
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
+ }
20639
21193
  function formatLogInkComposeEmpty({ hasStaged }) {
20640
21194
  if (hasStaged) {
20641
21195
  return undefined;
@@ -22265,14 +22819,14 @@ function deleteRemoteTag(git, tagName) {
22265
22819
  return runAction$2(() => git.raw(['push', 'origin', `:${tagName}`]), `Deleted remote tag ${tagName}`);
22266
22820
  }
22267
22821
 
22268
- const FIELD_SEPARATOR = '\x1f';
22822
+ const FIELD_SEPARATOR$1 = '\x1f';
22269
22823
  function parseTagRefs(output) {
22270
22824
  return output
22271
22825
  .split('\n')
22272
22826
  .map((line) => line.trimEnd())
22273
22827
  .filter(Boolean)
22274
22828
  .map((line) => {
22275
- const [name, hash, date, subject] = line.split(FIELD_SEPARATOR);
22829
+ const [name, hash, date, subject] = line.split(FIELD_SEPARATOR$1);
22276
22830
  return {
22277
22831
  name,
22278
22832
  hash,
@@ -22284,7 +22838,7 @@ function parseTagRefs(output) {
22284
22838
  async function getTagOverview(git) {
22285
22839
  const output = await git.raw([
22286
22840
  'for-each-ref',
22287
- `--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)`,
22288
22842
  '--sort=-creatordate',
22289
22843
  'refs/tags',
22290
22844
  ]);
@@ -24543,6 +25097,231 @@ function formatPullRequestStateLine(pr) {
24543
25097
  return parts.join(' · ');
24544
25098
  }
24545
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
+
24546
25325
  function sectionLines(title, diff) {
24547
25326
  const lines = diff.split('\n').map((line) => line.trimEnd());
24548
25327
  return [
@@ -24655,7 +25434,7 @@ async function safe(promise) {
24655
25434
  }
24656
25435
  }
24657
25436
  async function loadLogInkContext(git) {
24658
- 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([
24659
25438
  safe(getBranchOverview(git)),
24660
25439
  safe(getPullRequestOverview(git)),
24661
25440
  safe(getTagOverview(git)),
@@ -24664,12 +25443,16 @@ async function loadLogInkContext(git) {
24664
25443
  safe(getWorktreeListOverview(git)),
24665
25444
  safe(getGitOperationOverview(git)),
24666
25445
  safe(getProviderOverview(git)),
25446
+ safe(getReflogOverview(git)),
25447
+ safe(getBisectStatus(git)),
24667
25448
  ]);
24668
25449
  return {
25450
+ bisect,
24669
25451
  branches,
24670
25452
  operation,
24671
25453
  provider,
24672
25454
  pullRequest,
25455
+ reflog,
24673
25456
  stashes,
24674
25457
  tags,
24675
25458
  worktree,
@@ -24695,6 +25478,14 @@ function loadLogInkContextEntries(git) {
24695
25478
  key: 'tags',
24696
25479
  load: () => safe(getTagOverview(git)),
24697
25480
  },
25481
+ {
25482
+ key: 'reflog',
25483
+ load: () => safe(getReflogOverview(git)),
25484
+ },
25485
+ {
25486
+ key: 'bisect',
25487
+ load: () => safe(getBisectStatus(git)),
25488
+ },
24698
25489
  {
24699
25490
  key: 'worktree',
24700
25491
  load: () => safe(getWorktreeOverview(git)),
@@ -25083,6 +25874,10 @@ function LogInkApp(deps) {
25083
25874
  // colors match the commit-diff path.
25084
25875
  const [stashDiffLines, setStashDiffLines] = React.useState(undefined);
25085
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);
25086
25881
  const [hasMoreCommits, setHasMoreCommits] = React.useState(() => (Boolean(logArgv?.interactive && !logArgv.limit) &&
25087
25882
  getCommitRows(rows).length >= LOG_INTERACTIVE_DEFAULT_LIMIT));
25088
25883
  const [loadingMoreCommits, setLoadingMoreCommits] = React.useState(false);
@@ -25177,6 +25972,12 @@ function LogInkApp(deps) {
25177
25972
  return all;
25178
25973
  return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
25179
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]);
25180
25981
  const dispatch = React.useCallback((action) => {
25181
25982
  setState((current) => applyLogInkAction(current, action));
25182
25983
  }, []);
@@ -25387,6 +26188,41 @@ function LogInkApp(deps) {
25387
26188
  })();
25388
26189
  return () => { active = false; };
25389
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]);
25390
26226
  React.useEffect(() => {
25391
26227
  let active = true;
25392
26228
  async function loadWorktreeHunks() {
@@ -25897,6 +26733,50 @@ function LogInkApp(deps) {
25897
26733
  return { ok: false, message: 'No stash selected' };
25898
26734
  return popStash(git, stash);
25899
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
+ },
25900
26780
  'checkout-file-from-stash': async () => {
25901
26781
  const path = payload?.trim();
25902
26782
  const ref = state.stashDiffRef;
@@ -26569,9 +27449,13 @@ function LogInkApp(deps) {
26569
27449
  // perf pass) — reading them here is O(1) instead of O(branches +
26570
27450
  // tags + stashes + worktrees) per keystroke.
26571
27451
  const branchVisibleCount = filteredBranchList.length;
27452
+ const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
26572
27453
  const tagVisibleCount = filteredTagList.length;
27454
+ const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
26573
27455
  const stashVisibleCount = filteredStashList.length;
26574
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;
26575
27459
  const worktreeVisibleCount = filteredWorktreeList.length;
26576
27460
  // When the diff view is showing a stash patch, swap the previewLineCount
26577
27461
  // to the stash diff length so the existing pageDetailPreview path
@@ -26596,8 +27480,12 @@ function LogInkApp(deps) {
26596
27480
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
26597
27481
  commitDiffHunkOffsets,
26598
27482
  branchCount: branchVisibleCount,
27483
+ branchSelectedShortName,
26599
27484
  tagCount: tagVisibleCount,
27485
+ tagSelectedName,
26600
27486
  stashCount: stashVisibleCount,
27487
+ reflogCount: reflogVisibleCount,
27488
+ reflogSelectedHash,
26601
27489
  stashSelectedRef,
26602
27490
  stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
26603
27491
  stashDiffSelectedPath,
@@ -26703,12 +27591,15 @@ function LogInkApp(deps) {
26703
27591
  if (showOnboarding) {
26704
27592
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
26705
27593
  }
26706
- 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));
26707
27595
  }
26708
27596
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
26709
27597
  const { Box, Text } = components;
26710
27598
  const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
26711
- 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;
26712
27603
  const repo = context.provider?.repository.owner && context.provider.repository.name
26713
27604
  ? `${context.provider.repository.owner}/${context.provider.repository.name}`
26714
27605
  : 'local repository';
@@ -26963,12 +27854,12 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
26963
27854
  : []),
26964
27855
  ];
26965
27856
  }
26966
- 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) {
26967
27858
  if (state.activeView === 'status') {
26968
27859
  return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26969
27860
  }
26970
27861
  if (state.activeView === 'diff') {
26971
- 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);
26972
27863
  }
26973
27864
  if (state.activeView === 'compose') {
26974
27865
  return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
@@ -26979,6 +27870,12 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
26979
27870
  if (state.activeView === 'tags') {
26980
27871
  return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26981
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
+ }
26982
27879
  if (state.activeView === 'stash') {
26983
27880
  return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26984
27881
  }
@@ -27606,6 +28503,150 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
27606
28503
  width,
27607
28504
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
27608
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
+ }
27609
28650
  function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
27610
28651
  const { Box, Text } = components;
27611
28652
  const focused = state.focus === 'commits';
@@ -27811,7 +28852,7 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
27811
28852
  h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
27812
28853
  ];
27813
28854
  }
27814
- 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) {
27815
28856
  const { Box, Text } = components;
27816
28857
  const focused = state.focus === 'commits';
27817
28858
  const worktree = context.worktree;
@@ -27902,6 +28943,50 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
27902
28943
  dimColor: index > 0,
27903
28944
  }, truncate$1(line, width - 4))), ...stashBodyNodes);
27904
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
+ }
27905
28990
  // diffSource disambiguates: 'commit' was set when the user opened the
27906
28991
  // diff via history → Enter (read-only commit-diff explore), 'worktree'
27907
28992
  // was set when they came from status → Enter (stage / hunk / revert).
@@ -28760,6 +29845,7 @@ function renderFooter(h, components, state, context, theme, idleTip) {
28760
29845
  showHelp: state.showHelp,
28761
29846
  sidebarTab: state.sidebarTab,
28762
29847
  sidebarItemCount,
29848
+ compareBaseSet: Boolean(state.compareBase),
28763
29849
  });
28764
29850
  // Real status messages always win; idle tips only fill the slot when it
28765
29851
  // would otherwise be empty.