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.
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.44.0";
81
+ const BUILD_VERSION = "0.46.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -1252,6 +1252,18 @@ const schema$1 = {
1252
1252
  "$ref": "#/definitions/DynamicModelPreference",
1253
1253
  "description": "Default dynamic routing preference when model is set to \"dynamic\".",
1254
1254
  "default": "balanced"
1255
+ },
1256
+ "fastPath": {
1257
+ "type": "object",
1258
+ "properties": {
1259
+ "markdown": {
1260
+ "type": "boolean",
1261
+ "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.",
1262
+ "default": false
1263
+ }
1264
+ },
1265
+ "additionalProperties": false,
1266
+ "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."
1255
1267
  }
1256
1268
  },
1257
1269
  "required": [
@@ -1665,6 +1677,18 @@ const schema$1 = {
1665
1677
  "$ref": "#/definitions/DynamicModelPreference",
1666
1678
  "description": "Default dynamic routing preference when model is set to \"dynamic\".",
1667
1679
  "default": "balanced"
1680
+ },
1681
+ "fastPath": {
1682
+ "type": "object",
1683
+ "properties": {
1684
+ "markdown": {
1685
+ "type": "boolean",
1686
+ "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.",
1687
+ "default": false
1688
+ }
1689
+ },
1690
+ "additionalProperties": false,
1691
+ "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."
1668
1692
  }
1669
1693
  },
1670
1694
  "required": [
@@ -1821,6 +1845,18 @@ const schema$1 = {
1821
1845
  "$ref": "#/definitions/DynamicModelPreference",
1822
1846
  "description": "Default dynamic routing preference when model is set to \"dynamic\".",
1823
1847
  "default": "balanced"
1848
+ },
1849
+ "fastPath": {
1850
+ "type": "object",
1851
+ "properties": {
1852
+ "markdown": {
1853
+ "type": "boolean",
1854
+ "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.",
1855
+ "default": false
1856
+ }
1857
+ },
1858
+ "additionalProperties": false,
1859
+ "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."
1824
1860
  }
1825
1861
  },
1826
1862
  "required": [
@@ -7914,6 +7950,109 @@ async function summarize(documents$1, { chain, textSplitter, options, logger, to
7914
7950
  return res.text && res.text.trim();
7915
7951
  }
7916
7952
 
7953
+ /**
7954
+ * Markdown-aware fast path (#861, angle 5). For modification diffs to
7955
+ * `.md` / `.mdx` / `.markdown` files, build a templated summary from
7956
+ * the changed structure (added / removed / updated headings) instead
7957
+ * of paying for an LLM call. Mirrors `trivialDiff` from #845: a deterministic
7958
+ * skip when the diff's meaning is captured by its shape.
7959
+ *
7960
+ * Quality / cost trade-off, on purpose: LLM summaries of markdown edits
7961
+ * are wordier ("expanded the configuration section with new examples,
7962
+ * fixed typos in troubleshooting") but most of that detail isn't load-
7963
+ * bearing for a commit message. The templated summary names the
7964
+ * structural changes (which sections moved) plus a +/- line count, and
7965
+ * defers to the LLM only when the diff has no clear structural signals
7966
+ * (paragraph-only edits, where a templated summary would actually drop
7967
+ * useful context).
7968
+ */
7969
+ const MARKDOWN_EXTENSIONS = ['.md', '.markdown', '.mdx'];
7970
+ const MAX_HEADINGS_PER_BUCKET = 6;
7971
+ function isMarkdownFile(path) {
7972
+ const lower = path.toLowerCase();
7973
+ return MARKDOWN_EXTENSIONS.some((ext) => lower.endsWith(ext));
7974
+ }
7975
+ function summarizeMarkdownDiff(fileDiff) {
7976
+ if (!isMarkdownFile(fileDiff.file))
7977
+ return undefined;
7978
+ const addedHeadings = new Set();
7979
+ const removedHeadings = new Set();
7980
+ let addedLines = 0;
7981
+ let removedLines = 0;
7982
+ for (const line of fileDiff.diff.split('\n')) {
7983
+ if (isHeaderLine$1(line))
7984
+ continue;
7985
+ if (line.startsWith('+')) {
7986
+ addedLines++;
7987
+ const heading = parseHeading(line.slice(1));
7988
+ if (heading)
7989
+ addedHeadings.add(heading);
7990
+ }
7991
+ else if (line.startsWith('-')) {
7992
+ removedLines++;
7993
+ const heading = parseHeading(line.slice(1));
7994
+ if (heading)
7995
+ removedHeadings.add(heading);
7996
+ }
7997
+ }
7998
+ // No content change → nothing to summarize. Caller falls through.
7999
+ if (addedLines === 0 && removedLines === 0)
8000
+ return undefined;
8001
+ // No structural signal → fall through to LLM. We only fast-path
8002
+ // when the diff has heading-level changes; pure paragraph edits go
8003
+ // to the LLM so the summary keeps its detail.
8004
+ if (addedHeadings.size === 0 && removedHeadings.size === 0) {
8005
+ return undefined;
8006
+ }
8007
+ // A heading that appears in both buckets is likely an update (kept
8008
+ // around but its body changed) rather than two distinct events.
8009
+ // The naive split-by-bucket diff format used by git emits the old
8010
+ // text under `-` and the new text under `+`; an unchanged heading
8011
+ // line shouldn't show up in either bucket via the standard hunk
8012
+ // path, but defensively de-dupe in case the diff producer emits
8013
+ // surrounding context as +/-.
8014
+ const updated = new Set([...addedHeadings].filter((h) => removedHeadings.has(h)));
8015
+ const purelyAdded = [...addedHeadings].filter((h) => !updated.has(h));
8016
+ const purelyRemoved = [...removedHeadings].filter((h) => !updated.has(h));
8017
+ const parts = [`Updated markdown \`${fileDiff.file}\``];
8018
+ if (purelyAdded.length) {
8019
+ parts.push(`new sections: ${formatHeadingList(purelyAdded)}`);
8020
+ }
8021
+ if (purelyRemoved.length) {
8022
+ parts.push(`removed sections: ${formatHeadingList(purelyRemoved)}`);
8023
+ }
8024
+ if (updated.size) {
8025
+ parts.push(`updated sections: ${formatHeadingList([...updated])}`);
8026
+ }
8027
+ parts.push(`+${addedLines}/-${removedLines} lines`);
8028
+ return `${parts.join('. ')}.`;
8029
+ }
8030
+ function formatHeadingList(headings) {
8031
+ if (headings.length <= MAX_HEADINGS_PER_BUCKET) {
8032
+ return headings.join(', ');
8033
+ }
8034
+ const shown = headings.slice(0, MAX_HEADINGS_PER_BUCKET);
8035
+ const remainder = headings.length - shown.length;
8036
+ return `${shown.join(', ')} (+${remainder} more)`;
8037
+ }
8038
+ function isHeaderLine$1(line) {
8039
+ return (line.startsWith('diff --git') ||
8040
+ line.startsWith('index ') ||
8041
+ line.startsWith('--- ') ||
8042
+ line.startsWith('+++ ') ||
8043
+ line.startsWith('@@') ||
8044
+ line.startsWith('new file mode') ||
8045
+ line.startsWith('deleted file mode') ||
8046
+ line.startsWith('similarity index') ||
8047
+ line.startsWith('rename from ') ||
8048
+ line.startsWith('rename to ') ||
8049
+ line.startsWith('Binary files '));
8050
+ }
8051
+ function parseHeading(line) {
8052
+ const match = line.match(/^#{1,6}\s+(.+?)\s*$/);
8053
+ return match ? match[1].trim() : undefined;
8054
+ }
8055
+
7917
8056
  /**
7918
8057
  * Inspect a unified-diff string and report its shape, or undefined
7919
8058
  * if the diff isn't trivial (mixed +/- lines, weird headers, etc.).
@@ -8051,7 +8190,7 @@ function isCacheEnabled$1() {
8051
8190
  * synthetic summaries usually drop the directory token totals under
8052
8191
  * budget so wave consolidation skips too.
8053
8192
  */
8054
- async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, }) {
8193
+ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, fastPath, }) {
8055
8194
  const trivialSummary = summarizeTrivialDiff(fileDiff);
8056
8195
  if (trivialSummary !== undefined) {
8057
8196
  logger.verbose(` - ${fileDiff.file}: trivial-shape skip (no LLM call)`, { color: 'gray' });
@@ -8061,6 +8200,25 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
8061
8200
  tokenCount: tokenizer(trivialSummary),
8062
8201
  };
8063
8202
  }
8203
+ // Markdown fast path (#861, angle 5). Opt-in via `fastPath.markdown`
8204
+ // because it's a lossy optimization: the templated summary names
8205
+ // structural changes only and drops body-text detail that an LLM
8206
+ // summary would carry. Off by default; users who prefer summary
8207
+ // fidelity over speed (which is the safer default for commit-message
8208
+ // generation downstream) keep the LLM path. When the flag IS on, the
8209
+ // fast path still falls through to the LLM for paragraph-only edits
8210
+ // where a templated summary would lose useful context.
8211
+ if (fastPath?.markdown) {
8212
+ const markdownSummary = summarizeMarkdownDiff(fileDiff);
8213
+ if (markdownSummary !== undefined) {
8214
+ logger.verbose(` - ${fileDiff.file}: markdown fast-path skip (no LLM call)`, { color: 'gray' });
8215
+ return {
8216
+ ...fileDiff,
8217
+ diff: markdownSummary,
8218
+ tokenCount: tokenizer(markdownSummary),
8219
+ };
8220
+ }
8221
+ }
8064
8222
  // Cache lookup (#845, PR 5). Keyed on the file's literal diff
8065
8223
  // content + the active model + the summarization prompt hash.
8066
8224
  // A hit returns the prior summary instantly; on iterative
@@ -8172,7 +8330,7 @@ function createLimit$2(maxConcurrent) {
8172
8330
  * @returns Array of file diffs with large files summarized
8173
8331
  */
8174
8332
  async function summarizeLargeFiles(diffs, options) {
8175
- const { maxFileTokens, minTokensForSummary, maxConcurrent, tokenizer, logger, chain, textSplitter, metadata } = options;
8333
+ const { maxFileTokens, minTokensForSummary, maxConcurrent, maxTokens, fastPath, tokenizer, logger, chain, textSplitter, metadata, } = options;
8176
8334
  // Identify files that need summarization
8177
8335
  const filesToSummarize = [];
8178
8336
  const results = [...diffs];
@@ -8184,17 +8342,57 @@ async function summarizeLargeFiles(diffs, options) {
8184
8342
  if (filesToSummarize.length === 0) {
8185
8343
  return results;
8186
8344
  }
8187
- logger.verbose(`Pre-summarizing ${filesToSummarize.length} large file(s)...`, { color: 'blue' });
8188
- // Process large files in waves
8189
- const summarizedFiles = await processInWaves$1(filesToSummarize, async ({ diff }) => summarizeFileDiff(diff, { chain, textSplitter, tokenizer, logger, metadata }), maxConcurrent);
8190
- // Update results with summarized files
8191
- summarizedFiles.forEach((summarizedDiff, i) => {
8345
+ // Incremental termination (#861, PR 1). When the caller supplies a
8346
+ // budget, dispatch biggest-first and re-check the running total per
8347
+ // dispatch once earlier completions drop the total under maxTokens,
8348
+ // the remaining queued files skip the LLM and keep their raw diffs.
8349
+ // Mirrors the Phase 3 pattern in `summarizeDiffs.ts`. Without a
8350
+ // budget (undefined), behavior matches the prior path: every
8351
+ // eligible file is summarized regardless.
8352
+ filesToSummarize.sort((a, b) => b.diff.tokenCount - a.diff.tokenCount);
8353
+ const incrementalTermination = maxTokens !== undefined;
8354
+ let runningTotal = diffs.reduce((sum, diff) => sum + diff.tokenCount, 0);
8355
+ let summarizedCount = 0;
8356
+ let skippedCount = 0;
8357
+ logger.verbose(`Pre-summarizing up to ${filesToSummarize.length} large file(s)...`, { color: 'blue' });
8358
+ const processed = await processInWaves$1(filesToSummarize, async ({ diff }) => {
8359
+ // Re-check the budget at dispatch time when the caller supplied
8360
+ // one. Earlier completions may have already dropped the total
8361
+ // under the cap; in that case skip the LLM call entirely and
8362
+ // keep the raw diff. Without a budget, every eligible file is
8363
+ // summarized (preserves the prior behavior).
8364
+ if (incrementalTermination && runningTotal <= maxTokens) {
8365
+ return { diff, summarized: false };
8366
+ }
8367
+ const summarized = await summarizeFileDiff(diff, {
8368
+ chain,
8369
+ textSplitter,
8370
+ tokenizer,
8371
+ logger,
8372
+ metadata,
8373
+ fastPath,
8374
+ });
8375
+ const delta = diff.tokenCount - summarized.tokenCount;
8376
+ if (delta > 0) {
8377
+ runningTotal -= delta;
8378
+ }
8379
+ return { diff: summarized, summarized: true };
8380
+ }, maxConcurrent);
8381
+ processed.forEach((entry, i) => {
8192
8382
  const originalIndex = filesToSummarize[i].index;
8383
+ if (!entry.summarized) {
8384
+ skippedCount++;
8385
+ return;
8386
+ }
8387
+ summarizedCount++;
8193
8388
  const originalTokens = results[originalIndex].tokenCount;
8194
- const newTokens = summarizedDiff.tokenCount;
8195
- logger.verbose(` - ${summarizedDiff.file}: ${originalTokens} -> ${newTokens} tokens`, { color: 'magenta' });
8196
- results[originalIndex] = summarizedDiff;
8389
+ const newTokens = entry.diff.tokenCount;
8390
+ logger.verbose(` - ${entry.diff.file}: ${originalTokens} -> ${newTokens} tokens`, { color: 'magenta' });
8391
+ results[originalIndex] = entry.diff;
8197
8392
  });
8393
+ if (skippedCount > 0) {
8394
+ logger.verbose(`Skipped ${skippedCount} pre-summary call(s) — token budget already met after ${summarizedCount} earlier file(s)`, { color: 'cyan' });
8395
+ }
8198
8396
  return results;
8199
8397
  }
8200
8398
  /**
@@ -8460,7 +8658,7 @@ async function summarizeDiffs(rootDiffNode, { tokenizer, logger,
8460
8658
  // with the service defaults means a caller that omits
8461
8659
  // `maxTokens` doesn't accidentally fall into a tighter budget
8462
8660
  // than the rest of the system assumes.
8463
- maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
8661
+ maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, fastPath, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
8464
8662
  // Calculate maxFileTokens as 25% of maxTokens if not specified
8465
8663
  const effectiveMaxFileTokens = maxFileTokens ?? Math.floor(maxTokens * 0.25);
8466
8664
  // PHASE 1: Directory grouping & assessment
@@ -8484,6 +8682,13 @@ maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, t
8484
8682
  maxFileTokens: effectiveMaxFileTokens,
8485
8683
  minTokensForSummary,
8486
8684
  maxConcurrent,
8685
+ // #861, PR 1: pass the overall budget so Phase 2 can short-circuit
8686
+ // once earlier completions drop the running total under the cap.
8687
+ maxTokens,
8688
+ // #861, angle 5: opt-in markdown fast path. Off by default; when
8689
+ // enabled, markdown modification diffs with structural signals
8690
+ // resolve via a templated extract instead of an LLM call.
8691
+ fastPath,
8487
8692
  tokenizer,
8488
8693
  logger,
8489
8694
  chain,
@@ -11461,7 +11666,7 @@ for (var i = 0; i < 256; i++) {
11461
11666
  simpleEscapeMap[i] = simpleEscapeSequence(i);
11462
11667
  }
11463
11668
 
11464
- async function fileChangeParser({ changes, commit, options: { tokenizer, git, llm: model, logger, maxTokens, minTokensForSummary, maxFileTokens, maxConcurrent, metadata, }, }) {
11669
+ async function fileChangeParser({ changes, commit, options: { tokenizer, git, llm: model, logger, maxTokens, minTokensForSummary, maxFileTokens, maxConcurrent, fastPath, metadata, }, }) {
11465
11670
  const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: 10000, chunkOverlap: 250 });
11466
11671
  const summarizationChain = loadSummarizationChain(model, {
11467
11672
  type: 'map_reduce',
@@ -11493,6 +11698,7 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
11493
11698
  minTokensForSummary,
11494
11699
  maxFileTokens,
11495
11700
  maxConcurrent,
11701
+ fastPath,
11496
11702
  textSplitter,
11497
11703
  chain: summarizationChain,
11498
11704
  logger,
@@ -11512,6 +11718,7 @@ function createFileChangeParserOptions({ command, git, llm, logger, model, provi
11512
11718
  minTokensForSummary: service?.minTokensForSummary,
11513
11719
  maxFileTokens: service?.maxFileTokens,
11514
11720
  maxConcurrent: service?.maxConcurrent,
11721
+ fastPath: service?.fastPath,
11515
11722
  metadata: {
11516
11723
  command,
11517
11724
  provider,
@@ -14338,7 +14545,7 @@ const builder$3 = (yargs) => {
14338
14545
  return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
14339
14546
  };
14340
14547
 
14341
- const FIELD_SEPARATOR$2 = '\x1f';
14548
+ const FIELD_SEPARATOR$3 = '\x1f';
14342
14549
  // `%P` (parent hashes, space-separated) lets the TUI distinguish
14343
14550
  // merge commits (parents.length > 1) from regular commits without a
14344
14551
  // second round-trip to git. See #791 stage 3 — merge glyph + HEAD ring.
@@ -14391,13 +14598,13 @@ function parseLogOutput(output) {
14391
14598
  .map((line) => line.trimEnd())
14392
14599
  .filter(Boolean)
14393
14600
  .map((line) => {
14394
- if (!line.includes(FIELD_SEPARATOR$2)) {
14601
+ if (!line.includes(FIELD_SEPARATOR$3)) {
14395
14602
  return {
14396
14603
  type: 'graph',
14397
14604
  graph: line,
14398
14605
  };
14399
14606
  }
14400
- const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$2);
14607
+ const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$3);
14401
14608
  return {
14402
14609
  type: 'commit',
14403
14610
  graph: graph.trimEnd(),
@@ -14472,7 +14679,7 @@ function parseNameStatus(output, numstat = []) {
14472
14679
  function parseCommitDetail(metadata, files, numstatOutput = '') {
14473
14680
  const [hash, shortHash, parentsStr, date, author, refs, message, body = ''] = metadata
14474
14681
  .trimEnd()
14475
- .split(FIELD_SEPARATOR$2);
14682
+ .split(FIELD_SEPARATOR$3);
14476
14683
  const numstat = parseNumstat(numstatOutput);
14477
14684
  return {
14478
14685
  shortHash,
@@ -14612,14 +14819,14 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
14612
14819
  };
14613
14820
  }
14614
14821
 
14615
- const FIELD_SEPARATOR$1 = '\x1f';
14822
+ const FIELD_SEPARATOR$2 = '\x1f';
14616
14823
  function parseBranchRefs(output) {
14617
14824
  return output
14618
14825
  .split('\n')
14619
14826
  .map((line) => line.trimEnd())
14620
14827
  .filter(Boolean)
14621
14828
  .map((line) => {
14622
- const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$1);
14829
+ const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$2);
14623
14830
  if (!refName || !shortName) {
14624
14831
  return undefined;
14625
14832
  }
@@ -14659,7 +14866,7 @@ async function getBranchOverview(git) {
14659
14866
  const [branchOutput, statusOutput, currentBranchOutput] = await Promise.all([
14660
14867
  git.raw([
14661
14868
  'for-each-ref',
14662
- `--format=%(refname)${FIELD_SEPARATOR$1}%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(upstream:short)${FIELD_SEPARATOR$1}%(HEAD)${FIELD_SEPARATOR$1}%(committerdate:short)${FIELD_SEPARATOR$1}%(contents:subject)`,
14869
+ `--format=%(refname)${FIELD_SEPARATOR$2}%(refname:short)${FIELD_SEPARATOR$2}%(objectname:short)${FIELD_SEPARATOR$2}%(upstream:short)${FIELD_SEPARATOR$2}%(HEAD)${FIELD_SEPARATOR$2}%(committerdate:short)${FIELD_SEPARATOR$2}%(contents:subject)`,
14663
14870
  'refs/heads',
14664
14871
  'refs/remotes',
14665
14872
  ]),
@@ -15209,10 +15416,12 @@ async function runCommitDraftWorkflow(input = {}) {
15209
15416
  }
15210
15417
 
15211
15418
  const LOG_INK_CONTEXT_KEYS = [
15419
+ 'bisect',
15212
15420
  'branches',
15213
15421
  'operation',
15214
15422
  'provider',
15215
15423
  'pullRequest',
15424
+ 'reflog',
15216
15425
  'stashes',
15217
15426
  'tags',
15218
15427
  'worktree',
@@ -15932,6 +16141,45 @@ function getLogInkWorkflowActions() {
15932
16141
  kind: 'normal',
15933
16142
  requiresConfirmation: false,
15934
16143
  },
16144
+ {
16145
+ // #784 — bisect workflow actions. All four are scoped per-view in
16146
+ // inkInput (active only when activeView === 'bisect') so the
16147
+ // single-letter keys stay free elsewhere. Empty `key` keeps them
16148
+ // palette-discoverable. Reset is the only destructive one — it
16149
+ // throws away the bisect state — so it routes through y-confirm;
16150
+ // good / bad / skip are recoverable via `git bisect log` and run
16151
+ // immediately.
16152
+ id: 'bisect-good',
16153
+ key: '',
16154
+ label: 'Bisect: mark good',
16155
+ description: 'Mark the current bisect candidate as good and advance to the next one.',
16156
+ kind: 'normal',
16157
+ requiresConfirmation: false,
16158
+ },
16159
+ {
16160
+ id: 'bisect-bad',
16161
+ key: '',
16162
+ label: 'Bisect: mark bad',
16163
+ description: 'Mark the current bisect candidate as bad and advance to the next one.',
16164
+ kind: 'normal',
16165
+ requiresConfirmation: false,
16166
+ },
16167
+ {
16168
+ id: 'bisect-skip',
16169
+ key: '',
16170
+ label: 'Bisect: skip candidate',
16171
+ description: 'Skip the current bisect candidate (e.g. it does not build) and advance.',
16172
+ kind: 'normal',
16173
+ requiresConfirmation: false,
16174
+ },
16175
+ {
16176
+ id: 'bisect-reset',
16177
+ key: '',
16178
+ label: 'Bisect: reset',
16179
+ description: 'End the bisect session and restore HEAD. Discards in-progress bisect state.',
16180
+ kind: 'destructive',
16181
+ requiresConfirmation: true,
16182
+ },
15935
16183
  {
15936
16184
  id: 'ai-commit-summary',
15937
16185
  key: 'I',
@@ -16168,6 +16416,27 @@ const LOG_INK_KEY_BINDINGS = [
16168
16416
  description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
16169
16417
  contexts: ['normal'],
16170
16418
  },
16419
+ {
16420
+ id: 'navigateReflog',
16421
+ keys: ['gr'],
16422
+ label: 'reflog',
16423
+ description: 'Push the reflog browser view — chronological recovery log.',
16424
+ contexts: ['normal'],
16425
+ },
16426
+ {
16427
+ id: 'navigateBisect',
16428
+ keys: ['gB'],
16429
+ label: 'bisect',
16430
+ description: 'Push the bisect workflow view (#784). Capital B disambiguates from gb (branches). Available whenever a bisect is in progress; surfaces the current candidate and the good / bad / skip / reset action keys.',
16431
+ contexts: ['normal'],
16432
+ },
16433
+ {
16434
+ id: 'markForCompare',
16435
+ keys: ['m'],
16436
+ label: 'mark compare',
16437
+ description: 'Mark the cursored ref (branch / tag / commit) as the base for a compare-two-refs diff (#779). Press again on the same ref to clear; with a base set, Enter on another ref opens the compare diff.',
16438
+ contexts: ['commits'],
16439
+ },
16171
16440
  {
16172
16441
  id: 'navigateBack',
16173
16442
  keys: ['<', 'esc'],
@@ -16298,6 +16567,8 @@ const GLOBAL_BINDING_IDS = [
16298
16567
  'navigateWorktrees',
16299
16568
  'navigatePullRequest',
16300
16569
  'navigateConflicts',
16570
+ 'navigateReflog',
16571
+ 'navigateBisect',
16301
16572
  'navigateBack',
16302
16573
  ];
16303
16574
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -16425,6 +16696,15 @@ function getLogInkFooterHints(options) {
16425
16696
  global: NORMAL_GLOBAL_HINTS,
16426
16697
  };
16427
16698
  }
16699
+ if (options.diffSource === 'compare') {
16700
+ // Compare-two-refs (#779): read-only diff with no per-file
16701
+ // cherry-pick or hunk apply (those don't make sense across
16702
+ // arbitrary refs). Just scroll + back out.
16703
+ return {
16704
+ contextual: ['j/k lines', splitToggleHint, 'esc back'],
16705
+ global: NORMAL_GLOBAL_HINTS,
16706
+ };
16707
+ }
16428
16708
  return {
16429
16709
  contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'y yank'],
16430
16710
  global: NORMAL_GLOBAL_HINTS,
@@ -16437,14 +16717,26 @@ function getLogInkFooterHints(options) {
16437
16717
  };
16438
16718
  }
16439
16719
  if (options.activeView === 'branches') {
16720
+ if (options.compareBaseSet) {
16721
+ return {
16722
+ contextual: ['↑/↓ branches', 'enter compare', 'm clear', 'esc back'],
16723
+ global: NORMAL_GLOBAL_HINTS,
16724
+ };
16725
+ }
16440
16726
  return {
16441
- contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 's sort', 'y yank'],
16727
+ contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 'm compare', 's sort', 'y yank'],
16442
16728
  global: NORMAL_GLOBAL_HINTS,
16443
16729
  };
16444
16730
  }
16445
16731
  if (options.activeView === 'tags') {
16732
+ if (options.compareBaseSet) {
16733
+ return {
16734
+ contextual: ['↑/↓ tags', 'enter compare', 'm clear', 'esc back'],
16735
+ global: NORMAL_GLOBAL_HINTS,
16736
+ };
16737
+ }
16446
16738
  return {
16447
- contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 's sort', 'y yank'],
16739
+ contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 'm compare', 's sort', 'y yank'],
16448
16740
  global: NORMAL_GLOBAL_HINTS,
16449
16741
  };
16450
16742
  }
@@ -16476,6 +16768,28 @@ function getLogInkFooterHints(options) {
16476
16768
  global: NORMAL_GLOBAL_HINTS,
16477
16769
  };
16478
16770
  }
16771
+ if (options.activeView === 'reflog') {
16772
+ return {
16773
+ contextual: ['↑/↓ entries', 'enter inspect', 'esc back'],
16774
+ global: NORMAL_GLOBAL_HINTS,
16775
+ };
16776
+ }
16777
+ if (options.activeView === 'bisect') {
16778
+ return {
16779
+ contextual: ['g good', 'b bad', 's skip', 'x reset', 'esc back'],
16780
+ global: NORMAL_GLOBAL_HINTS,
16781
+ };
16782
+ }
16783
+ if (options.compareBaseSet) {
16784
+ // History view with a compare base set — Enter is overridden to
16785
+ // open the compare diff; show the override + the bail-out key.
16786
+ // Mutate / new chips are dropped so the footer doesn't compete
16787
+ // with the active workflow.
16788
+ return {
16789
+ contextual: ['↑/↓ move', 'enter compare', 'm clear', 'esc back'],
16790
+ global: NORMAL_GLOBAL_HINTS,
16791
+ };
16792
+ }
16479
16793
  return {
16480
16794
  // History view default hints. Mutating ops (`c` cherry-pick, `R`
16481
16795
  // revert, `Z` reset, `i` interactive-rebase) all route through a
@@ -16485,7 +16799,7 @@ function getLogInkFooterHints(options) {
16485
16799
  // Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
16486
16800
  // the footer stays scannable; full descriptions live in `?` help
16487
16801
  // and the palette.
16488
- contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
16802
+ contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'm compare', 'y/Y yank', '/ search'],
16489
16803
  global: NORMAL_GLOBAL_HINTS,
16490
16804
  };
16491
16805
  }
@@ -17064,6 +17378,7 @@ function withPushedView(state, value) {
17064
17378
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17065
17379
  diffSource: value === 'diff' ? state.diffSource : undefined,
17066
17380
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
17381
+ compareHead: value === 'diff' ? state.compareHead : undefined,
17067
17382
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
17068
17383
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
17069
17384
  pendingKey: undefined,
@@ -17075,6 +17390,13 @@ function withPoppedView(state) {
17075
17390
  }
17076
17391
  const viewStack = state.viewStack.slice(0, -1);
17077
17392
  const next = topOfStack(viewStack);
17393
+ // #779 — compareBase is "cleared when the diff view is popped." We
17394
+ // detect that case by checking if the *previous* top was 'diff'.
17395
+ // The compare workflow ends when the user backs out of the compare
17396
+ // diff; on the next mark they re-set the base. Other view pops
17397
+ // preserve compareBase so the user can move between branches / tags /
17398
+ // history while hunting for a head ref.
17399
+ const wasOnDiff = state.activeView === 'diff';
17078
17400
  return {
17079
17401
  ...state,
17080
17402
  activeView: next,
@@ -17087,6 +17409,8 @@ function withPoppedView(state) {
17087
17409
  selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17088
17410
  diffSource: next === 'diff' ? state.diffSource : undefined,
17089
17411
  stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
17412
+ compareBase: wasOnDiff ? undefined : state.compareBase,
17413
+ compareHead: next === 'diff' ? state.compareHead : undefined,
17090
17414
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
17091
17415
  statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
17092
17416
  pendingKey: undefined,
@@ -17105,6 +17429,7 @@ function withReplacedView(state, value) {
17105
17429
  selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
17106
17430
  diffSource: value === 'diff' ? state.diffSource : undefined,
17107
17431
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
17432
+ compareHead: value === 'diff' ? state.compareHead : undefined,
17108
17433
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
17109
17434
  statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
17110
17435
  pendingKey: undefined,
@@ -17125,6 +17450,11 @@ function withFilter$1(state, filter, promotedSelections) {
17125
17450
  (filterChanged ? 0 : state.selectedTagIndex);
17126
17451
  const stashIndex = promotedSelections?.stashIndex ??
17127
17452
  (filterChanged ? 0 : state.selectedStashIndex);
17453
+ // Reflog (#781) snaps to 0 on filter change rather than rectifying.
17454
+ // The list is chronological and the user is unlikely to be tracking
17455
+ // a specific entry through filter changes — the simpler reset
17456
+ // matches the "find recovery target by typing" interaction.
17457
+ const reflogIndex = filterChanged ? 0 : state.selectedReflogIndex;
17128
17458
  return {
17129
17459
  ...state,
17130
17460
  filter,
@@ -17134,6 +17464,7 @@ function withFilter$1(state, filter, promotedSelections) {
17134
17464
  selectedBranchIndex: branchIndex,
17135
17465
  selectedTagIndex: tagIndex,
17136
17466
  selectedStashIndex: stashIndex,
17467
+ selectedReflogIndex: reflogIndex,
17137
17468
  diffPreviewOffset: 0,
17138
17469
  pendingKey: undefined,
17139
17470
  };
@@ -17223,6 +17554,7 @@ function createLogInkState(rows, options = {}) {
17223
17554
  selectedStashIndex: 0,
17224
17555
  selectedWorktreeListIndex: 0,
17225
17556
  selectedConflictFileIndex: 0,
17557
+ selectedReflogIndex: 0,
17226
17558
  branchSort: DEFAULT_BRANCH_SORT_MODE,
17227
17559
  tagSort: DEFAULT_TAG_SORT_MODE,
17228
17560
  paletteFilter: '',
@@ -17470,6 +17802,12 @@ function applyLogInkAction(state, action) {
17470
17802
  selectedStashIndex: clampIndex$1(state.selectedStashIndex + action.delta, action.count),
17471
17803
  pendingKey: undefined,
17472
17804
  };
17805
+ case 'moveReflog':
17806
+ return {
17807
+ ...state,
17808
+ selectedReflogIndex: clampIndex$1(state.selectedReflogIndex + action.delta, action.count),
17809
+ pendingKey: undefined,
17810
+ };
17473
17811
  case 'moveWorktreeListEntry':
17474
17812
  return {
17475
17813
  ...state,
@@ -17694,6 +18032,31 @@ function applyLogInkAction(state, action) {
17694
18032
  worktreeDiffOffset: 0,
17695
18033
  };
17696
18034
  }
18035
+ case 'navigateOpenDiffForCompare': {
18036
+ const next = withPushedView(state, 'diff');
18037
+ return {
18038
+ ...next,
18039
+ diffSource: 'compare',
18040
+ compareBase: action.base,
18041
+ compareHead: action.head,
18042
+ // Reset scroll offset so the compare patch always opens at
18043
+ // the top — same reasoning as the stash branch above.
18044
+ diffPreviewOffset: 0,
18045
+ worktreeDiffOffset: 0,
18046
+ };
18047
+ }
18048
+ case 'setCompareBase':
18049
+ return {
18050
+ ...state,
18051
+ compareBase: action.value,
18052
+ pendingKey: undefined,
18053
+ };
18054
+ case 'clearCompareBase':
18055
+ return {
18056
+ ...state,
18057
+ compareBase: undefined,
18058
+ pendingKey: undefined,
18059
+ };
17697
18060
  case 'navigateOpenComposeForFile': {
17698
18061
  const next = withPushedView(state, 'status');
17699
18062
  return {
@@ -18049,10 +18412,66 @@ function isStashActionTarget(state) {
18049
18412
  return (state.activeView === 'stash' && state.focus === 'commits') ||
18050
18413
  (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
18051
18414
  }
18415
+ /**
18416
+ * Reflog has no sidebar tab — only the dedicated promoted view (#781).
18417
+ * The condition stays as a single helper anyway so navigation handlers
18418
+ * can read it the same way they do for the other promoted views.
18419
+ */
18420
+ function isReflogActionTarget(state) {
18421
+ return state.activeView === 'reflog' && state.focus === 'commits';
18422
+ }
18052
18423
  function isWorktreeActionTarget(state) {
18053
18424
  return (state.activeView === 'worktrees' && state.focus === 'commits') ||
18054
18425
  (state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
18055
18426
  }
18427
+ /**
18428
+ * Compare-flow target views (#779). The `m` mark + Enter-as-compare
18429
+ * overrides only fire on rows that represent a single ref the user
18430
+ * could pass to `git diff <ref>..<ref>` — branches, tags, and history
18431
+ * commits. The reflog view is intentionally excluded because reflog
18432
+ * entries are *moves* of HEAD, not refs a user typically diffs against.
18433
+ */
18434
+ function isCompareFlowTarget(state) {
18435
+ if (state.focus !== 'commits')
18436
+ return false;
18437
+ return state.activeView === 'branches' ||
18438
+ state.activeView === 'tags' ||
18439
+ state.activeView === 'history';
18440
+ }
18441
+ /**
18442
+ * Resolve the cursored ref for the compare flow (#779). Pulls the
18443
+ * concrete ref + label off context for branches / tags, and reads the
18444
+ * commit row from state for history. Returns undefined when no usable
18445
+ * ref is under the cursor (e.g., the views are empty, or the focus is
18446
+ * on the synthetic "(+) new commit" row).
18447
+ */
18448
+ function getCursoredCompareRef(state, context) {
18449
+ if (state.activeView === 'branches' && context.branchSelectedShortName) {
18450
+ return {
18451
+ kind: 'branch',
18452
+ ref: context.branchSelectedShortName,
18453
+ label: context.branchSelectedShortName,
18454
+ };
18455
+ }
18456
+ if (state.activeView === 'tags' && context.tagSelectedName) {
18457
+ return {
18458
+ kind: 'tag',
18459
+ ref: context.tagSelectedName,
18460
+ label: context.tagSelectedName,
18461
+ };
18462
+ }
18463
+ if (state.activeView === 'history' && !state.pendingCommitFocused) {
18464
+ const commit = state.filteredCommits[state.selectedIndex];
18465
+ if (commit) {
18466
+ return {
18467
+ kind: 'commit',
18468
+ ref: commit.hash,
18469
+ label: `${commit.shortHash} ${commit.message}`.trim(),
18470
+ };
18471
+ }
18472
+ }
18473
+ return undefined;
18474
+ }
18056
18475
  /**
18057
18476
  * Item count for the active sidebar tab — used by the generic
18058
18477
  * sidebar-Enter handler to decide whether to defer to the per-entity
@@ -18152,6 +18571,20 @@ function getLogInkPaletteExecuteEvents(command, state) {
18152
18571
  return [action({ type: 'pushView', value: 'pull-request' })];
18153
18572
  case 'navigateConflicts':
18154
18573
  return [action({ type: 'pushView', value: 'conflicts' })];
18574
+ case 'navigateReflog':
18575
+ return [action({ type: 'pushView', value: 'reflog' })];
18576
+ case 'navigateBisect':
18577
+ return [action({ type: 'pushView', value: 'bisect' })];
18578
+ case 'markForCompare':
18579
+ // Palette context can't reach the cursored ref (filtered branch /
18580
+ // tag lists live in runtime state, not the reducer). Surface a
18581
+ // hint and let the user press `m` directly on the row. The
18582
+ // inline keypress handler further down in this file does the
18583
+ // actual work and has access to the necessary context.
18584
+ return [action({
18585
+ type: 'setStatus',
18586
+ value: 'open branches / tags / history and press m on the cursored ref',
18587
+ })];
18155
18588
  case 'navigateBack':
18156
18589
  return [action({ type: 'popView' })];
18157
18590
  case 'openSelected': {
@@ -18629,6 +19062,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18629
19062
  action({ type: 'setStatus', value: 'jumped to conflicts' }),
18630
19063
  ];
18631
19064
  }
19065
+ // `gr` chord: jump to the reflog browser (#781). Recovery view —
19066
+ // chronological list of reflog entries with Enter to drill into the
19067
+ // commit-diff for the entry's hash. Loaded lazily by the runtime.
19068
+ if (state.pendingKey === 'g' && inputValue === 'r') {
19069
+ return [
19070
+ action({ type: 'pushView', value: 'reflog' }),
19071
+ action({ type: 'setStatus', value: 'jumped to reflog' }),
19072
+ ];
19073
+ }
19074
+ // `gB` chord: jump to the bisect workflow view (#784). Capital B
19075
+ // disambiguates from `gb` (branches). Always navigates — even when
19076
+ // bisect is inactive — so the user can see the empty-state hint and
19077
+ // know how to start one. The view's surface tells them the next step.
19078
+ if (state.pendingKey === 'g' && inputValue === 'B') {
19079
+ return [
19080
+ action({ type: 'pushView', value: 'bisect' }),
19081
+ action({ type: 'setStatus', value: 'jumped to bisect' }),
19082
+ ];
19083
+ }
18632
19084
  // `gH` chord: apply the cursored hunk to the index (`git apply
18633
19085
  // --cached`). Sibling of bare `H` which targets the worktree.
18634
19086
  // Discoverable via the footer hint on diff views and the help
@@ -18668,6 +19120,29 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18668
19120
  action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
18669
19121
  ];
18670
19122
  }
19123
+ // #784 — bisect view action keys. Scoped to `state.activeView ===
19124
+ // 'bisect' && state.focus === 'commits'` so the single-letter keys
19125
+ // stay free everywhere else. `g` and `b` collide with the global
19126
+ // chord prefix and the `gb` continuation respectively — placed
19127
+ // BEFORE the bare-`g` chord trigger below so a `g` keystroke on
19128
+ // the bisect view marks good rather than entering chord mode. The
19129
+ // user's path back out of bisect is `<` / `esc`, never a chord;
19130
+ // the in-bisect view itself can't navigate elsewhere via `g`-prefix
19131
+ // chords until the user exits with `esc` first.
19132
+ if (state.activeView === 'bisect' && state.focus === 'commits') {
19133
+ if (inputValue === 'g' && state.pendingKey !== 'g') {
19134
+ return [{ type: 'runWorkflowAction', id: 'bisect-good' }];
19135
+ }
19136
+ if (inputValue === 'b' && state.pendingKey !== 'g') {
19137
+ return [{ type: 'runWorkflowAction', id: 'bisect-bad' }];
19138
+ }
19139
+ if (inputValue === 's') {
19140
+ return [{ type: 'runWorkflowAction', id: 'bisect-skip' }];
19141
+ }
19142
+ if (inputValue === 'x') {
19143
+ return [action({ type: 'setPendingConfirmation', value: 'bisect-reset' })];
19144
+ }
19145
+ }
18671
19146
  if (inputValue === 'g') {
18672
19147
  if (state.pendingKey === 'g') {
18673
19148
  return [
@@ -18935,6 +19410,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18935
19410
  if (isStashActionTarget(state) && context.stashCount) {
18936
19411
  return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
18937
19412
  }
19413
+ if (isReflogActionTarget(state) && context.reflogCount) {
19414
+ return [action({ type: 'moveReflog', delta: -1, count: context.reflogCount })];
19415
+ }
18938
19416
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
18939
19417
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
18940
19418
  }
@@ -19016,6 +19494,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19016
19494
  if (isStashActionTarget(state) && context.stashCount) {
19017
19495
  return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
19018
19496
  }
19497
+ if (isReflogActionTarget(state) && context.reflogCount) {
19498
+ return [action({ type: 'moveReflog', delta: 1, count: context.reflogCount })];
19499
+ }
19019
19500
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
19020
19501
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
19021
19502
  }
@@ -19087,6 +19568,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19087
19568
  action({ type: 'setStatus', value: 'staging worktree changes' }),
19088
19569
  ];
19089
19570
  }
19571
+ // Compare-flow Enter override (#779). When `compareBase` is set and
19572
+ // the user presses Enter on a branch / tag / history commit row, we
19573
+ // open the compare diff (base..head) instead of the row's normal
19574
+ // action (checkout / drill-in / diff). Scoped to compare-flow
19575
+ // targets so non-flow views keep their Enter intact. Runs BEFORE
19576
+ // the per-row Enter handlers below so the override wins, including
19577
+ // before the history-row drill-in.
19578
+ if (key.return && state.compareBase && isCompareFlowTarget(state)) {
19579
+ const head = getCursoredCompareRef(state, context);
19580
+ if (!head) {
19581
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
19582
+ }
19583
+ if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
19584
+ return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
19585
+ }
19586
+ return [
19587
+ action({
19588
+ type: 'navigateOpenDiffForCompare',
19589
+ base: state.compareBase,
19590
+ head,
19591
+ }),
19592
+ action({ type: 'setStatus', value: `Comparing ${state.compareBase.label} → ${head.label}` }),
19593
+ ];
19594
+ }
19090
19595
  if (key.return &&
19091
19596
  state.activeView === 'history' &&
19092
19597
  state.focus === 'commits' &&
@@ -19103,6 +19608,28 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19103
19608
  ];
19104
19609
  }
19105
19610
  }
19611
+ // Enter on a reflog row drills into the diff for that entry's hash
19612
+ // (#781). Reuses `navigateOpenDiffForCommit`, which finds the commit
19613
+ // by hash in `state.filteredCommits` first and falls back to
19614
+ // `commitIndex` only when the hash isn't present. Reflog hashes that
19615
+ // exist in the loaded history (the common case) drill in cleanly;
19616
+ // dangling-commit hashes fall back to the index. The `commitIndex`
19617
+ // we pass is best-effort — index in `state.commits` if found, else
19618
+ // `state.selectedIndex` so the cursor stays sane on the diff view.
19619
+ if (key.return &&
19620
+ isReflogActionTarget(state) &&
19621
+ context.reflogSelectedHash) {
19622
+ const sha = context.reflogSelectedHash;
19623
+ const fallbackIndex = state.commits.findIndex((commit) => commit.hash === sha);
19624
+ return [
19625
+ action({
19626
+ type: 'navigateOpenDiffForCommit',
19627
+ sha,
19628
+ commitIndex: fallbackIndex >= 0 ? fallbackIndex : state.selectedIndex,
19629
+ }),
19630
+ action({ type: 'setStatus', value: `viewing diff for ${sha.slice(0, 7)}` }),
19631
+ ];
19632
+ }
19106
19633
  // Inspector Actions tab: Enter on the cursored action fires its
19107
19634
  // associated event (cherry-pick / revert / yank / etc.). Wins over
19108
19635
  // the file-list Enter below when the user has [/]-toggled to the
@@ -19282,6 +19809,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
19282
19809
  if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
19283
19810
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
19284
19811
  }
19812
+ // `m` marks (or un-marks) the cursored ref as the compare base
19813
+ // (#779). Scoped to compare-flow targets so it doesn't collide with
19814
+ // the `m` PR-merge handler further down. The toggle behavior — `m`
19815
+ // again on the same ref clears the base — gives the user a way to
19816
+ // bail out without remembering a separate cancel key.
19817
+ if (inputValue === 'm' && isCompareFlowTarget(state)) {
19818
+ const ref = getCursoredCompareRef(state, context);
19819
+ if (!ref) {
19820
+ return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
19821
+ }
19822
+ if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
19823
+ return [
19824
+ action({ type: 'clearCompareBase' }),
19825
+ action({ type: 'setStatus', value: `Cleared compare base ${ref.label}` }),
19826
+ ];
19827
+ }
19828
+ return [
19829
+ action({ type: 'setCompareBase', value: ref }),
19830
+ action({ type: 'setStatus', value: `Compare base: ${ref.label} — press enter on another ref to diff` }),
19831
+ ];
19832
+ }
19285
19833
  // Per-view worktree action: `D` removes the worktree AND deletes
19286
19834
  // the branch it was tracking (#838). Scoped to the worktrees
19287
19835
  // surface so it intercepts BEFORE the global workflow-by-key
@@ -20660,6 +21208,12 @@ function formatLogInkStatusEmpty({ hasChanges }) {
20660
21208
  }
20661
21209
  return 'Worktree clean. Press gh for history, gb for branches, gz for stash.';
20662
21210
  }
21211
+ function formatLogInkReflogEmpty({ filter }) {
21212
+ if (filter.trim()) {
21213
+ return `No reflog entries match filter '${filter}'. Press ctrl+u to clear.`;
21214
+ }
21215
+ return 'No reflog entries. Activity in this repo will appear here over time.';
21216
+ }
20663
21217
  function formatLogInkComposeEmpty({ hasStaged }) {
20664
21218
  if (hasStaged) {
20665
21219
  return undefined;
@@ -22289,14 +22843,14 @@ function deleteRemoteTag(git, tagName) {
22289
22843
  return runAction$2(() => git.raw(['push', 'origin', `:${tagName}`]), `Deleted remote tag ${tagName}`);
22290
22844
  }
22291
22845
 
22292
- const FIELD_SEPARATOR = '\x1f';
22846
+ const FIELD_SEPARATOR$1 = '\x1f';
22293
22847
  function parseTagRefs(output) {
22294
22848
  return output
22295
22849
  .split('\n')
22296
22850
  .map((line) => line.trimEnd())
22297
22851
  .filter(Boolean)
22298
22852
  .map((line) => {
22299
- const [name, hash, date, subject] = line.split(FIELD_SEPARATOR);
22853
+ const [name, hash, date, subject] = line.split(FIELD_SEPARATOR$1);
22300
22854
  return {
22301
22855
  name,
22302
22856
  hash,
@@ -22308,7 +22862,7 @@ function parseTagRefs(output) {
22308
22862
  async function getTagOverview(git) {
22309
22863
  const output = await git.raw([
22310
22864
  'for-each-ref',
22311
- `--format=%(refname:short)${FIELD_SEPARATOR}%(objectname:short)${FIELD_SEPARATOR}%(creatordate:short)${FIELD_SEPARATOR}%(subject)`,
22865
+ `--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
22312
22866
  '--sort=-creatordate',
22313
22867
  'refs/tags',
22314
22868
  ]);
@@ -24567,6 +25121,231 @@ function formatPullRequestStateLine(pr) {
24567
25121
  return parts.join(' · ');
24568
25122
  }
24569
25123
 
25124
+ const EMPTY_STATUS = {
25125
+ active: false,
25126
+ currentSha: '',
25127
+ log: [],
25128
+ };
25129
+ async function bisectIsActive(git) {
25130
+ try {
25131
+ const path = (await git.revparse(['--git-path', 'BISECT_LOG'])).trim();
25132
+ return path.length > 0 && fs.existsSync(path);
25133
+ }
25134
+ catch {
25135
+ return false;
25136
+ }
25137
+ }
25138
+ /**
25139
+ * Parse the output of `git bisect log` into structured entries. Each
25140
+ * entry corresponds to one user decision (start / good / bad / skip)
25141
+ * or the "# bad: [<sha>] <subject>" comment lines git emits for
25142
+ * traceability. Comment lines without a recognized prefix are dropped
25143
+ * — they're informational headers ("# status: ..."), not actions
25144
+ * the user took.
25145
+ */
25146
+ function parseBisectLog(output) {
25147
+ const entries = [];
25148
+ for (const rawLine of output.split('\n')) {
25149
+ const line = rawLine.trimEnd();
25150
+ if (!line)
25151
+ continue;
25152
+ // Comment rows: "# good: [sha] subject" / "# bad: [sha] subject" /
25153
+ // "# first bad commit: ..." / "# status: ...". The first two carry
25154
+ // the most user-relevant info (which commits were marked) so we
25155
+ // promote them to typed entries; the rest fall through as raw
25156
+ // lines tagged 'unknown' so the renderer can dim them or hide
25157
+ // entirely.
25158
+ if (line.startsWith('#')) {
25159
+ const commentMatch = line.match(/^#\s+(good|bad|skip):\s+\[([^\]]+)\]\s*(.*)$/);
25160
+ if (commentMatch) {
25161
+ entries.push({
25162
+ kind: commentMatch[1],
25163
+ sha: commentMatch[2],
25164
+ subject: commentMatch[3] || undefined,
25165
+ raw: line,
25166
+ });
25167
+ continue;
25168
+ }
25169
+ entries.push({ kind: 'unknown', raw: line });
25170
+ continue;
25171
+ }
25172
+ // Command rows: "git bisect start", "git bisect good <sha>",
25173
+ // "git bisect bad <sha>", "git bisect skip <sha>".
25174
+ const commandMatch = line.match(/^git\s+bisect\s+(start|good|bad|skip)\s*(.*)$/);
25175
+ if (commandMatch) {
25176
+ const sha = commandMatch[2]?.trim().split(/\s+/)[0] || undefined;
25177
+ entries.push({
25178
+ kind: commandMatch[1],
25179
+ sha: sha || undefined,
25180
+ raw: line,
25181
+ });
25182
+ continue;
25183
+ }
25184
+ entries.push({ kind: 'unknown', raw: line });
25185
+ }
25186
+ return entries;
25187
+ }
25188
+ /**
25189
+ * Load the live bisect status. Best-effort — when bisect isn't
25190
+ * active the empty-status sentinel returns immediately so callers
25191
+ * don't pay for a `git bisect log` round-trip on every refresh.
25192
+ */
25193
+ async function getBisectStatus(git) {
25194
+ if (!(await bisectIsActive(git))) {
25195
+ return EMPTY_STATUS;
25196
+ }
25197
+ let log = [];
25198
+ try {
25199
+ const output = await git.raw(['bisect', 'log']);
25200
+ log = parseBisectLog(output);
25201
+ }
25202
+ catch {
25203
+ // bisect log can fail on a freshly-started bisect with no decisions.
25204
+ // Treat the absence of a parseable log as "active but empty" rather
25205
+ // than non-active, so the surface still routes to the bisect view
25206
+ // and the user can see the badge.
25207
+ log = [];
25208
+ }
25209
+ let currentSha = '';
25210
+ try {
25211
+ currentSha = (await git.revparse(['HEAD'])).trim();
25212
+ }
25213
+ catch {
25214
+ currentSha = '';
25215
+ }
25216
+ return { active: true, currentSha, log };
25217
+ }
25218
+
25219
+ /**
25220
+ * Thin wrappers around `git bisect <verb>` for the TUI's in-bisect
25221
+ * action keys (#784). Each returns the raw stdout so the surface can
25222
+ * surface git's own "Bisecting: N revisions left to test after this
25223
+ * (roughly K steps)" hint as a status message — that wording is the
25224
+ * single most useful piece of feedback git emits during bisect, and
25225
+ * mirroring it keeps the TUI's status line authoritative.
25226
+ *
25227
+ * No try/catch here — git itself returns non-zero on user errors
25228
+ * (already-bisecting, no good ref, etc.) and `simple-git` surfaces
25229
+ * those as rejections. The runtime catches them and routes to the
25230
+ * status line.
25231
+ */
25232
+ async function bisectGood(git, ref) {
25233
+ const args = ['bisect', 'good'];
25234
+ return git.raw(args);
25235
+ }
25236
+ async function bisectBad(git, ref) {
25237
+ const args = ['bisect', 'bad'];
25238
+ return git.raw(args);
25239
+ }
25240
+ async function bisectSkip(git, ref) {
25241
+ const args = ['bisect', 'skip'];
25242
+ return git.raw(args);
25243
+ }
25244
+ async function bisectReset(git) {
25245
+ return git.raw(['bisect', 'reset']);
25246
+ }
25247
+ /**
25248
+ * Pull the user-facing remaining-revisions hint out of `git bisect`
25249
+ * stdout. Looks for the canonical line:
25250
+ *
25251
+ * `Bisecting: N revisions left to test after this (roughly K steps)`
25252
+ *
25253
+ * Returns undefined when the line isn't present (e.g. the run
25254
+ * finished and git emitted a "<sha> is the first bad commit" line
25255
+ * instead). Callers fall back to an empty status update in that case.
25256
+ */
25257
+ function extractBisectRemainingHint(stdout) {
25258
+ for (const line of stdout.split('\n').reverse()) {
25259
+ const trimmed = line.trim();
25260
+ if (trimmed.startsWith('Bisecting:'))
25261
+ return trimmed;
25262
+ if (trimmed.includes('is the first bad commit'))
25263
+ return trimmed;
25264
+ }
25265
+ return undefined;
25266
+ }
25267
+
25268
+ /**
25269
+ * Compare two refs (branches / tags / commits) and return the unified
25270
+ * patch as line-split string output (#779).
25271
+ *
25272
+ * Mirrors the stash-diff loader's contract — emits `string[]` so the
25273
+ * existing diff surface can render the lines through its standard
25274
+ * +/-/@@ coloring path. Two-dot syntax (`base..head`) gives the
25275
+ * "what changed on head, relative to base" view that's natural for
25276
+ * branch reviews and pre-merge sanity checks.
25277
+ *
25278
+ * Defensive about input — both refs are passed as-is to git, so the
25279
+ * caller is responsible for providing a git-resolvable form
25280
+ * (branch shortName, tag name, or commit hash). On any git error
25281
+ * (unknown ref, etc.) the runtime's `safe()` wrapper at the call
25282
+ * site catches the throw and the surface falls back to a "no diff"
25283
+ * hint.
25284
+ */
25285
+ async function getCompareDiff(git, base, head) {
25286
+ return (await git.raw(['diff', `${base}..${head}`]))
25287
+ .split('\n')
25288
+ .map((line) => line.replace(/\r$/, ''));
25289
+ }
25290
+
25291
+ const FIELD_SEPARATOR = '\x1f';
25292
+ /**
25293
+ * Default fetch limit. 200 entries is enough to span weeks of normal
25294
+ * activity for an active repo while keeping the load fast — `git reflog`
25295
+ * is local-only so even 1000+ entries is sub-second, but 200 keeps the
25296
+ * rendered list bounded for terminals.
25297
+ */
25298
+ const DEFAULT_REFLOG_LIMIT = 200;
25299
+ function parseReflogOverview(output) {
25300
+ return output
25301
+ .split('\n')
25302
+ .map((line) => line.trimEnd())
25303
+ .filter(Boolean)
25304
+ .map((line) => {
25305
+ const [selector, hash, relativeDate, subject] = line.split(FIELD_SEPARATOR);
25306
+ return {
25307
+ selector: selector || '',
25308
+ hash: hash || '',
25309
+ relativeDate: relativeDate || '',
25310
+ subject: subject || '',
25311
+ };
25312
+ });
25313
+ }
25314
+ async function getReflogOverview(git, limit = DEFAULT_REFLOG_LIMIT) {
25315
+ const output = await git.raw([
25316
+ 'reflog',
25317
+ `--max-count=${limit}`,
25318
+ `--pretty=format:%gd${FIELD_SEPARATOR}%h${FIELD_SEPARATOR}%cr${FIELD_SEPARATOR}%gs`,
25319
+ ]);
25320
+ return {
25321
+ entries: parseReflogOverview(output),
25322
+ };
25323
+ }
25324
+ /**
25325
+ * Pull the action prefix off a reflog subject. Reflog subjects follow
25326
+ * a `<verb>[ qualifier]: <message>` pattern emitted by git itself —
25327
+ * "commit: ...", "commit (amend): ...", "checkout: moving from main
25328
+ * to feature", "merge feature: ...", "reset: moving to HEAD~1", etc.
25329
+ *
25330
+ * For display we want the verb (and any parenthetical qualifier) on
25331
+ * its own so the view can render a fixed-width `action` column and
25332
+ * keep the rest of the message left-aligned.
25333
+ *
25334
+ * Defensive: if the subject has no colon, the whole string is treated
25335
+ * as the action and the message is empty. This keeps the renderer
25336
+ * from crashing on a malformed entry.
25337
+ */
25338
+ function splitReflogSubject(subject) {
25339
+ const colonIndex = subject.indexOf(':');
25340
+ if (colonIndex === -1) {
25341
+ return { action: subject.trim(), message: '' };
25342
+ }
25343
+ return {
25344
+ action: subject.slice(0, colonIndex).trim(),
25345
+ message: subject.slice(colonIndex + 1).trim(),
25346
+ };
25347
+ }
25348
+
24570
25349
  function sectionLines(title, diff) {
24571
25350
  const lines = diff.split('\n').map((line) => line.trimEnd());
24572
25351
  return [
@@ -24679,7 +25458,7 @@ async function safe(promise) {
24679
25458
  }
24680
25459
  }
24681
25460
  async function loadLogInkContext(git) {
24682
- const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider] = await Promise.all([
25461
+ const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider, reflog, bisect] = await Promise.all([
24683
25462
  safe(getBranchOverview(git)),
24684
25463
  safe(getPullRequestOverview(git)),
24685
25464
  safe(getTagOverview(git)),
@@ -24688,12 +25467,16 @@ async function loadLogInkContext(git) {
24688
25467
  safe(getWorktreeListOverview(git)),
24689
25468
  safe(getGitOperationOverview(git)),
24690
25469
  safe(getProviderOverview(git)),
25470
+ safe(getReflogOverview(git)),
25471
+ safe(getBisectStatus(git)),
24691
25472
  ]);
24692
25473
  return {
25474
+ bisect,
24693
25475
  branches,
24694
25476
  operation,
24695
25477
  provider,
24696
25478
  pullRequest,
25479
+ reflog,
24697
25480
  stashes,
24698
25481
  tags,
24699
25482
  worktree,
@@ -24719,6 +25502,14 @@ function loadLogInkContextEntries(git) {
24719
25502
  key: 'tags',
24720
25503
  load: () => safe(getTagOverview(git)),
24721
25504
  },
25505
+ {
25506
+ key: 'reflog',
25507
+ load: () => safe(getReflogOverview(git)),
25508
+ },
25509
+ {
25510
+ key: 'bisect',
25511
+ load: () => safe(getBisectStatus(git)),
25512
+ },
24722
25513
  {
24723
25514
  key: 'worktree',
24724
25515
  load: () => safe(getWorktreeOverview(git)),
@@ -25107,6 +25898,10 @@ function LogInkApp(deps) {
25107
25898
  // colors match the commit-diff path.
25108
25899
  const [stashDiffLines, setStashDiffLines] = React.useState(undefined);
25109
25900
  const [stashDiffLoading, setStashDiffLoading] = React.useState(false);
25901
+ // #779 — compare-two-refs diff state. Loaded lazily when the diff
25902
+ // view becomes active with `diffSource === 'compare'`.
25903
+ const [compareDiffLines, setCompareDiffLines] = React.useState(undefined);
25904
+ const [compareDiffLoading, setCompareDiffLoading] = React.useState(false);
25110
25905
  const [hasMoreCommits, setHasMoreCommits] = React.useState(() => (Boolean(logArgv?.interactive && !logArgv.limit) &&
25111
25906
  getCommitRows(rows).length >= LOG_INTERACTIVE_DEFAULT_LIMIT));
25112
25907
  const [loadingMoreCommits, setLoadingMoreCommits] = React.useState(false);
@@ -25201,6 +25996,12 @@ function LogInkApp(deps) {
25201
25996
  return all;
25202
25997
  return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
25203
25998
  }, [context.worktreeList?.worktrees, state.filter]);
25999
+ const filteredReflogList = React.useMemo(() => {
26000
+ const all = context.reflog?.entries || [];
26001
+ if (!state.filter)
26002
+ return all;
26003
+ return all.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter));
26004
+ }, [context.reflog?.entries, state.filter]);
25204
26005
  const dispatch = React.useCallback((action) => {
25205
26006
  setState((current) => applyLogInkAction(current, action));
25206
26007
  }, []);
@@ -25411,6 +26212,41 @@ function LogInkApp(deps) {
25411
26212
  })();
25412
26213
  return () => { active = false; };
25413
26214
  }, [git, state.activeView, state.diffSource, state.stashDiffRef]);
26215
+ // #779 — load `git diff <base>..<head>` once the diff view becomes
26216
+ // active with diffSource='compare'. Mirrors the stash loader's
26217
+ // shape; the surface renders the lines via the same +/-/@@ coloring
26218
+ // path. On unknown ref / git error, `safe()` swallows and the
26219
+ // surface falls back to a "no diff" hint.
26220
+ const compareBaseRef = state.compareBase?.ref;
26221
+ const compareHeadRef = state.compareHead?.ref;
26222
+ React.useEffect(() => {
26223
+ if (state.activeView !== 'diff' ||
26224
+ state.diffSource !== 'compare' ||
26225
+ !compareBaseRef ||
26226
+ !compareHeadRef) {
26227
+ return;
26228
+ }
26229
+ let active = true;
26230
+ setCompareDiffLoading(true);
26231
+ void (async () => {
26232
+ const lines = await safe(getCompareDiff(git, compareBaseRef, compareHeadRef));
26233
+ if (active) {
26234
+ setCompareDiffLines(lines || []);
26235
+ setCompareDiffLoading(false);
26236
+ }
26237
+ })();
26238
+ return () => { active = false; };
26239
+ }, [git, state.activeView, state.diffSource, compareBaseRef, compareHeadRef]);
26240
+ // Reset compare-diff state whenever the diff view exits. Without
26241
+ // this, opening a new compare immediately after closing one would
26242
+ // briefly show the previous comparison's lines while the new
26243
+ // loader runs.
26244
+ React.useEffect(() => {
26245
+ if (state.diffSource !== 'compare') {
26246
+ setCompareDiffLines(undefined);
26247
+ setCompareDiffLoading(false);
26248
+ }
26249
+ }, [state.diffSource]);
25414
26250
  React.useEffect(() => {
25415
26251
  let active = true;
25416
26252
  async function loadWorktreeHunks() {
@@ -25921,6 +26757,50 @@ function LogInkApp(deps) {
25921
26757
  return { ok: false, message: 'No stash selected' };
25922
26758
  return popStash(git, stash);
25923
26759
  },
26760
+ 'bisect-good': async () => {
26761
+ if (!context.bisect?.active)
26762
+ return { ok: false, message: 'No bisect in progress' };
26763
+ try {
26764
+ const stdout = await bisectGood(git);
26765
+ return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked good' };
26766
+ }
26767
+ catch (error) {
26768
+ return { ok: false, message: `Bisect good failed: ${error.message}` };
26769
+ }
26770
+ },
26771
+ 'bisect-bad': async () => {
26772
+ if (!context.bisect?.active)
26773
+ return { ok: false, message: 'No bisect in progress' };
26774
+ try {
26775
+ const stdout = await bisectBad(git);
26776
+ return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked bad' };
26777
+ }
26778
+ catch (error) {
26779
+ return { ok: false, message: `Bisect bad failed: ${error.message}` };
26780
+ }
26781
+ },
26782
+ 'bisect-skip': async () => {
26783
+ if (!context.bisect?.active)
26784
+ return { ok: false, message: 'No bisect in progress' };
26785
+ try {
26786
+ const stdout = await bisectSkip(git);
26787
+ return { ok: true, message: extractBisectRemainingHint(stdout) || 'Skipped' };
26788
+ }
26789
+ catch (error) {
26790
+ return { ok: false, message: `Bisect skip failed: ${error.message}` };
26791
+ }
26792
+ },
26793
+ 'bisect-reset': async () => {
26794
+ if (!context.bisect?.active)
26795
+ return { ok: false, message: 'No bisect in progress' };
26796
+ try {
26797
+ await bisectReset(git);
26798
+ return { ok: true, message: 'Bisect reset' };
26799
+ }
26800
+ catch (error) {
26801
+ return { ok: false, message: `Bisect reset failed: ${error.message}` };
26802
+ }
26803
+ },
25924
26804
  'checkout-file-from-stash': async () => {
25925
26805
  const path = payload?.trim();
25926
26806
  const ref = state.stashDiffRef;
@@ -26593,9 +27473,13 @@ function LogInkApp(deps) {
26593
27473
  // perf pass) — reading them here is O(1) instead of O(branches +
26594
27474
  // tags + stashes + worktrees) per keystroke.
26595
27475
  const branchVisibleCount = filteredBranchList.length;
27476
+ const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
26596
27477
  const tagVisibleCount = filteredTagList.length;
27478
+ const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
26597
27479
  const stashVisibleCount = filteredStashList.length;
26598
27480
  const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
27481
+ const reflogVisibleCount = filteredReflogList.length;
27482
+ const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
26599
27483
  const worktreeVisibleCount = filteredWorktreeList.length;
26600
27484
  // When the diff view is showing a stash patch, swap the previewLineCount
26601
27485
  // to the stash diff length so the existing pageDetailPreview path
@@ -26620,8 +27504,12 @@ function LogInkApp(deps) {
26620
27504
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
26621
27505
  commitDiffHunkOffsets,
26622
27506
  branchCount: branchVisibleCount,
27507
+ branchSelectedShortName,
26623
27508
  tagCount: tagVisibleCount,
27509
+ tagSelectedName,
26624
27510
  stashCount: stashVisibleCount,
27511
+ reflogCount: reflogVisibleCount,
27512
+ reflogSelectedHash,
26625
27513
  stashSelectedRef,
26626
27514
  stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
26627
27515
  stashDiffSelectedPath,
@@ -26727,12 +27615,15 @@ function LogInkApp(deps) {
26727
27615
  if (showOnboarding) {
26728
27616
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
26729
27617
  }
26730
- 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));
27618
+ return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
26731
27619
  }
26732
27620
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
26733
27621
  const { Box, Text } = components;
26734
27622
  const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
26735
- const dirty = context.branches?.dirty ? 'dirty' : 'clean';
27623
+ // #784 surface bisect-in-progress in the title bar so users entering
27624
+ // the TUI mid-bisect see it immediately, before they navigate to gB.
27625
+ const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
27626
+ const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
26736
27627
  const repo = context.provider?.repository.owner && context.provider.repository.name
26737
27628
  ? `${context.provider.repository.owner}/${context.provider.repository.name}`
26738
27629
  : 'local repository';
@@ -26987,12 +27878,12 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
26987
27878
  : []),
26988
27879
  ];
26989
27880
  }
26990
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
27881
+ function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
26991
27882
  if (state.activeView === 'status') {
26992
27883
  return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26993
27884
  }
26994
27885
  if (state.activeView === 'diff') {
26995
- return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme);
27886
+ return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme);
26996
27887
  }
26997
27888
  if (state.activeView === 'compose') {
26998
27889
  return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
@@ -27003,6 +27894,12 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
27003
27894
  if (state.activeView === 'tags') {
27004
27895
  return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27005
27896
  }
27897
+ if (state.activeView === 'reflog') {
27898
+ return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27899
+ }
27900
+ if (state.activeView === 'bisect') {
27901
+ return renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27902
+ }
27006
27903
  if (state.activeView === 'stash') {
27007
27904
  return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
27008
27905
  }
@@ -27630,6 +28527,150 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
27630
28527
  width,
27631
28528
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
27632
28529
  }
28530
+ /**
28531
+ * Promoted reflog browser (#781). Mirrors `renderTagsSurface` visually
28532
+ * — same header / filter affordance / footer hint conventions — but
28533
+ * lays out four columns per row: relative date, action prefix, short
28534
+ * hash, and message. Filtering matches against all four (so typing
28535
+ * "checkout" narrows to checkout entries, "abc" narrows to a hash).
28536
+ *
28537
+ * Per-row layout uses fixed column widths derived from the visible
28538
+ * window so short-action rows don't leave a wide gutter and long
28539
+ * actions don't push the message off-screen. The cap mirrors the
28540
+ * tags surface's name-column treatment.
28541
+ */
28542
+ function renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
28543
+ const { Box, Text } = components;
28544
+ const focused = state.focus === 'commits';
28545
+ const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
28546
+ const allEntries = context.reflog?.entries || [];
28547
+ const entries = state.filter
28548
+ ? allEntries.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter))
28549
+ : allEntries;
28550
+ const selected = Math.max(0, Math.min(state.selectedReflogIndex, Math.max(0, entries.length - 1)));
28551
+ const listRows = Math.max(4, bodyRows - 4);
28552
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
28553
+ const visible = entries.slice(startIndex, startIndex + listRows);
28554
+ const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
28555
+ const headerRight = loading
28556
+ ? 'loading reflog'
28557
+ : `${entries.length}/${allEntries.length} entries${filterLabel}`;
28558
+ const emptyLabel = formatLogInkReflogEmpty({ filter: state.filter });
28559
+ const loadingLabel = formatLogInkLoading({ resource: 'reflog' });
28560
+ // Column widths derived from the visible window. The hash column is
28561
+ // fixed (short SHA is always 7 chars) and the date column caps so
28562
+ // "X minutes ago" / "Y hours ago" stays readable without dominating
28563
+ // the row. Action column scales to the longest visible action so
28564
+ // commit / checkout / merge align cleanly.
28565
+ const splitVisible = visible.map((entry) => ({
28566
+ entry,
28567
+ parts: splitReflogSubject(entry.subject),
28568
+ }));
28569
+ const dateColWidth = splitVisible.length === 0
28570
+ ? 16
28571
+ : Math.min(20, Math.max(6, ...splitVisible.map(({ entry }) => entry.relativeDate.length)));
28572
+ const actionColWidth = splitVisible.length === 0
28573
+ ? 12
28574
+ : Math.min(24, Math.max(6, ...splitVisible.map(({ parts }) => parts.action.length)));
28575
+ const hashColWidth = 8;
28576
+ const lines = loading
28577
+ ? [h(Text, { key: 'reflog-loading', dimColor: true }, loadingLabel)]
28578
+ : entries.length === 0
28579
+ ? [h(Text, { key: 'reflog-empty', dimColor: true }, emptyLabel)]
28580
+ : splitVisible.map(({ entry, parts }, offset) => {
28581
+ const index = startIndex + offset;
28582
+ const isSelected = index === selected;
28583
+ const cursor = isSelected ? '>' : ' ';
28584
+ const datePadded = truncate$1(entry.relativeDate, dateColWidth).padEnd(dateColWidth);
28585
+ const actionPadded = truncate$1(parts.action, actionColWidth).padEnd(actionColWidth);
28586
+ const hashPadded = truncate$1(entry.hash, hashColWidth).padEnd(hashColWidth);
28587
+ const message = parts.message || entry.subject;
28588
+ const lineText = truncate$1(`${cursor} ${datePadded} ${actionPadded} ${hashPadded} ${message}`, Math.max(20, width - 4));
28589
+ return h(Text, {
28590
+ key: `reflog-${index}`,
28591
+ bold: isSelected,
28592
+ dimColor: !isSelected,
28593
+ }, lineText);
28594
+ });
28595
+ return h(Box, {
28596
+ borderColor: focusBorderColor(theme, focused),
28597
+ borderStyle: theme.borderStyle,
28598
+ flexDirection: 'column',
28599
+ flexShrink: 0,
28600
+ paddingX: 1,
28601
+ width,
28602
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Reflog', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
28603
+ }
28604
+ /**
28605
+ * Bisect workflow surface (#784). Shows the current candidate commit
28606
+ * (HEAD), a parsed view of recent decisions from `git bisect log`, and
28607
+ * the four action keys (g good, b bad, s skip, x reset).
28608
+ *
28609
+ * When bisect is inactive, the surface renders an empty-state hint
28610
+ * pointing the user at the CLI to start one. The view stays
28611
+ * navigable so the user can read the documentation before starting
28612
+ * — they can't break anything from here.
28613
+ */
28614
+ function renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
28615
+ const { Box, Text } = components;
28616
+ const focused = state.focus === 'commits';
28617
+ const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
28618
+ const bisect = context.bisect;
28619
+ const accent = theme.noColor ? undefined : theme.colors.accent;
28620
+ const lines = [];
28621
+ if (loading) {
28622
+ lines.push(h(Text, { key: 'bisect-loading', dimColor: true }, truncate$1('· Loading bisect status…', width - 4)));
28623
+ }
28624
+ else if (!bisect?.active) {
28625
+ // No bisect active. Surface the CLI on-ramp — starting from the
28626
+ // TUI is intentionally out of scope for this PR (#784 follow-up).
28627
+ // The user is expected to enter via `git bisect start <bad> <good>`
28628
+ // and re-open `coco ui`; once bisect is active this view drives
28629
+ // the rest.
28630
+ lines.push(h(Text, { key: 'bisect-empty-1', bold: true }, truncate$1('No bisect in progress.', width - 4)));
28631
+ lines.push(h(Text, { key: 'bisect-empty-2' }, ''));
28632
+ lines.push(h(Text, { key: 'bisect-empty-3' }, truncate$1('Start one from the shell with:', width - 4)));
28633
+ lines.push(h(Text, { key: 'bisect-empty-4', color: accent }, truncate$1(' git bisect start <bad-ref> <good-ref>', width - 4)));
28634
+ lines.push(h(Text, { key: 'bisect-empty-5' }, ''));
28635
+ lines.push(h(Text, { key: 'bisect-empty-6', dimColor: true }, truncate$1('coco will pick up the active bisect on the next refresh — actions will become available here.', width - 4)));
28636
+ }
28637
+ else {
28638
+ // Active bisect. Two-section body: current candidate, recent
28639
+ // decisions. Action keys live in the footer.
28640
+ const headerSha = bisect.currentSha ? bisect.currentSha.slice(0, 8) : '<unknown>';
28641
+ lines.push(h(Text, { key: 'bisect-active-title', bold: true }, truncate$1(`Bisecting · current candidate ${headerSha}`, width - 4)));
28642
+ lines.push(h(Text, { key: 'bisect-active-spacer' }, ''));
28643
+ const decisions = bisect.log.filter((entry) => entry.kind === 'good' || entry.kind === 'bad' || entry.kind === 'skip');
28644
+ if (decisions.length === 0) {
28645
+ 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)));
28646
+ }
28647
+ else {
28648
+ lines.push(h(Text, { key: 'bisect-decisions-header', bold: true }, truncate$1(`Decisions (${decisions.length}):`, width - 4)));
28649
+ const recent = decisions.slice(-Math.max(4, bodyRows - 8));
28650
+ for (const entry of recent) {
28651
+ const kindLabel = entry.kind.toUpperCase().padEnd(5);
28652
+ const sha = (entry.sha || '<unknown>').padEnd(8);
28653
+ const subject = entry.subject || '';
28654
+ const text = ` ${kindLabel} ${sha} ${subject}`;
28655
+ lines.push(h(Text, {
28656
+ key: `bisect-entry-${entry.raw}`,
28657
+ dimColor: entry.kind === 'skip',
28658
+ bold: entry.kind === 'bad',
28659
+ }, truncate$1(text, width - 4)));
28660
+ }
28661
+ }
28662
+ lines.push(h(Text, { key: 'bisect-action-spacer' }, ''));
28663
+ lines.push(h(Text, { key: 'bisect-action-hint', dimColor: true }, truncate$1('Actions: g good · b bad · s skip · x reset', width - 4)));
28664
+ }
28665
+ return h(Box, {
28666
+ borderColor: focusBorderColor(theme, focused),
28667
+ borderStyle: theme.borderStyle,
28668
+ flexDirection: 'column',
28669
+ flexShrink: 0,
28670
+ paddingX: 1,
28671
+ width,
28672
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Bisect', focused)), h(Text, { dimColor: true }, bisect?.active ? 'BISECTING' : 'inactive')), ...lines);
28673
+ }
27633
28674
  function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
27634
28675
  const { Box, Text } = components;
27635
28676
  const focused = state.focus === 'commits';
@@ -27835,7 +28876,7 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
27835
28876
  h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
27836
28877
  ];
27837
28878
  }
27838
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme) {
28879
+ function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme) {
27839
28880
  const { Box, Text } = components;
27840
28881
  const focused = state.focus === 'commits';
27841
28882
  const worktree = context.worktree;
@@ -27926,6 +28967,50 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
27926
28967
  dimColor: index > 0,
27927
28968
  }, truncate$1(line, width - 4))), ...stashBodyNodes);
27928
28969
  }
28970
+ // Compare-two-refs branch (#779). Mirrors the stash diff above but
28971
+ // sourced from `git diff <base>..<head>`. No per-file cherry-pick or
28972
+ // hunk apply — comparing arbitrary refs doesn't have a sensible
28973
+ // mutate-from-here flow, so the surface is read-only navigation.
28974
+ if (state.diffSource === 'compare') {
28975
+ const lines = compareDiffLines || [];
28976
+ const splitActive = isSplitDiffViable(state, width);
28977
+ const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
28978
+ const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
28979
+ const baseLabel = state.compareBase?.label || state.compareBase?.ref || '<base>';
28980
+ const headLabel = state.compareHead?.label || state.compareHead?.ref || '<head>';
28981
+ const compareTitle = `${baseLabel} → ${headLabel}`;
28982
+ const baseHeaderLines = compareDiffLoading
28983
+ ? [`Loading diff for ${compareTitle}...`]
28984
+ : lines.length && (lines.length > 1 || lines[0])
28985
+ ? [
28986
+ compareTitle,
28987
+ `Lines ${Math.min(state.diffPreviewOffset + 1, lines.length)}-${Math.min(state.diffPreviewOffset + visibleLines.length, lines.length)}/${lines.length}`,
28988
+ '',
28989
+ ]
28990
+ : ['No diff to display — refs may resolve to the same tree.'];
28991
+ const headerLines = splitRequestedButTooNarrow
28992
+ ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
28993
+ : baseHeaderLines;
28994
+ const compareBodyNodes = compareDiffLoading || !lines.length || (lines.length === 1 && !lines[0])
28995
+ ? []
28996
+ : splitActive
28997
+ ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'compare-diff-split')
28998
+ : visibleLines.map((line, index) => h(Text, {
28999
+ key: `compare-diff-line-${state.diffPreviewOffset + index}`,
29000
+ ...diffLineProps(line, theme),
29001
+ }, truncate$1(line, width - 4)));
29002
+ return h(Box, {
29003
+ borderColor: focusBorderColor(theme, focused),
29004
+ borderStyle: theme.borderStyle,
29005
+ flexDirection: 'column',
29006
+ flexShrink: 0,
29007
+ paddingX: 1,
29008
+ width,
29009
+ }, 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, {
29010
+ key: `compare-diff-header-${index}`,
29011
+ dimColor: index > 0,
29012
+ }, truncate$1(line, width - 4))), ...compareBodyNodes);
29013
+ }
27929
29014
  // diffSource disambiguates: 'commit' was set when the user opened the
27930
29015
  // diff via history → Enter (read-only commit-diff explore), 'worktree'
27931
29016
  // was set when they came from status → Enter (stage / hunk / revert).
@@ -28784,6 +29869,7 @@ function renderFooter(h, components, state, context, theme, idleTip) {
28784
29869
  showHelp: state.showHelp,
28785
29870
  sidebarTab: state.sidebarTab,
28786
29871
  sidebarItemCount,
29872
+ compareBaseSet: Boolean(state.compareBase),
28787
29873
  });
28788
29874
  // Real status messages always win; idle tips only fill the slot when it
28789
29875
  // would otherwise be empty.