git-coco 0.37.0 → 0.39.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.
Files changed (3) hide show
  1. package/dist/index.esm.mjs +2848 -354
  2. package/dist/index.js +2847 -353
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -43,6 +43,7 @@ var path$1 = require('node:path');
43
43
  var crypto = require('node:crypto');
44
44
  var readline = require('readline');
45
45
  var util$1 = require('util');
46
+ var crypto$1 = require('crypto');
46
47
  var url = require('url');
47
48
 
48
49
  function _interopNamespaceDefault(e) {
@@ -77,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
77
78
  /**
78
79
  * Current build version from package.json
79
80
  */
80
- const BUILD_VERSION = "0.37.0";
81
+ const BUILD_VERSION = "0.39.0";
81
82
 
82
83
  const isInteractive = (config) => {
83
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -13539,8 +13540,11 @@ const builder$3 = (yargs) => {
13539
13540
  };
13540
13541
 
13541
13542
  const FIELD_SEPARATOR$2 = '\x1f';
13542
- const LOG_FORMAT = `%x1f%h%x1f%H%x1f%ad%x1f%an%x1f%d%x1f%s`;
13543
- const DETAIL_FORMAT = `%H%x1f%h%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
13543
+ // `%P` (parent hashes, space-separated) lets the TUI distinguish
13544
+ // merge commits (parents.length > 1) from regular commits without a
13545
+ // second round-trip to git. See #791 stage 3 — merge glyph + HEAD ring.
13546
+ const LOG_FORMAT = `%x1f%h%x1f%H%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s`;
13547
+ const DETAIL_FORMAT = `%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
13544
13548
  const LOG_DEFAULT_LIMIT = 30;
13545
13549
  const LOG_INTERACTIVE_DEFAULT_LIMIT = 300;
13546
13550
  function toArray(value) {
@@ -13594,12 +13598,13 @@ function parseLogOutput(output) {
13594
13598
  graph: line,
13595
13599
  };
13596
13600
  }
13597
- const [graph, shortHash, hash, date, author, refs, message] = line.split(FIELD_SEPARATOR$2);
13601
+ const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$2);
13598
13602
  return {
13599
13603
  type: 'commit',
13600
13604
  graph: graph.trimEnd(),
13601
13605
  shortHash,
13602
13606
  hash,
13607
+ parents: parentsStr ? parentsStr.trim().split(' ').filter(Boolean) : [],
13603
13608
  date,
13604
13609
  author,
13605
13610
  refs: cleanRefs(refs),
@@ -13666,13 +13671,14 @@ function parseNameStatus(output, numstat = []) {
13666
13671
  });
13667
13672
  }
13668
13673
  function parseCommitDetail(metadata, files, numstatOutput = '') {
13669
- const [hash, shortHash, date, author, refs, message, body = ''] = metadata
13674
+ const [hash, shortHash, parentsStr, date, author, refs, message, body = ''] = metadata
13670
13675
  .trimEnd()
13671
13676
  .split(FIELD_SEPARATOR$2);
13672
13677
  const numstat = parseNumstat(numstatOutput);
13673
13678
  return {
13674
13679
  shortHash,
13675
13680
  hash,
13681
+ parents: parentsStr ? parentsStr.trim().split(' ').filter(Boolean) : [],
13676
13682
  date,
13677
13683
  author,
13678
13684
  refs: cleanRefs(refs),
@@ -13726,6 +13732,24 @@ function buildLogArgs(argv, options = {}) {
13726
13732
  }
13727
13733
  return args;
13728
13734
  }
13735
+ /**
13736
+ * Build merged `LogArgv` for the interactive TUI's `g` graph toggle.
13737
+ *
13738
+ * The TUI tracks a transient `fullGraph` boolean; toggling it must produce
13739
+ * a fresh fetch with the right `view` so the renderer actually has graph
13740
+ * topology to draw. When switching to full mode we override `view` to
13741
+ * `'full'` (which `buildLogArgs` already maps to `--all`, dropping
13742
+ * `--first-parent`/`--no-merges`). When switching back we honor the user's
13743
+ * original `view` from argv, defaulting to `'compact'`.
13744
+ *
13745
+ * Pure helper so the effect that calls it stays trivially testable.
13746
+ */
13747
+ function buildToggleGraphArgs(argv, fullGraph) {
13748
+ if (fullGraph) {
13749
+ return { ...argv, view: 'full' };
13750
+ }
13751
+ return { ...argv, view: argv.view ?? 'compact' };
13752
+ }
13729
13753
  async function getLogRows(git, argv, options = {}) {
13730
13754
  return parseLogOutput(await git.raw(buildLogArgs(argv, options)));
13731
13755
  }
@@ -13967,7 +13991,7 @@ function splitCommitDraft(draft) {
13967
13991
  body,
13968
13992
  };
13969
13993
  }
13970
- function compactOutputLines$4(output) {
13994
+ function compactOutputLines$5(output) {
13971
13995
  return output
13972
13996
  .split('\n')
13973
13997
  .map((line) => line.trim())
@@ -13975,14 +13999,14 @@ function compactOutputLines$4(output) {
13975
13999
  }
13976
14000
  function formatManualCommitFailure(error) {
13977
14001
  if (error instanceof PreCommitHookError) {
13978
- const details = compactOutputLines$4(error.hookOutput);
14002
+ const details = compactOutputLines$5(error.hookOutput);
13979
14003
  return {
13980
14004
  ok: false,
13981
14005
  message: `Commit blocked by hook: ${details[0] || 'hook failed'}`,
13982
14006
  details: details.slice(1, 6),
13983
14007
  };
13984
14008
  }
13985
- const details = compactOutputLines$4(error.message);
14009
+ const details = compactOutputLines$5(error.message);
13986
14010
  return {
13987
14011
  ok: false,
13988
14012
  message: details[0] || 'Commit failed.',
@@ -14283,7 +14307,7 @@ function formatCommitWorkflowMessage(action, output) {
14283
14307
  }
14284
14308
  return 'Generated commit message.';
14285
14309
  }
14286
- function compactOutputLines$3(output) {
14310
+ function compactOutputLines$4(output) {
14287
14311
  return output
14288
14312
  .split('\n')
14289
14313
  .map((line) => line.trim())
@@ -14291,14 +14315,14 @@ function compactOutputLines$3(output) {
14291
14315
  }
14292
14316
  function formatCommitFailure(error) {
14293
14317
  if (error instanceof PreCommitHookError) {
14294
- const details = compactOutputLines$3(error.hookOutput);
14318
+ const details = compactOutputLines$4(error.hookOutput);
14295
14319
  return {
14296
14320
  ok: false,
14297
14321
  message: `Commit blocked by hook: ${details[0] || 'hook failed'}`,
14298
14322
  details: details.slice(1, 6),
14299
14323
  };
14300
14324
  }
14301
- const details = compactOutputLines$3(error.message);
14325
+ const details = compactOutputLines$4(error.message);
14302
14326
  return {
14303
14327
  ok: false,
14304
14328
  message: details[0] || 'Commit action failed.',
@@ -14331,7 +14355,7 @@ async function runCommitWorkflow({ action, git = getRepo(), noVerify = false, })
14331
14355
  }
14332
14356
  catch (error) {
14333
14357
  if (isCommandExitError(error)) {
14334
- const lines = compactOutputLines$3(output || error.message);
14358
+ const lines = compactOutputLines$4(output || error.message);
14335
14359
  return {
14336
14360
  ok: error.code === 0,
14337
14361
  message: lines[0] || error.message,
@@ -14373,7 +14397,7 @@ async function runCommitDraftWorkflow(input = {}) {
14373
14397
  }
14374
14398
  catch (error) {
14375
14399
  if (isCommandExitError(error)) {
14376
- const lines = compactOutputLines$3(error.message);
14400
+ const lines = compactOutputLines$4(error.message);
14377
14401
  return {
14378
14402
  ok: error.code === 0,
14379
14403
  message: lines[0] || error.message,
@@ -14411,6 +14435,274 @@ function isLogInkContextKeyLoading(status, key) {
14411
14435
  return status[key] === 'loading';
14412
14436
  }
14413
14437
 
14438
+ /**
14439
+ * The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
14440
+ * `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
14441
+ * but the angles read poorly when many branches overlap.
14442
+ *
14443
+ * `substituteGraphChars` walks the row left-to-right with one-char
14444
+ * lookahead so it can recognize git's two-char junction patterns and
14445
+ * emit proper box-drawing junctions (├╮ / ├╯) instead of overlapping
14446
+ * pipes (│╲ / │╱). Anything that isn't part of a recognized pattern
14447
+ * falls back to the legacy 1-to-1 substitution.
14448
+ *
14449
+ * `theme.ascii` (TERM=dumb / vt100) bypasses substitution entirely so
14450
+ * legacy terminals get the raw `git log --graph` output. `theme.noColor`
14451
+ * is orthogonal — Unicode chars still render, just without color.
14452
+ *
14453
+ * Kept ASCII-only intentionally:
14454
+ * - alphanumerics (commit refs / annotations git sometimes injects)
14455
+ * - parens / brackets (HEAD decoration markers, not part of the graph)
14456
+ * - hyphens / colons (likewise)
14457
+ */
14458
+ const ASCII_TO_UNICODE_MAP = {
14459
+ '*': '●',
14460
+ '|': '│',
14461
+ '/': '╱',
14462
+ '\\': '╲',
14463
+ '_': '─',
14464
+ };
14465
+ const DEFAULT_COMMIT_GLYPH = '●';
14466
+ /**
14467
+ * #791 stage 3 — distinct glyphs for merges and HEAD so they stand
14468
+ * out from the run of regular commits. `◆` (filled diamond) flags a
14469
+ * merge commit (`parents.length > 1`); `◉` (fisheye) flags HEAD
14470
+ * regardless of parent count. Both render at the same column width as
14471
+ * `●` so graph alignment stays intact across mixed commit types.
14472
+ */
14473
+ const MERGE_COMMIT_GLYPH = '◆';
14474
+ const HEAD_COMMIT_GLYPH = '◉';
14475
+ /**
14476
+ * Recognized 2-char junction patterns. The key is the bigram git emits
14477
+ * (lane char + spacer char); the value is the box-drawing pair we render.
14478
+ *
14479
+ * - `|\` (fork): trunk lane gains a right-T (├) and the spacer becomes
14480
+ * the upper-right corner (╮) starting the new lane below.
14481
+ * - `|/` (converge): trunk lane gains a right-T (├) and the spacer
14482
+ * becomes the upper-left corner (╯) absorbing the side lane from
14483
+ * above.
14484
+ *
14485
+ * `*\` and `* /` (commit-row variants) are handled the same way, but
14486
+ * the commit glyph itself stays configurable via `commitGlyph` so
14487
+ * stage 3 can swap in `◆` / `◉` for merges and HEAD.
14488
+ */
14489
+ const PIPE_FORK = '├╮';
14490
+ const PIPE_CONVERGE = '├╯';
14491
+ const FORK_SPACER = '╮';
14492
+ const CONVERGE_SPACER = '╯';
14493
+ function substituteGraphChars(graph, options) {
14494
+ if (options.ascii) {
14495
+ return graph;
14496
+ }
14497
+ const commitGlyph = options.commitGlyph ?? DEFAULT_COMMIT_GLYPH;
14498
+ let output = '';
14499
+ let i = 0;
14500
+ while (i < graph.length) {
14501
+ const a = graph[i];
14502
+ const b = i + 1 < graph.length ? graph[i + 1] : '';
14503
+ if (a === '|' && b === '\\') {
14504
+ output += PIPE_FORK;
14505
+ i += 2;
14506
+ continue;
14507
+ }
14508
+ if (a === '|' && b === '/') {
14509
+ output += PIPE_CONVERGE;
14510
+ i += 2;
14511
+ continue;
14512
+ }
14513
+ if (a === '*' && b === '\\') {
14514
+ output += commitGlyph + FORK_SPACER;
14515
+ i += 2;
14516
+ continue;
14517
+ }
14518
+ if (a === '*' && b === '/') {
14519
+ output += commitGlyph + CONVERGE_SPACER;
14520
+ i += 2;
14521
+ continue;
14522
+ }
14523
+ if (a === '*') {
14524
+ output += commitGlyph;
14525
+ }
14526
+ else {
14527
+ output += ASCII_TO_UNICODE_MAP[a] ?? a;
14528
+ }
14529
+ i += 1;
14530
+ }
14531
+ return output;
14532
+ }
14533
+
14534
+ /**
14535
+ * Lane tracking + per-lane coloring for the Ink log TUI graph (#791
14536
+ * stage 2).
14537
+ *
14538
+ * `git log --graph` emits topology in 2-char patterns where every even
14539
+ * position is a lane column (`*`, `|`, ` `) and every odd position is
14540
+ * a spacer that may carry a connector (`\`, `/`, `_`). To color graph
14541
+ * chars by which logical lane they belong to, we walk the rows
14542
+ * left-to-right tracking which lane id occupies each column and apply
14543
+ * git's emission rules:
14544
+ *
14545
+ * - `|\` (fork): the spacer spawns a new lane id at column +1 below.
14546
+ * - `|/` (converge): the spacer absorbs the lane at column +1 into
14547
+ * this column; the absorbed lane disappears in the next row.
14548
+ * - `*` is treated like `|` for lane purposes (a commit lives on a
14549
+ * lane and connects through the row).
14550
+ *
14551
+ * Lane ids are stable across rows for the same column unless one of
14552
+ * the transition patterns above fires. Other shifts (multi-step `_`
14553
+ * crossings, octopus merges) degrade gracefully — uncovered chars
14554
+ * just fall back to `undefined` lane id, so they render in the muted
14555
+ * graph color rather than a wrong lane color.
14556
+ *
14557
+ * The segment builder collapses adjacent characters with the same
14558
+ * lane id into one `LaneSegment` so the renderer emits one Text span
14559
+ * per visually-distinct color region instead of per-char.
14560
+ */
14561
+ function createLaneTrackerState() {
14562
+ return { columnLanes: new Map(), nextLaneId: 0 };
14563
+ }
14564
+ /**
14565
+ * Walk a single graph row left-to-right, mutating the tracker so the
14566
+ * next row sees the updated column → lane id map. Returns lane
14567
+ * segments ready for the renderer. When `options.ascii` is true the
14568
+ * tracker is left untouched and the row is emitted as a single
14569
+ * lane-less segment so legacy terminals get raw ASCII output with no
14570
+ * coloring.
14571
+ */
14572
+ function renderGraphRowSegments(graph, tracker, options) {
14573
+ if (options.ascii) {
14574
+ return [{ text: graph, laneId: undefined }];
14575
+ }
14576
+ const commitGlyph = options.commitGlyph ?? DEFAULT_COMMIT_GLYPH;
14577
+ const segments = [];
14578
+ const push = (text, laneId) => {
14579
+ const last = segments[segments.length - 1];
14580
+ if (last && last.laneId === laneId) {
14581
+ last.text += text;
14582
+ }
14583
+ else {
14584
+ segments.push({ text, laneId });
14585
+ }
14586
+ };
14587
+ let i = 0;
14588
+ while (i < graph.length) {
14589
+ const c = graph[i];
14590
+ const next = i + 1 < graph.length ? graph[i + 1] : '';
14591
+ const col = i >> 1;
14592
+ const isSpacer = (i & 1) === 1;
14593
+ if (c === ' ') {
14594
+ push(' ', undefined);
14595
+ i += 1;
14596
+ continue;
14597
+ }
14598
+ if (!isSpacer && (c === '|' || c === '*')) {
14599
+ if (!tracker.columnLanes.has(col)) {
14600
+ tracker.columnLanes.set(col, tracker.nextLaneId++);
14601
+ }
14602
+ const laneId = tracker.columnLanes.get(col);
14603
+ const glyph = c === '|' ? '│' : commitGlyph;
14604
+ if (next === '\\') {
14605
+ const newLaneId = tracker.nextLaneId++;
14606
+ tracker.columnLanes.set(col + 1, newLaneId);
14607
+ push(c === '|' ? '├' : commitGlyph, laneId);
14608
+ push('╮', newLaneId);
14609
+ i += 2;
14610
+ continue;
14611
+ }
14612
+ if (next === '/') {
14613
+ const absorbedLaneId = tracker.columnLanes.get(col + 1);
14614
+ push(c === '|' ? '├' : commitGlyph, laneId);
14615
+ push('╯', absorbedLaneId);
14616
+ tracker.columnLanes.delete(col + 1);
14617
+ i += 2;
14618
+ continue;
14619
+ }
14620
+ push(glyph, laneId);
14621
+ i += 1;
14622
+ continue;
14623
+ }
14624
+ // Non-lane chars (standalone `\`, `/`, `_`, decorations) — substitute
14625
+ // 1-to-1 and leave the lane id undefined so they render in the muted
14626
+ // fallback color.
14627
+ push(ASCII_TO_UNICODE_MAP[c] ?? c, undefined);
14628
+ i += 1;
14629
+ }
14630
+ return segments;
14631
+ }
14632
+ /**
14633
+ * Run the tracker over `count` rows starting from `state.rows[0]` so
14634
+ * downstream callers can resume tracking from a specific window
14635
+ * without re-scanning. Used by `getVisibleLogInkHistory` to keep lane
14636
+ * ids stable across scrolling — without this, each scroll would
14637
+ * re-color lanes from a fresh tracker.
14638
+ */
14639
+ function advanceTrackerThrough(graphs, tracker, count) {
14640
+ for (let i = 0; i < count && i < graphs.length; i++) {
14641
+ renderGraphRowSegments(graphs[i], tracker, { ascii: false });
14642
+ }
14643
+ }
14644
+ /**
14645
+ * Theme-aware lane palette. Default uses bright ANSI named colors that
14646
+ * render reliably on 16-color terminals; catppuccin / gruvbox lift
14647
+ * accent hues from their respective palettes so the graph stays
14648
+ * coherent with the surrounding chrome.
14649
+ *
14650
+ * Selecting 8 colors gives enough variety to distinguish lanes in
14651
+ * practice (most repos peak at 3-4 simultaneous lanes); the modulo
14652
+ * lookup wraps cleanly for the rare case of more.
14653
+ */
14654
+ const DEFAULT_LANE_PALETTE = [
14655
+ 'cyan', 'magenta', 'yellow', 'green', 'blue', 'red', 'cyanBright', 'magentaBright',
14656
+ ];
14657
+ const CATPPUCCIN_LANE_PALETTE = [
14658
+ '#89b4fa', '#f5c2e7', '#f9e2af', '#a6e3a1', '#cba6f7', '#fab387', '#94e2d5', '#f5e0dc',
14659
+ ];
14660
+ const GRUVBOX_LANE_PALETTE = [
14661
+ '#83a598', '#d3869b', '#fabd2f', '#b8bb26', '#d65d0e', '#fb4934', '#8ec07c', '#fe8019',
14662
+ ];
14663
+ function getLanePalette(theme) {
14664
+ if (theme.noColor) {
14665
+ return [];
14666
+ }
14667
+ const accent = theme.colors.accent;
14668
+ if (accent === '#89b4fa') {
14669
+ return CATPPUCCIN_LANE_PALETTE;
14670
+ }
14671
+ if (accent === '#83a598') {
14672
+ return GRUVBOX_LANE_PALETTE;
14673
+ }
14674
+ return DEFAULT_LANE_PALETTE;
14675
+ }
14676
+ function getLaneColor(laneId, theme) {
14677
+ if (laneId === undefined) {
14678
+ return undefined;
14679
+ }
14680
+ const palette = getLanePalette(theme);
14681
+ if (palette.length === 0) {
14682
+ return undefined;
14683
+ }
14684
+ return palette[laneId % palette.length];
14685
+ }
14686
+
14687
+ /**
14688
+ * Pick the commit glyph based on parent count + HEAD-ness so the
14689
+ * renderer can flag merges and the current head visually. HEAD wins
14690
+ * over merge when both apply (HEAD on a merge commit) — the ◉ ring
14691
+ * is the more salient signal and the user can still see the merge
14692
+ * via the lane topology.
14693
+ */
14694
+ function commitGlyphFor(commit) {
14695
+ if (isHeadCommit$1(commit)) {
14696
+ return HEAD_COMMIT_GLYPH;
14697
+ }
14698
+ if (commit.parents.length > 1) {
14699
+ return MERGE_COMMIT_GLYPH;
14700
+ }
14701
+ return DEFAULT_COMMIT_GLYPH;
14702
+ }
14703
+ function isHeadCommit$1(commit) {
14704
+ return commit.refs.some((ref) => ref === 'HEAD' || ref.startsWith('HEAD ->'));
14705
+ }
14414
14706
  function clampWindowStart(index, count, visibleCount) {
14415
14707
  return Math.max(0, Math.min(index - Math.floor(visibleCount / 2), Math.max(0, count - visibleCount)));
14416
14708
  }
@@ -14426,6 +14718,11 @@ function toCompactItems(state, visibleCount) {
14426
14718
  type: 'commit',
14427
14719
  commit,
14428
14720
  graph: '*',
14721
+ // Compact mode skips lane tracking (no topology to color) but still
14722
+ // wants the merge / HEAD glyph so the user can spot them at a
14723
+ // glance. Lane id stays undefined so the segment renders muted —
14724
+ // matching the legacy compact appearance, just with a richer glyph.
14725
+ laneSegments: [{ text: commitGlyphFor(commit), laneId: undefined }],
14429
14726
  selected: start + offset === state.selectedIndex,
14430
14727
  }));
14431
14728
  }
@@ -14436,17 +14733,28 @@ function toFullGraphItems(state, visibleCount) {
14436
14733
  const selected = state.filteredCommits[state.selectedIndex];
14437
14734
  const selectedRowIndex = state.rows.findIndex((row) => isSelectedCommit(row, selected));
14438
14735
  const start = clampWindowStart(selectedRowIndex >= 0 ? selectedRowIndex : 0, state.rows.length, visibleCount);
14736
+ // Lane tracking is order-dependent — fast-forward the tracker through
14737
+ // every row above the visible window so lane ids stay stable as the
14738
+ // user scrolls. Without this, scrolling would re-color lanes from a
14739
+ // fresh tracker each time.
14740
+ const tracker = createLaneTrackerState();
14741
+ const allGraphs = state.rows.map((row) => (row.type === 'commit' ? row.graph || '*' : row.graph));
14742
+ advanceTrackerThrough(allGraphs, tracker, start);
14439
14743
  return state.rows.slice(start, start + visibleCount).map((row) => {
14440
14744
  if (row.type === 'graph') {
14441
14745
  return {
14442
14746
  type: 'graph',
14443
14747
  graph: row.graph,
14748
+ laneSegments: renderGraphRowSegments(row.graph, tracker, { ascii: false }),
14444
14749
  };
14445
14750
  }
14751
+ const graph = row.graph || '*';
14752
+ const commitGlyph = commitGlyphFor(row);
14446
14753
  return {
14447
14754
  type: 'commit',
14448
14755
  commit: row,
14449
- graph: row.graph || '*',
14756
+ graph,
14757
+ laneSegments: renderGraphRowSegments(graph, tracker, { ascii: false, commitGlyph }),
14450
14758
  selected: isSelectedCommit(row, selected),
14451
14759
  };
14452
14760
  });
@@ -14464,84 +14772,6 @@ function formatInkRefLabels(refs) {
14464
14772
  return refs.length ? ` ${refs.map((ref) => `[${ref}]`).join(' ')}` : '';
14465
14773
  }
14466
14774
 
14467
- function countLabel(count, singular, plural = `${singular}s`) {
14468
- return `${count} ${count === 1 ? singular : plural}`;
14469
- }
14470
- function getLogInkWorkflowSections(context) {
14471
- const currentBranch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
14472
- const dirty = context.branches?.dirty ? 'dirty worktree' : 'clean worktree';
14473
- const loading = context.contextLoading;
14474
- const currentPullRequest = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
14475
- const repository = context.provider?.repository;
14476
- const repoName = repository?.owner && repository.name
14477
- ? `${repository.owner}/${repository.name}`
14478
- : repository?.message || 'local repository';
14479
- const operation = context.operation;
14480
- const worktree = context.worktree;
14481
- return [
14482
- {
14483
- title: 'Branch',
14484
- lines: [
14485
- `Current: ${currentBranch}`,
14486
- `State: ${dirty}`,
14487
- loading && !context.branches
14488
- ? 'Branch data loading'
14489
- : context.branches
14490
- ? `${countLabel(context.branches.localBranches.length, 'local branch', 'local branches')} | ${countLabel(context.branches.remoteBranches.length, 'remote branch', 'remote branches')}`
14491
- : 'Branch data unavailable',
14492
- ],
14493
- },
14494
- {
14495
- title: 'Provider / PR',
14496
- lines: [
14497
- `Repository: ${repoName}`,
14498
- loading && !context.provider && !context.pullRequest
14499
- ? 'Provider and pull request data loading'
14500
- : currentPullRequest
14501
- ? `PR #${currentPullRequest.number} ${currentPullRequest.state}${currentPullRequest.isDraft ? ' draft' : ''}`
14502
- : 'No pull request detected for current branch',
14503
- context.provider?.authenticated === false ? 'Provider auth: offline' : 'Provider auth: available',
14504
- ],
14505
- },
14506
- {
14507
- title: 'Status',
14508
- lines: loading && !worktree
14509
- ? ['Status data loading']
14510
- : worktree
14511
- ? [
14512
- `${countLabel(worktree.stagedCount, 'staged file')}`,
14513
- `${countLabel(worktree.unstagedCount, 'unstaged file')}`,
14514
- `${countLabel(worktree.untrackedCount, 'untracked file')}`,
14515
- ]
14516
- : ['Status data unavailable'],
14517
- },
14518
- {
14519
- title: 'Tags / Stashes / Worktrees',
14520
- lines: [
14521
- loading && !context.tags ? 'Tags loading' : context.tags ? countLabel(context.tags.tags.length, 'tag') : 'Tags unavailable',
14522
- loading && !context.stashes ? 'Stashes loading' : context.stashes ? countLabel(context.stashes.stashes.length, 'stash', 'stashes') : 'Stashes unavailable',
14523
- context.worktreeList
14524
- ? countLabel(context.worktreeList.worktrees.length, 'worktree')
14525
- : loading
14526
- ? 'Worktrees loading'
14527
- : 'Worktrees unavailable',
14528
- ],
14529
- },
14530
- {
14531
- title: 'Operation / AI',
14532
- lines: [
14533
- loading && !operation
14534
- ? 'Operation data loading'
14535
- : operation?.operation
14536
- ? `${operation.operation} in progress with ${countLabel(operation.conflictedFiles.length, 'conflict')}`
14537
- : 'No merge, rebase, cherry-pick, or revert in progress',
14538
- context.selectedCommit
14539
- ? `AI actions target ${context.selectedCommit.shortHash}; estimates shown before execution`
14540
- : 'AI actions require a selected commit',
14541
- ],
14542
- },
14543
- ];
14544
- }
14545
14775
  function getLogInkWorkflowActions() {
14546
14776
  return [
14547
14777
  {
@@ -14595,6 +14825,34 @@ function getLogInkWorkflowActions() {
14595
14825
  kind: 'destructive',
14596
14826
  requiresConfirmation: true,
14597
14827
  },
14828
+ {
14829
+ // Per-view-only: scoped to commit-diff and stash-diff explores in
14830
+ // inkInput (key: H). The action is non-destructive in the sense
14831
+ // that `git apply` won't lose any data — `git apply -R` undoes
14832
+ // it cleanly — so it bypasses the y-confirm path. The patch text
14833
+ // travels via the action's `payload` field. Empty key keeps the
14834
+ // workflow palette-discoverable without registering a global
14835
+ // hotkey (the palette path can't synthesize the patch text and
14836
+ // surfaces a hint instead — actual dispatch is from H in diff
14837
+ // view).
14838
+ id: 'apply-hunk-worktree',
14839
+ key: '',
14840
+ label: 'Apply hunk to worktree',
14841
+ description: 'Extract the hunk under the cursor and apply it to the working tree via `git apply`.',
14842
+ kind: 'normal',
14843
+ requiresConfirmation: false,
14844
+ },
14845
+ {
14846
+ // Sibling of `apply-hunk-worktree` — same extraction path, but
14847
+ // `git apply --cached` so the patch lands in the index without
14848
+ // touching the worktree. Bound to the `gH` chord in inkInput.
14849
+ id: 'apply-hunk-index',
14850
+ key: '',
14851
+ label: 'Apply hunk to index',
14852
+ description: 'Extract the hunk under the cursor and apply it to the index via `git apply --cached`.',
14853
+ kind: 'normal',
14854
+ requiresConfirmation: false,
14855
+ },
14598
14856
  {
14599
14857
  id: 'open-pr',
14600
14858
  key: 'O',
@@ -14687,6 +14945,152 @@ function getLogInkWorkflowActions() {
14687
14945
  kind: 'destructive',
14688
14946
  requiresConfirmation: true,
14689
14947
  },
14948
+ // #783 — full PR action panel. All five entries are palette-only
14949
+ // (`key: ''`) — actual dispatch is per-view scoped in inkInput so
14950
+ // the keys stay free outside the pull-request view. Merge / close /
14951
+ // approve / request-changes route through the y-confirm path
14952
+ // because each is irreversible (or near-irreversible) once gh
14953
+ // publishes it; comment is a free-form prompt with no extra
14954
+ // confirmation since the body itself is the affirmative action.
14955
+ {
14956
+ id: 'merge-pr',
14957
+ key: '',
14958
+ label: 'Merge pull request',
14959
+ description: 'Merge the current branch\'s pull request (prompts for merge / squash / rebase, then confirms).',
14960
+ kind: 'destructive',
14961
+ requiresConfirmation: true,
14962
+ },
14963
+ {
14964
+ id: 'close-pr',
14965
+ key: '',
14966
+ label: 'Close pull request',
14967
+ description: 'Close the current pull request without merging.',
14968
+ kind: 'destructive',
14969
+ requiresConfirmation: true,
14970
+ },
14971
+ {
14972
+ id: 'approve-pr',
14973
+ key: '',
14974
+ label: 'Approve pull request',
14975
+ description: 'Submit an approving review on the current pull request.',
14976
+ kind: 'normal',
14977
+ requiresConfirmation: true,
14978
+ },
14979
+ {
14980
+ id: 'request-changes-pr',
14981
+ key: '',
14982
+ label: 'Request changes on pull request',
14983
+ description: 'Submit a change-request review (prompts for the review body, then confirms).',
14984
+ kind: 'normal',
14985
+ requiresConfirmation: true,
14986
+ },
14987
+ {
14988
+ id: 'comment-pr',
14989
+ key: '',
14990
+ label: 'Comment on pull request',
14991
+ description: 'Add a comment to the current pull request (prompts for body).',
14992
+ kind: 'normal',
14993
+ requiresConfirmation: false,
14994
+ },
14995
+ {
14996
+ // Per-view-only: scoped to the history view in inkInput so `R`
14997
+ // doesn't fire elsewhere (it's also `R` for rename in branches
14998
+ // and delete-remote-tag in tags). Empty key keeps it
14999
+ // palette-discoverable without registering a global hotkey.
15000
+ id: 'revert-commit',
15001
+ key: '',
15002
+ label: 'Revert commit',
15003
+ description: 'Revert the cursored commit by adding an inverse commit on top of HEAD.',
15004
+ kind: 'destructive',
15005
+ requiresConfirmation: true,
15006
+ },
15007
+ {
15008
+ // Per-view-only: scoped to the history view in inkInput. Triggers
15009
+ // a mode prompt (soft / mixed / hard) before the reset runs so
15010
+ // `Z` alone never silently rewrites history.
15011
+ id: 'reset-to-commit',
15012
+ key: '',
15013
+ label: 'Reset to commit',
15014
+ description: 'Move the current branch tip to the cursored commit (prompts for soft / mixed / hard).',
15015
+ kind: 'destructive',
15016
+ requiresConfirmation: true,
15017
+ },
15018
+ {
15019
+ // Per-view-only: scoped to the history view in inkInput (key `B`).
15020
+ // The prompt itself is the affirmative gate — the user has to
15021
+ // type a branch name before anything happens — so this skips the
15022
+ // y-confirm path. Empty key keeps it palette-discoverable; the
15023
+ // palette path can't synthesize a branch name and surfaces a
15024
+ // hint instead.
15025
+ //
15026
+ // Distinct from `create-branch` (palette / `+` on branches view),
15027
+ // which uses `git switch -c` and switches onto the new branch.
15028
+ // This workflow uses `git branch <name> <sha>` and stays put —
15029
+ // GitKraken's "create branch here" semantic.
15030
+ id: 'create-branch-here',
15031
+ key: '',
15032
+ label: 'Create branch from commit',
15033
+ description: 'Create a branch pointed at the cursored commit (does not switch).',
15034
+ kind: 'normal',
15035
+ requiresConfirmation: false,
15036
+ },
15037
+ {
15038
+ // Per-view-only: scoped to the history view in inkInput via the
15039
+ // `gT` chord (bare `T` is taken by delete-tag on the tags view).
15040
+ // Same prompt-as-confirmation pattern as create-branch-here.
15041
+ // Lightweight tag — annotated tags remain available through the
15042
+ // existing `+` flow on the tags view.
15043
+ id: 'create-tag-here',
15044
+ key: '',
15045
+ label: 'Create tag at commit',
15046
+ description: 'Create a lightweight tag at the cursored commit.',
15047
+ kind: 'normal',
15048
+ requiresConfirmation: false,
15049
+ },
15050
+ {
15051
+ // Per-view-only: scoped to the history view in inkInput. `i`
15052
+ // (lowercase) is used instead of `I` so the existing `I`
15053
+ // ai-commit-summary workflow stays reachable on the history
15054
+ // view — `i` matches the `git rebase -i` flag mnemonic anyway.
15055
+ id: 'interactive-rebase',
15056
+ key: '',
15057
+ label: 'Interactive rebase',
15058
+ description: 'Start an interactive rebase from the cursored commit (opens $GIT_EDITOR for the todo list).',
15059
+ kind: 'destructive',
15060
+ requiresConfirmation: true,
15061
+ },
15062
+ {
15063
+ // Per-view-only: scoped to the history view in inkInput (key `B`).
15064
+ // The prompt itself is the affirmative gate — the user has to
15065
+ // type a branch name before anything happens — so this skips the
15066
+ // y-confirm path. Empty key keeps it palette-discoverable; the
15067
+ // palette path can't synthesize a branch name and surfaces a
15068
+ // hint instead.
15069
+ //
15070
+ // Distinct from `create-branch` (palette / `+` on branches view),
15071
+ // which uses `git switch -c` and switches onto the new branch.
15072
+ // This workflow uses `git branch <name> <sha>` and stays put —
15073
+ // GitKraken's "create branch here" semantic.
15074
+ id: 'create-branch-here',
15075
+ key: '',
15076
+ label: 'Create branch from commit',
15077
+ description: 'Create a branch pointed at the cursored commit (does not switch).',
15078
+ kind: 'normal',
15079
+ requiresConfirmation: false,
15080
+ },
15081
+ {
15082
+ // Per-view-only: scoped to the history view in inkInput via the
15083
+ // `gT` chord (bare `T` is taken by delete-tag on the tags view).
15084
+ // Same prompt-as-confirmation pattern as create-branch-here.
15085
+ // Lightweight tag — annotated tags remain available through the
15086
+ // existing `+` flow on the tags view.
15087
+ id: 'create-tag-here',
15088
+ key: '',
15089
+ label: 'Create tag at commit',
15090
+ description: 'Create a lightweight tag at the cursored commit.',
15091
+ kind: 'normal',
15092
+ requiresConfirmation: false,
15093
+ },
14690
15094
  {
14691
15095
  id: 'ai-commit-summary',
14692
15096
  key: 'I',
@@ -14708,6 +15112,15 @@ function getLogInkWorkflowActions() {
14708
15112
  ];
14709
15113
  }
14710
15114
  function getLogInkWorkflowActionByKey(inputValue) {
15115
+ // Workflow actions with an empty `key` are palette-only — they
15116
+ // exist so the command palette can surface them but should never
15117
+ // match a raw keystroke. Without this guard, any unbound key
15118
+ // (left/right arrow, function keys) that arrives with an empty
15119
+ // inputValue would `find()` the first empty-key entry —
15120
+ // `cherry-pick-commit` — and pop its confirmation dialog.
15121
+ if (!inputValue) {
15122
+ return undefined;
15123
+ }
14711
15124
  return getLogInkWorkflowActions().find((action) => action.key === inputValue);
14712
15125
  }
14713
15126
  function getLogInkWorkflowActionById(id) {
@@ -14837,6 +15250,13 @@ const LOG_INK_KEY_BINDINGS = [
14837
15250
  description: 'Toggle compact and full graph display.',
14838
15251
  contexts: ['normal', 'commits'],
14839
15252
  },
15253
+ {
15254
+ id: 'toggleDiffViewMode',
15255
+ keys: ['d'],
15256
+ label: 'split/unified',
15257
+ description: 'Toggle the diff view between unified and side-by-side split rendering. Falls back to unified on narrow terminals.',
15258
+ contexts: ['commits'],
15259
+ },
14840
15260
  {
14841
15261
  id: 'navigateHome',
14842
15262
  keys: ['gh'],
@@ -14893,6 +15313,13 @@ const LOG_INK_KEY_BINDINGS = [
14893
15313
  description: 'Push the linked worktrees view.',
14894
15314
  contexts: ['normal'],
14895
15315
  },
15316
+ {
15317
+ id: 'navigatePullRequest',
15318
+ keys: ['gp'],
15319
+ label: 'pull request',
15320
+ description: 'Push the dedicated pull-request action panel for the current branch.',
15321
+ contexts: ['normal'],
15322
+ },
14896
15323
  {
14897
15324
  id: 'navigateBack',
14898
15325
  keys: ['<', 'esc'],
@@ -15021,6 +15448,7 @@ const GLOBAL_BINDING_IDS = [
15021
15448
  'navigateTags',
15022
15449
  'navigateStash',
15023
15450
  'navigateWorktrees',
15451
+ 'navigatePullRequest',
15024
15452
  'navigateBack',
15025
15453
  ];
15026
15454
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -15081,8 +15509,37 @@ function getLogInkFooterHints(options) {
15081
15509
  };
15082
15510
  }
15083
15511
  if (options.focus === 'sidebar') {
15512
+ // Per-tab hints when the active tab has selectable items — the user
15513
+ // can act on the cursored entity without leaving the workstation
15514
+ // view. Status tab + empty content tabs fall back to the generic
15515
+ // "enter open" hint that drills into the dedicated view.
15516
+ const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
15517
+ if (itemsPresent && options.sidebarTab === 'branches') {
15518
+ return {
15519
+ contextual: ['↑/↓ branches', '←/→ tab', 'enter checkout', 'D delete', 'R rename', 'u upstream'],
15520
+ global: NORMAL_GLOBAL_HINTS,
15521
+ };
15522
+ }
15523
+ if (itemsPresent && options.sidebarTab === 'stashes') {
15524
+ return {
15525
+ contextual: ['↑/↓ stashes', '←/→ tab', 'enter diff', 'a apply', 'p pop', 'X drop'],
15526
+ global: NORMAL_GLOBAL_HINTS,
15527
+ };
15528
+ }
15529
+ if (itemsPresent && options.sidebarTab === 'tags') {
15530
+ return {
15531
+ contextual: ['↑/↓ tags', '←/→ tab', '+ new', 'P push', 'T delete'],
15532
+ global: NORMAL_GLOBAL_HINTS,
15533
+ };
15534
+ }
15535
+ if (itemsPresent && options.sidebarTab === 'worktrees') {
15536
+ return {
15537
+ contextual: ['↑/↓ worktrees', '←/→ tab', 'W remove'],
15538
+ global: NORMAL_GLOBAL_HINTS,
15539
+ };
15540
+ }
15084
15541
  return {
15085
- contextual: ['[/] tab', '1-5 jump', 'tab focus'],
15542
+ contextual: ['←/→ tab', '1-5 jump', 'enter open', 'tab focus'],
15086
15543
  global: NORMAL_GLOBAL_HINTS,
15087
15544
  };
15088
15545
  }
@@ -15099,17 +15556,23 @@ function getLogInkFooterHints(options) {
15099
15556
  };
15100
15557
  }
15101
15558
  if (options.activeView === 'diff') {
15559
+ // Surface what `d` will switch *to* — labels the next mode rather
15560
+ // than the current one so the hint reads as a verb. The split-mode
15561
+ // hint is only shown for the read-only diff sources (commit/stash);
15562
+ // the worktree diff stays unified-only for now.
15563
+ const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
15102
15564
  if (options.diffSource === 'stash') {
15103
15565
  return {
15104
- contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'o edit', 'y yank', 'esc back'],
15566
+ contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', 'o edit', splitToggleHint, 'y yank', 'esc back'],
15105
15567
  global: NORMAL_GLOBAL_HINTS,
15106
15568
  };
15107
15569
  }
15108
15570
  if (options.diffSource === 'commit') {
15109
15571
  // Commit-diff explore: read-only diff, but `c` cherry-picks the
15110
- // cursored file from the commit into the worktree.
15572
+ // cursored file from the commit into the worktree, and `H`
15573
+ // (or `gH` for index) applies just the cursored hunk.
15111
15574
  return {
15112
- contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'y/Y yank', 'esc back'],
15575
+ contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'y/Y yank', 'esc back'],
15113
15576
  global: NORMAL_GLOBAL_HINTS,
15114
15577
  };
15115
15578
  }
@@ -15148,8 +15611,26 @@ function getLogInkFooterHints(options) {
15148
15611
  global: NORMAL_GLOBAL_HINTS,
15149
15612
  };
15150
15613
  }
15614
+ if (options.activeView === 'pull-request') {
15615
+ return {
15616
+ // #783 — full PR action panel. Five mutating ops scoped to this
15617
+ // view: m / x / a / R / c, plus O for open-in-browser (already
15618
+ // a global). Each routes through y-confirm or an input prompt;
15619
+ // none fire silently.
15620
+ contextual: ['m merge', 'x close', 'a approve', 'R changes', 'c comment', 'O open', 'esc back'],
15621
+ global: NORMAL_GLOBAL_HINTS,
15622
+ };
15623
+ }
15151
15624
  return {
15152
- contextual: ['↑/↓ move', 'enter diff', 'c cherry-pick', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15625
+ // History view default hints. Mutating ops (`c` cherry-pick, `R`
15626
+ // revert, `Z` reset, `i` interactive-rebase) all route through a
15627
+ // y-confirm or mode prompt — none fire silently from the keystroke.
15628
+ // `B` create-branch-here and `gT` create-tag-here use a prompt as
15629
+ // the affirmative gate (typing the name is the confirmation).
15630
+ // Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
15631
+ // the footer stays scannable; full descriptions live in `?` help
15632
+ // and the palette.
15633
+ contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15153
15634
  global: NORMAL_GLOBAL_HINTS,
15154
15635
  };
15155
15636
  }
@@ -15292,39 +15773,6 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
15292
15773
  .map((entry) => entry.command);
15293
15774
  }
15294
15775
 
15295
- /**
15296
- * The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
15297
- * `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
15298
- * but the angles read poorly when many branches overlap.
15299
- *
15300
- * `substituteGraphChars` swaps them for box-drawing / geometric Unicode
15301
- * equivalents when the terminal can render them; falls back to ASCII
15302
- * under `theme.ascii` (TERM=dumb / vt100) and `theme.noColor` is
15303
- * orthogonal — the Unicode chars are still rendered, just without color.
15304
- *
15305
- * Kept ASCII-only intentionally:
15306
- * - alphanumerics (commit refs / annotations git sometimes injects)
15307
- * - parens / brackets (HEAD decoration markers, not part of the graph)
15308
- * - hyphens / colons (likewise)
15309
- */
15310
- const ASCII_TO_UNICODE = {
15311
- '*': '●',
15312
- '|': '│',
15313
- '/': '╱',
15314
- '\\': '╲',
15315
- '_': '─',
15316
- };
15317
- function substituteGraphChars(graph, options) {
15318
- if (options.ascii) {
15319
- return graph;
15320
- }
15321
- let output = '';
15322
- for (const character of graph) {
15323
- output += ASCII_TO_UNICODE[character] ?? character;
15324
- }
15325
- return output;
15326
- }
15327
-
15328
15776
  /**
15329
15777
  * OSC 8 hyperlink helpers for the Ink TUI (P5.1).
15330
15778
  *
@@ -15396,6 +15844,84 @@ function formatHyperlink(text, url, env = process.env) {
15396
15844
  return `${OSC_PREFIX}${url}${ST}${text}${OSC_PREFIX}${ST}`;
15397
15845
  }
15398
15846
 
15847
+ /**
15848
+ * Extract a single hunk from a unified-patch diff so it can be fed to
15849
+ * `git apply` (or `git apply --cached`) for a hunk-level cherry-pick.
15850
+ *
15851
+ * The TUI's diff explore views render two flavors of patch text:
15852
+ *
15853
+ * - stash-diff: full `git stash show -p` output, which includes
15854
+ * `diff --git`, `---`, `+++`, and one or more `@@ ... @@` hunks
15855
+ * per file.
15856
+ * - commit-diff: the per-file `filePreview.hunks` array, which is
15857
+ * hunks-only (no `diff --git` / `---` / `+++` headers).
15858
+ *
15859
+ * Either way, this helper walks `lines` from `cursorOffset` backwards
15860
+ * to find the most recent `@@` header, walks forward to the end of
15861
+ * that hunk's body, and synthesizes a fresh `diff --git` /
15862
+ * `---` / `+++` set using the caller-provided path. The output is a
15863
+ * complete, self-contained patch suitable for `git apply` without
15864
+ * having to preserve original headers from `lines`.
15865
+ */
15866
+ const HUNK_HEADER_PREFIX = '@@';
15867
+ const DIFF_GIT_PREFIX = 'diff --git ';
15868
+ /**
15869
+ * Find the index of the `@@` hunk header at or before `cursorOffset`.
15870
+ * Returns -1 when the cursor sits before the first hunk in the patch
15871
+ * (i.e. on a `diff --git` / `---` / `+++` header line) — caller treats
15872
+ * that as "no hunk at cursor" and surfaces a status message.
15873
+ */
15874
+ function findHunkHeaderAtOrBefore(lines, cursorOffset) {
15875
+ const start = Math.min(cursorOffset, lines.length - 1);
15876
+ for (let i = start; i >= 0; i -= 1) {
15877
+ if (lines[i]?.startsWith(HUNK_HEADER_PREFIX)) {
15878
+ return i;
15879
+ }
15880
+ }
15881
+ return -1;
15882
+ }
15883
+ /**
15884
+ * Walk forward from a hunk header to either the next `@@` header or
15885
+ * the next `diff --git` line — that's where this hunk's body ends.
15886
+ * The end index is exclusive (the line at `endIndex` is NOT part of
15887
+ * this hunk).
15888
+ */
15889
+ function findHunkBodyEnd(lines, headerIndex) {
15890
+ for (let i = headerIndex + 1; i < lines.length; i += 1) {
15891
+ const line = lines[i];
15892
+ if (line?.startsWith(HUNK_HEADER_PREFIX) || line?.startsWith(DIFF_GIT_PREFIX)) {
15893
+ return i;
15894
+ }
15895
+ }
15896
+ return lines.length;
15897
+ }
15898
+ function extractDiffHunk(input) {
15899
+ const { lines, cursorOffset, path } = input;
15900
+ if (!lines.length || !path) {
15901
+ return null;
15902
+ }
15903
+ const headerIndex = findHunkHeaderAtOrBefore(lines, cursorOffset);
15904
+ if (headerIndex < 0) {
15905
+ return null;
15906
+ }
15907
+ const bodyEnd = findHunkBodyEnd(lines, headerIndex);
15908
+ // Header itself + at least one body line. An empty hunk body would
15909
+ // mean the patch is malformed and `git apply` would reject it; bail
15910
+ // out early so the caller can surface a clear status message.
15911
+ if (bodyEnd <= headerIndex + 1) {
15912
+ return null;
15913
+ }
15914
+ const hunkLines = lines.slice(headerIndex, bodyEnd);
15915
+ const patchText = [
15916
+ `diff --git a/${path} b/${path}`,
15917
+ `--- a/${path}`,
15918
+ `+++ b/${path}`,
15919
+ ...hunkLines,
15920
+ '',
15921
+ ].join('\n');
15922
+ return { patchText };
15923
+ }
15924
+
15399
15925
  /**
15400
15926
  * Sort modes for the promoted views (P4.2).
15401
15927
  *
@@ -15415,23 +15941,31 @@ function cycleBranchSort(mode) {
15415
15941
  return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
15416
15942
  }
15417
15943
  function sortBranches(branches, mode) {
15418
- const copy = branches.slice();
15944
+ // Pin the current branch at index 0 regardless of sort mode (#806
15945
+ // follow-up). Lands the user's cursor on the active branch by
15946
+ // default and keeps the most-relevant row glued to the top of the
15947
+ // list as they cycle sorts.
15948
+ const current = branches.find((entry) => entry.current);
15949
+ const rest = branches.filter((entry) => !entry.current);
15950
+ const sortedRest = rest.slice();
15419
15951
  switch (mode) {
15420
15952
  case 'name':
15421
- return copy.sort((a, b) => a.shortName.localeCompare(b.shortName));
15953
+ sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
15954
+ break;
15422
15955
  case 'recent':
15423
15956
  // ISO-shaped dates compare byte-for-byte; descending so the freshest
15424
15957
  // branch sits at the top.
15425
- return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
15958
+ sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
15426
15959
  a.shortName.localeCompare(b.shortName));
15960
+ break;
15427
15961
  case 'ahead':
15428
15962
  // ahead-first; ties broken by behind, then by name. Keeps "this branch
15429
15963
  // has unmerged work" in the user's first scroll.
15430
- return copy.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
15964
+ sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
15431
15965
  a.shortName.localeCompare(b.shortName));
15432
- default:
15433
- return copy;
15966
+ break;
15434
15967
  }
15968
+ return current ? [current, ...sortedRest] : sortedRest;
15435
15969
  }
15436
15970
  const TAG_SORT_MODES = ['recent', 'name'];
15437
15971
  const DEFAULT_TAG_SORT_MODE = 'recent';
@@ -15765,6 +16299,8 @@ function createLogInkState(rows, options = {}) {
15765
16299
  sidebarTab: 'status',
15766
16300
  userSidebarTab: 'status',
15767
16301
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
16302
+ diffViewMode: 'unified',
16303
+ inspectorTab: 'inspector',
15768
16304
  };
15769
16305
  }
15770
16306
  function getSelectedInkCommit(state) {
@@ -15822,6 +16358,28 @@ function applyLogInkAction(state, action) {
15822
16358
  pendingCommitFocused: false,
15823
16359
  pendingKey: undefined,
15824
16360
  };
16361
+ case 'selectCommitByHash': {
16362
+ // Locates a commit by its full or short hash within the active
16363
+ // filtered list and snaps the cursor to it. Used by the
16364
+ // branch/tag auto-jump effect (#806 follow-up): cursoring a
16365
+ // branch in the sidebar tracks the history view to that
16366
+ // branch's tip without the user manually scrolling. No-op when
16367
+ // the hash isn't in the loaded list (the runtime surfaces a
16368
+ // status hint in that case).
16369
+ const target = action.hash;
16370
+ const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
16371
+ if (index < 0) {
16372
+ return state;
16373
+ }
16374
+ return {
16375
+ ...state,
16376
+ selectedIndex: index,
16377
+ selectedFileIndex: 0,
16378
+ diffPreviewOffset: 0,
16379
+ pendingCommitFocused: false,
16380
+ pendingKey: undefined,
16381
+ };
16382
+ }
15825
16383
  case 'focusPendingCommit':
15826
16384
  return {
15827
16385
  ...state,
@@ -15861,6 +16419,34 @@ function applyLogInkAction(state, action) {
15861
16419
  selectedBranchIndex: clampIndex$1(state.selectedBranchIndex + action.delta, action.count),
15862
16420
  pendingKey: undefined,
15863
16421
  };
16422
+ case 'resetBranchSelection':
16423
+ // Snap the branches sidebar / view cursor back to position 0.
16424
+ // Used after a successful checkout (#806 follow-up): combined
16425
+ // with the "current branch pinned at top" rule from #809, this
16426
+ // lands the user's cursor on the just-checked-out branch.
16427
+ return {
16428
+ ...state,
16429
+ selectedBranchIndex: 0,
16430
+ pendingKey: undefined,
16431
+ };
16432
+ case 'setInspectorTab':
16433
+ return {
16434
+ ...state,
16435
+ inspectorTab: action.value,
16436
+ pendingKey: undefined,
16437
+ };
16438
+ case 'cycleInspectorTab': {
16439
+ // Two-tab toggle — `delta` is symmetrical so direction does not
16440
+ // matter, but we keep the action shape consistent with the
16441
+ // sidebar's `nextSidebarTab` / `previousSidebarTab` so callers
16442
+ // can mirror the sidebar pattern verbatim.
16443
+ const next = state.inspectorTab === 'inspector' ? 'actions' : 'inspector';
16444
+ return {
16445
+ ...state,
16446
+ inspectorTab: next,
16447
+ pendingKey: undefined,
16448
+ };
16449
+ }
15864
16450
  case 'moveTag':
15865
16451
  return {
15866
16452
  ...state,
@@ -15902,6 +16488,7 @@ function applyLogInkAction(state, action) {
15902
16488
  kind: action.kind,
15903
16489
  label: action.label,
15904
16490
  value: action.initial || '',
16491
+ multiline: action.multiline,
15905
16492
  },
15906
16493
  pendingKey: undefined,
15907
16494
  };
@@ -15934,6 +16521,27 @@ function applyLogInkAction(state, action) {
15934
16521
  }
15935
16522
  case 'setHistoryFetchArgs':
15936
16523
  return { ...state, historyFetchArgs: action.value, pendingKey: undefined };
16524
+ case 'toggleDiffViewMode':
16525
+ // Reset the scroll offsets so the new mode opens at the top — long
16526
+ // lines wrap differently in split mode (the renderer truncates per
16527
+ // column instead of per row), so the saved offset can land on a
16528
+ // different visual line. Snap to the top is simpler than mapping
16529
+ // unified offsets to split offsets.
16530
+ return {
16531
+ ...state,
16532
+ diffViewMode: state.diffViewMode === 'unified' ? 'split' : 'unified',
16533
+ diffPreviewOffset: 0,
16534
+ worktreeDiffOffset: 0,
16535
+ pendingKey: undefined,
16536
+ };
16537
+ case 'setDiffViewMode':
16538
+ return {
16539
+ ...state,
16540
+ diffViewMode: action.value,
16541
+ diffPreviewOffset: 0,
16542
+ worktreeDiffOffset: 0,
16543
+ pendingKey: undefined,
16544
+ };
15937
16545
  case 'moveToBottom':
15938
16546
  return {
15939
16547
  ...state,
@@ -16212,12 +16820,151 @@ function applyLogInkAction(state, action) {
16212
16820
  }
16213
16821
  }
16214
16822
 
16823
+ /**
16824
+ * In-sidebar selection helpers (#791 follow-up — sidebar entity ops).
16825
+ *
16826
+ * The workstation sidebar's branches / tags / stashes / worktrees tabs
16827
+ * used to be read-only previews — to act on an entity the user had to
16828
+ * drill into the dedicated promoted view. With the per-entity ops
16829
+ * gated to also fire on `state.focus === 'sidebar'` plus a matching
16830
+ * `sidebarTab`, j/k navigates the visible list inside the sidebar
16831
+ * itself, Enter performs the primary action (checkout / open diff),
16832
+ * and the existing per-view secondary keys (a/p/X/D/R/u/+P) are now
16833
+ * reachable without leaving the workstation view.
16834
+ *
16835
+ * The sidebar accordion is short — the visible window for an active
16836
+ * tab is capped (defaults below) so a long branch list doesn't
16837
+ * collapse the rest of the chrome. When the cursor scrolls past the
16838
+ * visible window, this module produces a sliding window that keeps it
16839
+ * in view; the dedicated view stays the right home for "show me all
16840
+ * 80 branches at once."
16841
+ */
16842
+ const DEFAULT_SIDEBAR_VISIBLE = 8;
16843
+ /**
16844
+ * Compute the sliding window so that `selected` stays inside it while
16845
+ * the window remains anchored at the top whenever possible (so short
16846
+ * lists don't scroll for no reason). When the cursor moves past the
16847
+ * window, the window slides just enough to keep the cursor in view —
16848
+ * matching the commit history's `clampWindowStart` behaviour for
16849
+ * familiarity.
16850
+ */
16851
+ function getSidebarVisibleWindow(total, selected, visible = DEFAULT_SIDEBAR_VISIBLE) {
16852
+ const size = Math.max(1, Math.min(visible, total));
16853
+ if (total <= visible) {
16854
+ return { start: 0, size, truncatedAbove: 0, truncatedBelow: 0 };
16855
+ }
16856
+ const half = Math.floor(size / 2);
16857
+ const idealStart = selected - half;
16858
+ const maxStart = total - size;
16859
+ const start = Math.max(0, Math.min(idealStart, maxStart));
16860
+ return {
16861
+ start,
16862
+ size,
16863
+ truncatedAbove: start,
16864
+ truncatedBelow: total - (start + size),
16865
+ };
16866
+ }
16867
+ /**
16868
+ * True when an in-sidebar action (j/k move, Enter checkout, etc.)
16869
+ * should fire instead of the generic drill-in / tab-cycle behaviour.
16870
+ *
16871
+ * Status tab is excluded because its preview shows worktree files —
16872
+ * those have their own selection model in the dedicated status view
16873
+ * and the sidebar doesn't surface them as selectable rows.
16874
+ */
16875
+ function sidebarTabHasSelectableItems(sidebarTab, itemCount) {
16876
+ if (!itemCount || itemCount <= 0)
16877
+ return false;
16878
+ return sidebarTab === 'branches' ||
16879
+ sidebarTab === 'tags' ||
16880
+ sidebarTab === 'stashes' ||
16881
+ sidebarTab === 'worktrees';
16882
+ }
16883
+
16215
16884
  function action(actionValue) {
16216
16885
  return {
16217
16886
  type: 'action',
16218
16887
  action: actionValue,
16219
16888
  };
16220
16889
  }
16890
+ /**
16891
+ * Build the events needed to apply the hunk under the diff cursor. The
16892
+ * runtime workflow handler expects payload format `<target>\n<patch>`
16893
+ * — splitting on the first newline keeps the patch body intact for
16894
+ * targets like `worktree` and `index` (no newlines in the prefix).
16895
+ *
16896
+ * Returns [] when the user isn't on a commit-diff / stash-diff explore,
16897
+ * or when no hunk can be extracted at the current cursor offset
16898
+ * (e.g. cursor sits on a `diff --git` header before the first `@@`).
16899
+ * Callers fall back to a contextual status message when this returns [].
16900
+ */
16901
+ function buildApplyHunkEvents(state, context, target) {
16902
+ if (state.activeView !== 'diff')
16903
+ return [];
16904
+ if (state.diffSource !== 'commit' && state.diffSource !== 'stash')
16905
+ return [];
16906
+ const lines = context.diffLinesForHunkApply;
16907
+ if (!lines || lines.length === 0)
16908
+ return [];
16909
+ const path = state.diffSource === 'stash'
16910
+ ? context.stashDiffSelectedPath
16911
+ : context.commitDiffSelectedPath;
16912
+ if (!path)
16913
+ return [];
16914
+ const extracted = extractDiffHunk({
16915
+ lines,
16916
+ cursorOffset: state.diffPreviewOffset,
16917
+ path,
16918
+ });
16919
+ if (!extracted)
16920
+ return [];
16921
+ const id = target === 'index' ? 'apply-hunk-index' : 'apply-hunk-worktree';
16922
+ return [{
16923
+ type: 'runWorkflowAction',
16924
+ id,
16925
+ payload: `${target}\n${extracted.patchText}`,
16926
+ }];
16927
+ }
16928
+ /**
16929
+ * Per-entity action-target predicates. The promoted views (`branches`,
16930
+ * `tags`, `stash`, `worktrees`) each scope a set of ops to their
16931
+ * dedicated surface. The same ops also fire when the user has the
16932
+ * sidebar focused on the matching tab — that's how in-sidebar
16933
+ * selection (#791 follow-up) lets the user checkout / apply / drop
16934
+ * without leaving the workstation view.
16935
+ */
16936
+ function isBranchActionTarget(state) {
16937
+ return (state.activeView === 'branches' && state.focus === 'commits') ||
16938
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
16939
+ }
16940
+ function isTagActionTarget(state) {
16941
+ return (state.activeView === 'tags' && state.focus === 'commits') ||
16942
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
16943
+ }
16944
+ function isStashActionTarget(state) {
16945
+ return (state.activeView === 'stash' && state.focus === 'commits') ||
16946
+ (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
16947
+ }
16948
+ function isWorktreeActionTarget(state) {
16949
+ return (state.activeView === 'worktrees' && state.focus === 'commits') ||
16950
+ (state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
16951
+ }
16952
+ /**
16953
+ * Item count for the active sidebar tab — used by the generic
16954
+ * sidebar-Enter handler to decide whether to defer to the per-entity
16955
+ * Enter (when items are present and the user is cursoring through
16956
+ * them) or to drill into the dedicated view (when the tab is empty
16957
+ * or has no per-entity Enter handler defined).
16958
+ */
16959
+ function getSidebarItemCount(sidebarTab, context) {
16960
+ switch (sidebarTab) {
16961
+ case 'branches': return context.branchCount;
16962
+ case 'tags': return context.tagCount;
16963
+ case 'stashes': return context.stashCount;
16964
+ case 'worktrees': return context.worktreeListCount;
16965
+ default: return undefined;
16966
+ }
16967
+ }
16221
16968
  /**
16222
16969
  * Translate a palette command into the same events its keystroke would have
16223
16970
  * produced. Phase 6 makes `:` a real launcher: this is the single mapping
@@ -16297,6 +17044,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
16297
17044
  return [action({ type: 'pushView', value: 'stash' })];
16298
17045
  case 'navigateWorktrees':
16299
17046
  return [action({ type: 'pushView', value: 'worktrees' })];
17047
+ case 'navigatePullRequest':
17048
+ return [action({ type: 'pushView', value: 'pull-request' })];
16300
17049
  case 'navigateBack':
16301
17050
  return [action({ type: 'popView' })];
16302
17051
  case 'openSelected': {
@@ -16354,10 +17103,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
16354
17103
  case 'clearSearch':
16355
17104
  return [action({ type: 'clearFilter' })];
16356
17105
  case 'cycleSort':
16357
- if (state.activeView === 'branches') {
17106
+ if (isBranchActionTarget(state)) {
16358
17107
  return [action({ type: 'cycleBranchSort' })];
16359
17108
  }
16360
- if (state.activeView === 'tags') {
17109
+ if (isTagActionTarget(state)) {
16361
17110
  return [action({ type: 'cycleTagSort' })];
16362
17111
  }
16363
17112
  return [action({
@@ -16394,6 +17143,77 @@ function hasUnsavedComposeDraft(state) {
16394
17143
  }
16395
17144
  return Boolean(compose.summary.trim() || compose.body.trim());
16396
17145
  }
17146
+ /**
17147
+ * Submit the active input prompt — used by Enter on single-line
17148
+ * prompts and by Ctrl+D on multi-line prompts (#806). Most prompt
17149
+ * kinds dispatch a workflow whose id matches the kind
17150
+ * (`create-branch`, `rename-branch`, etc.). A few are exceptions:
17151
+ * - `reset-mode` (#777) collects soft/mixed/hard and forwards the
17152
+ * mode as the payload to `reset-to-commit`.
17153
+ * - `pr-merge-strategy` (#783) validates the strategy and routes to
17154
+ * `merge-pr` via the y-confirm path.
17155
+ * - `pr-comment` dispatches `comment-pr` directly — the body itself
17156
+ * is the affirmative action.
17157
+ * - `pr-request-changes` routes to `request-changes-pr` via
17158
+ * y-confirm because the review is publicly visible.
17159
+ * Each exception validates here so a typo doesn't surface as a
17160
+ * "workflow not yet wired" status downstream.
17161
+ *
17162
+ * Empty values yield a hint instead of a no-op so the user knows what
17163
+ * to do — the same UX whether they pressed Enter (single-line) or
17164
+ * Ctrl+D (multi-line).
17165
+ */
17166
+ function submitInputPrompt(state) {
17167
+ if (!state.inputPrompt)
17168
+ return [];
17169
+ const value = state.inputPrompt.value.trim();
17170
+ if (!value) {
17171
+ return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
17172
+ }
17173
+ if (state.inputPrompt.kind === 'reset-mode') {
17174
+ const mode = value.toLowerCase();
17175
+ if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
17176
+ return [action({
17177
+ type: 'setStatus',
17178
+ value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
17179
+ })];
17180
+ }
17181
+ return [
17182
+ { type: 'runWorkflowAction', id: 'reset-to-commit', payload: mode },
17183
+ action({ type: 'closeInputPrompt' }),
17184
+ ];
17185
+ }
17186
+ if (state.inputPrompt.kind === 'pr-merge-strategy') {
17187
+ const strategy = value.toLowerCase();
17188
+ if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
17189
+ return [action({
17190
+ type: 'setStatus',
17191
+ value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
17192
+ })];
17193
+ }
17194
+ return [
17195
+ action({ type: 'setPendingConfirmation', value: 'merge-pr', payload: strategy }),
17196
+ action({ type: 'closeInputPrompt' }),
17197
+ ];
17198
+ }
17199
+ if (state.inputPrompt.kind === 'pr-comment') {
17200
+ return [
17201
+ { type: 'runWorkflowAction', id: 'comment-pr', payload: value },
17202
+ action({ type: 'closeInputPrompt' }),
17203
+ ];
17204
+ }
17205
+ if (state.inputPrompt.kind === 'pr-request-changes') {
17206
+ return [
17207
+ action({ type: 'setPendingConfirmation', value: 'request-changes-pr', payload: value }),
17208
+ action({ type: 'closeInputPrompt' }),
17209
+ ];
17210
+ }
17211
+ const id = state.inputPrompt.kind;
17212
+ return [
17213
+ { type: 'runWorkflowAction', id, payload: value },
17214
+ action({ type: 'closeInputPrompt' }),
17215
+ ];
17216
+ }
16397
17217
  function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16398
17218
  if (key.ctrl && inputValue === 'c') {
16399
17219
  if (hasUnsavedComposeDraft(state) && !state.pendingMutationConfirmation) {
@@ -16406,22 +17226,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16406
17226
  // filter/confirmation/compose handlers so a prompt opened from inside
16407
17227
  // any of those still captures focus cleanly.
16408
17228
  if (state.inputPrompt) {
17229
+ const isMultiline = Boolean(state.inputPrompt.multiline);
16409
17230
  if (key.escape) {
16410
17231
  return [
16411
17232
  action({ type: 'closeInputPrompt' }),
16412
17233
  action({ type: 'setStatus', value: 'cancelled' }),
16413
17234
  ];
16414
17235
  }
17236
+ // Multi-line prompts (#806): Ctrl+D submits (Unix EOF convention,
17237
+ // mirrors `git commit -m -` and HEREDOC patterns). Plain Enter
17238
+ // inserts a newline so the user can compose review bodies / PR
17239
+ // comments naturally without opening $EDITOR.
17240
+ if (isMultiline && key.ctrl && inputValue === 'd') {
17241
+ return submitInputPrompt(state);
17242
+ }
17243
+ if (isMultiline && key.return) {
17244
+ return [action({ type: 'appendInputPrompt', value: '\n' })];
17245
+ }
16415
17246
  if (key.return) {
16416
- const value = state.inputPrompt.value.trim();
16417
- if (!value) {
16418
- return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
16419
- }
16420
- const id = state.inputPrompt.kind;
16421
- return [
16422
- { type: 'runWorkflowAction', id, payload: value },
16423
- action({ type: 'closeInputPrompt' }),
16424
- ];
17247
+ return submitInputPrompt(state);
16425
17248
  }
16426
17249
  if (key.backspace || key.delete) {
16427
17250
  return [action({ type: 'backspaceInputPrompt' })];
@@ -16683,6 +17506,56 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16683
17506
  action({ type: 'setStatus', value: 'jumped to worktrees' }),
16684
17507
  ];
16685
17508
  }
17509
+ // `gp` jumps to the dedicated pull-request action panel (#783).
17510
+ // Lowercase `p` matches the pattern of other navigation chords
17511
+ // (gh / gs / gd / gc / gb / gt / gz / gw). The panel renders the
17512
+ // current branch's PR via `gh pr view --json` enriched fields and
17513
+ // exposes m / x / a / R / c action keys scoped to the view.
17514
+ if (state.pendingKey === 'g' && inputValue === 'p') {
17515
+ return [
17516
+ action({ type: 'pushView', value: 'pull-request' }),
17517
+ action({ type: 'setStatus', value: 'jumped to pull request' }),
17518
+ ];
17519
+ }
17520
+ // `gH` chord: apply the cursored hunk to the index (`git apply
17521
+ // --cached`). Sibling of bare `H` which targets the worktree.
17522
+ // Discoverable via the footer hint on diff views and the help
17523
+ // overlay; the explicit chord keeps `H` (single keystroke) for
17524
+ // the more common worktree case.
17525
+ if (state.pendingKey === 'g' && inputValue === 'H') {
17526
+ const events = buildApplyHunkEvents(state, context, 'index');
17527
+ if (events.length) {
17528
+ return [action({ type: 'setPendingKey', value: undefined }), ...events];
17529
+ }
17530
+ return [
17531
+ action({ type: 'setPendingKey', value: undefined }),
17532
+ action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
17533
+ ];
17534
+ }
17535
+ // `gT` chord: create a lightweight tag at the cursored commit on the
17536
+ // history view. Bare `T` is taken (delete-tag on the tags view) so we
17537
+ // use the chord. Mirrors `gH` exactly — uppercase letter after the
17538
+ // `g` chord prefix, distinct from the lowercase `gt` chord which
17539
+ // jumps to the tags view. The prompt is the affirmative gate.
17540
+ if (state.pendingKey === 'g' && inputValue === 'T') {
17541
+ if (state.activeView === 'history' &&
17542
+ state.focus === 'commits' &&
17543
+ state.filteredCommits.length > 0 &&
17544
+ !state.pendingCommitFocused) {
17545
+ return [
17546
+ action({ type: 'setPendingKey', value: undefined }),
17547
+ action({
17548
+ type: 'openInputPrompt',
17549
+ kind: 'create-tag-here',
17550
+ label: 'New tag name (at cursored commit)',
17551
+ }),
17552
+ ];
17553
+ }
17554
+ return [
17555
+ action({ type: 'setPendingKey', value: undefined }),
17556
+ action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
17557
+ ];
17558
+ }
16686
17559
  if (inputValue === 'g') {
16687
17560
  if (state.pendingKey === 'g') {
16688
17561
  return [
@@ -16692,6 +17565,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16692
17565
  }
16693
17566
  return [action({ type: 'setPendingKey', value: 'g' })];
16694
17567
  }
17568
+ // `d` on the diff view toggles between unified and side-by-side split
17569
+ // rendering (#785). Scoped to the diff view so the letter stays free
17570
+ // for other surfaces. The chord branch above already claimed `gd`,
17571
+ // so by the time we get here `pendingKey` is not `g`.
17572
+ if (inputValue === 'd' && state.activeView === 'diff') {
17573
+ const next = state.diffViewMode === 'unified' ? 'split' : 'unified';
17574
+ return [
17575
+ action({ type: 'toggleDiffViewMode' }),
17576
+ action({
17577
+ type: 'setStatus',
17578
+ value: next === 'split'
17579
+ ? 'Switched to side-by-side diff'
17580
+ : 'Switched to unified diff',
17581
+ }),
17582
+ ];
17583
+ }
16695
17584
  if (inputValue === '\\') {
16696
17585
  return [action({ type: 'toggleGraph' })];
16697
17586
  }
@@ -16714,10 +17603,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16714
17603
  return [{ type: 'refreshContext' }];
16715
17604
  }
16716
17605
  if (inputValue === 's') {
16717
- if (state.activeView === 'branches') {
17606
+ if (isBranchActionTarget(state)) {
16718
17607
  return [action({ type: 'cycleBranchSort' })];
16719
17608
  }
16720
- if (state.activeView === 'tags') {
17609
+ if (isTagActionTarget(state)) {
16721
17610
  return [action({ type: 'cycleTagSort' })];
16722
17611
  }
16723
17612
  // Falls through so other views (history/status/diff/compose/stash) still
@@ -16748,6 +17637,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16748
17637
  hunkOffsets: context.commitDiffHunkOffsets,
16749
17638
  })];
16750
17639
  }
17640
+ // Inspector focused: cycle the inspector tab. The renderer only
17641
+ // honors the tab field on short terminals (where the inspector
17642
+ // collapses into a tabbed layout), but we let the user pre-set
17643
+ // their preference on tall terminals too.
17644
+ if (state.focus === 'detail') {
17645
+ return [action({ type: 'cycleInspectorTab', delta: -1 })];
17646
+ }
16751
17647
  return [action({ type: 'previousSidebarTab' })];
16752
17648
  }
16753
17649
  if (inputValue === ']') {
@@ -16772,6 +17668,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16772
17668
  hunkOffsets: context.commitDiffHunkOffsets,
16773
17669
  })];
16774
17670
  }
17671
+ if (state.focus === 'detail') {
17672
+ return [action({ type: 'cycleInspectorTab', delta: 1 })];
17673
+ }
16775
17674
  return [action({ type: 'nextSidebarTab' })];
16776
17675
  }
16777
17676
  // Status surface intercepts 1/2/3 before the sidebar-tab numeric
@@ -16788,6 +17687,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16788
17687
  if (key.tab) {
16789
17688
  return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
16790
17689
  }
17690
+ // ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
17691
+ // Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
17692
+ // vertical axis (↑/↓ below) is "within the active tab's items".
17693
+ // [/] still works as a keyboard alternative for users who prefer
17694
+ // non-arrow keys.
17695
+ if (key.leftArrow && state.focus === 'sidebar') {
17696
+ return [action({ type: 'previousSidebarTab' })];
17697
+ }
17698
+ if (key.rightArrow && state.focus === 'sidebar') {
17699
+ return [action({ type: 'nextSidebarTab' })];
17700
+ }
16791
17701
  if (key.upArrow || inputValue === 'k') {
16792
17702
  if (state.focus === 'detail' && context.detailFileCount) {
16793
17703
  return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
@@ -16817,16 +17727,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16817
17727
  previewLineCount: context.previewLineCount,
16818
17728
  })];
16819
17729
  }
16820
- if (state.activeView === 'branches' && context.branchCount) {
17730
+ if (isBranchActionTarget(state) && context.branchCount) {
16821
17731
  return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
16822
17732
  }
16823
- if (state.activeView === 'tags' && context.tagCount) {
17733
+ if (isTagActionTarget(state) && context.tagCount) {
16824
17734
  return [action({ type: 'moveTag', delta: -1, count: context.tagCount })];
16825
17735
  }
16826
- if (state.activeView === 'stash' && context.stashCount) {
17736
+ if (isStashActionTarget(state) && context.stashCount) {
16827
17737
  return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
16828
17738
  }
16829
- if (state.activeView === 'worktrees' && context.worktreeListCount) {
17739
+ if (isWorktreeActionTarget(state) && context.worktreeListCount) {
16830
17740
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
16831
17741
  }
16832
17742
  if (state.activeView === 'history' &&
@@ -16836,6 +17746,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16836
17746
  context.worktreeDirty) {
16837
17747
  return [action({ type: 'focusPendingCommit' })];
16838
17748
  }
17749
+ // Sidebar fallback: when no entity claim above succeeds (status
17750
+ // tab or empty content tab), ↑ falls through to cycling sidebar
17751
+ // tabs so the user always has a way to navigate. With ←/→ above
17752
+ // already handling tab switching, this is mostly a vim-style
17753
+ // safety net for `k`.
16839
17754
  return [
16840
17755
  action(state.focus === 'sidebar'
16841
17756
  ? { type: 'previousSidebarTab' }
@@ -16870,16 +17785,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16870
17785
  previewLineCount: context.previewLineCount,
16871
17786
  })];
16872
17787
  }
16873
- if (state.activeView === 'branches' && context.branchCount) {
17788
+ if (isBranchActionTarget(state) && context.branchCount) {
16874
17789
  return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
16875
17790
  }
16876
- if (state.activeView === 'tags' && context.tagCount) {
17791
+ if (isTagActionTarget(state) && context.tagCount) {
16877
17792
  return [action({ type: 'moveTag', delta: 1, count: context.tagCount })];
16878
17793
  }
16879
- if (state.activeView === 'stash' && context.stashCount) {
17794
+ if (isStashActionTarget(state) && context.stashCount) {
16880
17795
  return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
16881
17796
  }
16882
- if (state.activeView === 'worktrees' && context.worktreeListCount) {
17797
+ if (isWorktreeActionTarget(state) && context.worktreeListCount) {
16883
17798
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
16884
17799
  }
16885
17800
  return [
@@ -16983,30 +17898,42 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16983
17898
  }
16984
17899
  }
16985
17900
  // Enter on a sidebar tab drills into the corresponding promoted view
16986
- // (status / branches / tags / stash). Sits above the per-view Enter
16987
- // handlers so a sidebar-focused Enter never fires checkout-branch /
16988
- // navigateOpenDiffForCommit / etc. against the (hidden) selection in
16989
- // the active tab.
17901
+ // (status / branches / tags / stash) but only when the sidebar tab
17902
+ // either has no per-entity Enter handler defined (status, tags,
17903
+ // worktrees) or has zero items (so the dedicated view's empty-state
17904
+ // tells the user what to do next).
16990
17905
  //
16991
- // The Enter also moves focus out of the sidebar into the newly opened
16992
- // list otherwise ↑/↓ keep cycling sidebar tabs instead of navigating
16993
- // inside the just-opened view, which made the drill-in feel half-done.
17906
+ // When the sidebar IS focused on a content tab WITH items, this
17907
+ // handler defers to the per-entity Enter below (checkout-branch for
17908
+ // branches, navigateOpenDiffForStash for stashes) so the user can
17909
+ // act on the cursored item without leaving the workstation view —
17910
+ // the in-sidebar selection win from #791 follow-up.
17911
+ //
17912
+ // The drill-in moves focus out of the sidebar into the newly opened
17913
+ // list — otherwise ↑/↓ keep navigating the sidebar instead of the
17914
+ // just-opened view, which made the drill-in feel half-done.
16994
17915
  if (key.return && state.focus === 'sidebar') {
16995
- const tabToView = {
16996
- status: 'status',
16997
- branches: 'branches',
16998
- tags: 'tags',
16999
- stashes: 'stash',
17000
- worktrees: 'worktrees',
17001
- };
17002
- const target = tabToView[state.sidebarTab];
17003
- if (target) {
17004
- return [
17005
- action({ type: 'pushView', value: target }),
17006
- action({ type: 'setFocus', value: 'commits' }),
17007
- ];
17916
+ const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
17917
+ const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
17918
+ sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
17919
+ if (!hasInSidebarPrimaryAction) {
17920
+ const tabToView = {
17921
+ status: 'status',
17922
+ branches: 'branches',
17923
+ tags: 'tags',
17924
+ stashes: 'stash',
17925
+ worktrees: 'worktrees',
17926
+ };
17927
+ const target = tabToView[state.sidebarTab];
17928
+ if (target) {
17929
+ return [
17930
+ action({ type: 'pushView', value: target }),
17931
+ action({ type: 'setFocus', value: 'commits' }),
17932
+ ];
17933
+ }
17934
+ return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
17008
17935
  }
17009
- return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
17936
+ // Fall through per-entity Enter handler below claims the keystroke.
17010
17937
  }
17011
17938
  if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
17012
17939
  return [action({
@@ -17015,8 +17942,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17015
17942
  })];
17016
17943
  }
17017
17944
  // Enter on a branch row checks the branch out. Non-destructive workflow
17018
- // action — no confirmation prompt.
17019
- if (key.return && state.activeView === 'branches' && state.focus === 'commits' && context.branchCount) {
17945
+ // action — no confirmation prompt. Fires from either the dedicated
17946
+ // branches view or from the sidebar when the branches tab is focused
17947
+ // with items.
17948
+ if (key.return && isBranchActionTarget(state) && context.branchCount) {
17020
17949
  return [{ type: 'runWorkflowAction', id: 'checkout-branch' }];
17021
17950
  }
17022
17951
  // `+` opens a create-branch / create-tag prompt depending on context.
@@ -17043,32 +17972,33 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17043
17972
  }
17044
17973
  // Per-view stash actions: `a` apply (keep the stash), `p` pop (apply
17045
17974
  // then drop). Drop is the existing destructive `X` workflow which
17046
- // routes through the y-confirm path. Scoped to the stash view so the
17047
- // letters stay free elsewhere.
17048
- if (inputValue === 'a' && state.activeView === 'stash' && context.stashCount) {
17975
+ // routes through the y-confirm path. Scoped to the stash target so
17976
+ // the letters stay free elsewhere — the target predicate also fires
17977
+ // when the sidebar's stashes tab is focused with items.
17978
+ if (inputValue === 'a' && isStashActionTarget(state) && context.stashCount) {
17049
17979
  return [{ type: 'runWorkflowAction', id: 'apply-stash' }];
17050
17980
  }
17051
- if (inputValue === 'p' && state.activeView === 'stash' && context.stashCount) {
17981
+ if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
17052
17982
  return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
17053
17983
  }
17054
17984
  // Per-view tag action: `P` pushes the selected tag to origin. Letter
17055
- // is scoped to the tags surface so it doesn't collide with `p` for
17985
+ // is scoped to the tags target so it doesn't collide with `p` for
17056
17986
  // pop-stash. Note: this also takes precedence over the global
17057
17987
  // push-current-branch workflow's `P` key.
17058
- if (inputValue === 'P' && state.activeView === 'tags' && context.tagCount) {
17988
+ if (inputValue === 'P' && isTagActionTarget(state) && context.tagCount) {
17059
17989
  return [{ type: 'runWorkflowAction', id: 'push-tag' }];
17060
17990
  }
17061
17991
  // Per-view branches actions: `R` renames the selected branch, `u`
17062
17992
  // sets its upstream. Both open the input prompt so the user can type
17063
17993
  // the new value. Pre-fills are handled by the prompt's `initial`.
17064
- if (inputValue === 'R' && state.activeView === 'branches' && context.branchCount) {
17994
+ if (inputValue === 'R' && isBranchActionTarget(state) && context.branchCount) {
17065
17995
  return [action({
17066
17996
  type: 'openInputPrompt',
17067
17997
  kind: 'rename-branch',
17068
17998
  label: 'Rename branch to',
17069
17999
  })];
17070
18000
  }
17071
- if (inputValue === 'u' && state.activeView === 'branches' && context.branchCount) {
18001
+ if (inputValue === 'u' && isBranchActionTarget(state) && context.branchCount) {
17072
18002
  return [action({
17073
18003
  type: 'openInputPrompt',
17074
18004
  kind: 'set-upstream',
@@ -17076,11 +18006,48 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17076
18006
  })];
17077
18007
  }
17078
18008
  // Per-view tag action: `R` deletes the tag from the remote (after
17079
- // confirmation). Scoped per-view so this letter is free elsewhere
17080
- // (especially the `R` rename binding on the branches view).
17081
- if (inputValue === 'R' && state.activeView === 'tags' && context.tagCount) {
18009
+ // confirmation). Scoped per-target so this letter is free elsewhere
18010
+ // (especially the `R` rename binding on the branches target).
18011
+ if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
17082
18012
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
17083
18013
  }
18014
+ // #783 — full PR action panel keys, scoped to the pull-request view.
18015
+ // All five wrap a `gh pr <verb>` invocation; merge / request-changes /
18016
+ // comment open prompts first, the rest route through the y-confirm
18017
+ // path because they're irreversible (or near-irreversible).
18018
+ if (inputValue === 'm' && state.activeView === 'pull-request') {
18019
+ return [action({
18020
+ type: 'openInputPrompt',
18021
+ kind: 'pr-merge-strategy',
18022
+ label: 'Merge strategy (merge / squash / rebase)',
18023
+ })];
18024
+ }
18025
+ if (inputValue === 'x' && state.activeView === 'pull-request') {
18026
+ return [action({ type: 'setPendingConfirmation', value: 'close-pr' })];
18027
+ }
18028
+ if (inputValue === 'a' && state.activeView === 'pull-request') {
18029
+ return [action({ type: 'setPendingConfirmation', value: 'approve-pr' })];
18030
+ }
18031
+ if (inputValue === 'R' && state.activeView === 'pull-request') {
18032
+ // Free-form review body — multi-line so the reviewer can structure
18033
+ // their feedback naturally without opening $EDITOR (#806).
18034
+ return [action({
18035
+ type: 'openInputPrompt',
18036
+ kind: 'pr-request-changes',
18037
+ label: 'Request changes — review body (Enter newline · Ctrl+D submit)',
18038
+ multiline: true,
18039
+ })];
18040
+ }
18041
+ if (inputValue === 'c' && state.activeView === 'pull-request') {
18042
+ // Free-form comment body — multi-line for the same reason as
18043
+ // pr-request-changes.
18044
+ return [action({
18045
+ type: 'openInputPrompt',
18046
+ kind: 'pr-comment',
18047
+ label: 'Comment body (Enter newline · Ctrl+D submit)',
18048
+ multiline: true,
18049
+ })];
18050
+ }
17084
18051
  // Global stash hotkey: `S` opens a stash-message prompt and
17085
18052
  // `createStash` runs once submitted. Available everywhere there's
17086
18053
  // not a more modal handler in front of it.
@@ -17138,6 +18105,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17138
18105
  payload: `${context.commitDiffSelectedSha} ${context.commitDiffSelectedPath}`,
17139
18106
  })];
17140
18107
  }
18108
+ // `H` on a commit-diff or stash-diff explore extracts the hunk under
18109
+ // the cursor and applies it to the working tree (`git apply`). The
18110
+ // sibling `gH` chord targets the index (`git apply --cached`). Both
18111
+ // bypass the y-confirm path because `git apply` is non-destructive
18112
+ // (it'll fail loudly on conflict and `git apply -R` undoes a clean
18113
+ // apply).
18114
+ if (inputValue === 'H') {
18115
+ const events = buildApplyHunkEvents(state, context, 'worktree');
18116
+ if (events.length) {
18117
+ return events;
18118
+ }
18119
+ if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
18120
+ return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
18121
+ }
18122
+ }
17141
18123
  // `c` on the history view cherry-picks the full selected commit on
17142
18124
  // top of the current branch. Routed through the y-confirm flow since
17143
18125
  // it can produce conflicts and is a real working-tree mutation.
@@ -17148,6 +18130,62 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17148
18130
  !state.pendingCommitFocused) {
17149
18131
  return [action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' })];
17150
18132
  }
18133
+ // `R` reverts the cursored commit by adding an inverse commit on top
18134
+ // of HEAD. Same y-confirm gate as cherry-pick — non-rewriting but
18135
+ // still a real mutation.
18136
+ if (inputValue === 'R' &&
18137
+ state.activeView === 'history' &&
18138
+ state.focus === 'commits' &&
18139
+ state.filteredCommits.length > 0 &&
18140
+ !state.pendingCommitFocused) {
18141
+ return [action({ type: 'setPendingConfirmation', value: 'revert-commit' })];
18142
+ }
18143
+ // `Z` resets the current branch tip to the cursored commit. Opens a
18144
+ // mode prompt (soft / mixed / hard) instead of jumping straight to
18145
+ // confirmation because the choice changes the destructiveness
18146
+ // dramatically — `--hard` discards working-tree changes. The prompt
18147
+ // submission special-cases `kind === 'reset-mode'` to forward the
18148
+ // mode through `reset-to-commit` (see prompt-submit handler above).
18149
+ // No `initial` value: existing prompts append to initial rather than
18150
+ // replacing it, which would surprise the user typing the mode.
18151
+ if (inputValue === 'Z' &&
18152
+ state.activeView === 'history' &&
18153
+ state.focus === 'commits' &&
18154
+ state.filteredCommits.length > 0 &&
18155
+ !state.pendingCommitFocused) {
18156
+ return [action({
18157
+ type: 'openInputPrompt',
18158
+ kind: 'reset-mode',
18159
+ label: 'Reset mode (soft / mixed / hard)',
18160
+ })];
18161
+ }
18162
+ // `i` (lowercase) starts an interactive rebase from the cursored
18163
+ // commit's parent. Lowercase keeps the existing global `I`
18164
+ // ai-commit-summary workflow reachable on the history view; `i`
18165
+ // also matches the `git rebase -i` flag mnemonic.
18166
+ if (inputValue === 'i' &&
18167
+ state.activeView === 'history' &&
18168
+ state.focus === 'commits' &&
18169
+ state.filteredCommits.length > 0 &&
18170
+ !state.pendingCommitFocused) {
18171
+ return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
18172
+ }
18173
+ // `B` opens a create-branch prompt rooted at the cursored commit
18174
+ // (`git branch <name> <sha>` — does NOT switch to the new branch).
18175
+ // The prompt itself is the affirmative gate, so no separate y-confirm.
18176
+ // Bare uppercase `B` since the lowercase `b` is used by the `gb`
18177
+ // chord prefix and we want a single keystroke for this common op.
18178
+ if (inputValue === 'B' &&
18179
+ state.activeView === 'history' &&
18180
+ state.focus === 'commits' &&
18181
+ state.filteredCommits.length > 0 &&
18182
+ !state.pendingCommitFocused) {
18183
+ return [action({
18184
+ type: 'openInputPrompt',
18185
+ kind: 'create-branch-here',
18186
+ label: 'New branch name (at cursored commit)',
18187
+ })];
18188
+ }
17151
18189
  // `y` / `Y` yank the contextually relevant identifier from the active
17152
18190
  // view to the system clipboard:
17153
18191
  // history → cursored commit hash (Y for short hash)
@@ -17163,13 +18201,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17163
18201
  if (state.activeView === 'history' && state.filteredCommits.length > 0) {
17164
18202
  return [{ type: 'yankFromActiveView', short }];
17165
18203
  }
17166
- if (state.activeView === 'branches' && context.branchCount) {
18204
+ if (isBranchActionTarget(state) && context.branchCount) {
17167
18205
  return [{ type: 'yankFromActiveView' }];
17168
18206
  }
17169
- if (state.activeView === 'tags' && context.tagCount) {
18207
+ if (isTagActionTarget(state) && context.tagCount) {
17170
18208
  return [{ type: 'yankFromActiveView' }];
17171
18209
  }
17172
- if (state.activeView === 'stash' && context.stashCount && context.stashSelectedRef) {
18210
+ if (isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
17173
18211
  return [{ type: 'yankFromActiveView' }];
17174
18212
  }
17175
18213
  if (state.activeView === 'status' && context.worktreeSelectedPath) {
@@ -17187,8 +18225,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17187
18225
  // Enter on a stash row pushes the diff view scoped to that stash.
17188
18226
  // The runtime loads `git stash show -p <ref>` once the view is
17189
18227
  // active. The stash ref is passed via the action so we don't need a
17190
- // context lookup here.
17191
- if (key.return && state.activeView === 'stash' && state.focus === 'commits' && context.stashCount && context.stashSelectedRef) {
18228
+ // context lookup here. Fires from either the dedicated stash view or
18229
+ // from the sidebar when the stashes tab is focused with items.
18230
+ if (key.return && isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
17192
18231
  return [action({
17193
18232
  type: 'navigateOpenDiffForStash',
17194
18233
  ref: context.stashSelectedRef,
@@ -17249,6 +18288,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17249
18288
  * fall back to "already seen" so we never block startup.
17250
18289
  */
17251
18290
  const MARKER_BASENAME = 'onboarding.seen';
18291
+ function resolveCacheDir$2() {
18292
+ const xdg = process.env.XDG_CACHE_HOME;
18293
+ if (xdg && xdg.trim().length > 0) {
18294
+ return path__namespace$1.join(xdg, 'coco');
18295
+ }
18296
+ return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
18297
+ }
18298
+ function getOnboardingMarkerPath() {
18299
+ return path__namespace$1.join(resolveCacheDir$2(), MARKER_BASENAME);
18300
+ }
18301
+ function hasSeenOnboarding() {
18302
+ try {
18303
+ return fs__namespace$1.existsSync(getOnboardingMarkerPath());
18304
+ }
18305
+ catch {
18306
+ // If we can't even stat the path (sandboxed env, etc.), treat the
18307
+ // user as "seen" so we don't keep showing a panel they can never
18308
+ // dismiss persistently.
18309
+ return true;
18310
+ }
18311
+ }
18312
+ function markOnboardingSeen() {
18313
+ const markerPath = getOnboardingMarkerPath();
18314
+ try {
18315
+ fs__namespace$1.mkdirSync(path__namespace$1.dirname(markerPath), { recursive: true });
18316
+ fs__namespace$1.writeFileSync(markerPath, '');
18317
+ }
18318
+ catch {
18319
+ // Best-effort persistence; swallow.
18320
+ }
18321
+ }
18322
+
18323
+ /**
18324
+ * Persist the user's preferred diff view mode (unified vs side-by-side
18325
+ * split — #785) per repo. Mirrors `inkSidebarPersistence.ts` so the
18326
+ * cache layout, error model, and key derivation stay consistent across
18327
+ * settings: best-effort, XDG-friendly, no PII in the cache filename.
18328
+ */
18329
+ const VALID_MODES = ['unified', 'split'];
17252
18330
  function resolveCacheDir$1() {
17253
18331
  const xdg = process.env.XDG_CACHE_HOME;
17254
18332
  if (xdg && xdg.trim().length > 0) {
@@ -17256,25 +18334,32 @@ function resolveCacheDir$1() {
17256
18334
  }
17257
18335
  return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
17258
18336
  }
17259
- function getOnboardingMarkerPath() {
17260
- return path__namespace$1.join(resolveCacheDir$1(), MARKER_BASENAME);
18337
+ function repoKey$1(repoPath) {
18338
+ // sha1 is used here as a non-security cache-key derivation — we just
18339
+ // need a deterministic short identifier for the marker filename. No
18340
+ // PII or auth context is hashed.
18341
+ // DevSkim: ignore DS126858
18342
+ return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
17261
18343
  }
17262
- function hasSeenOnboarding() {
18344
+ function getDiffViewModeMarkerPath(repoPath) {
18345
+ return path__namespace$1.join(resolveCacheDir$1(), `diff-view-mode.${repoKey$1(repoPath)}`);
18346
+ }
18347
+ function getSavedDiffViewMode(repoPath) {
17263
18348
  try {
17264
- return fs__namespace$1.existsSync(getOnboardingMarkerPath());
18349
+ const raw = fs__namespace$1.readFileSync(getDiffViewModeMarkerPath(repoPath), 'utf8').trim();
18350
+ return VALID_MODES.includes(raw)
18351
+ ? raw
18352
+ : undefined;
17265
18353
  }
17266
18354
  catch {
17267
- // If we can't even stat the path (sandboxed env, etc.), treat the
17268
- // user as "seen" so we don't keep showing a panel they can never
17269
- // dismiss persistently.
17270
- return true;
18355
+ return undefined;
17271
18356
  }
17272
18357
  }
17273
- function markOnboardingSeen() {
17274
- const markerPath = getOnboardingMarkerPath();
18358
+ function saveDiffViewMode(repoPath, mode) {
18359
+ const marker = getDiffViewModeMarkerPath(repoPath);
17275
18360
  try {
17276
- fs__namespace$1.mkdirSync(path__namespace$1.dirname(markerPath), { recursive: true });
17277
- fs__namespace$1.writeFileSync(markerPath, '');
18361
+ fs__namespace$1.mkdirSync(path__namespace$1.dirname(marker), { recursive: true });
18362
+ fs__namespace$1.writeFileSync(marker, mode);
17278
18363
  }
17279
18364
  catch {
17280
18365
  // Best-effort persistence; swallow.
@@ -17340,6 +18425,144 @@ function saveSidebarTab(repoPath, tab) {
17340
18425
  }
17341
18426
  }
17342
18427
 
18428
+ /**
18429
+ * Pair-alignment helper for the side-by-side diff view (#785).
18430
+ *
18431
+ * Takes the unified-diff line array that the renderer already paints (one
18432
+ * line per element, the leading character drives `+`/`-`/context coloring)
18433
+ * and re-shapes it into two-column rows the split renderer can lay out
18434
+ * without further parsing. Pure / synchronous so it can be exercised from
18435
+ * tests without spinning up Ink.
18436
+ *
18437
+ * Algorithm:
18438
+ * 1. Walk lines in order. `@@` headers seed a new hunk and reset the
18439
+ * `oldLineNo` / `newLineNo` cursors from the header range.
18440
+ * 2. Inside a hunk, group the consecutive runs of `-` and `+` lines that
18441
+ * follow each other. Each run of removals + the immediately-following
18442
+ * run of additions forms a "change block" that pairs up element-wise:
18443
+ * row[i] = { left: removals[i], right: additions[i] }. When one side
18444
+ * is shorter, pad with `kind: 'empty'` rows so the columns stay
18445
+ * aligned.
18446
+ * 3. Context lines emit as a paired row with the same text on both
18447
+ * sides and the synthesized line numbers from each cursor.
18448
+ * 4. Diff metadata (`diff `, `index `, `--- `, `+++ `, etc.) emit as
18449
+ * `kind: 'header'` rows so the split view still has a section break.
18450
+ * 5. A context line that interrupts a change block forces the in-flight
18451
+ * block to flush before the context row is emitted — pairs are never
18452
+ * drawn across context boundaries (matches lazygit / fugitive
18453
+ * behavior, and is what the issue specifies).
18454
+ *
18455
+ * Long lines are not wrapped here — the renderer truncates per column at
18456
+ * paint time so this helper stays pure and trivially testable.
18457
+ */
18458
+ const EMPTY_LEFT = { text: '', kind: 'empty' };
18459
+ const EMPTY_RIGHT = { text: '', kind: 'empty' };
18460
+ /**
18461
+ * Parse the start line numbers out of an `@@ -A,B +C,D @@` header. Returns
18462
+ * `[oldStart, newStart]`; either falls back to 1 when the header is
18463
+ * malformed (which only happens with synthetic / hand-crafted patches).
18464
+ */
18465
+ function parseHunkHeader(line) {
18466
+ const match = /@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(line);
18467
+ if (!match) {
18468
+ return [1, 1];
18469
+ }
18470
+ return [Number(match[1]) || 1, Number(match[2]) || 1];
18471
+ }
18472
+ function isDiffHeader(line) {
18473
+ return (line.startsWith('diff ') ||
18474
+ line.startsWith('index ') ||
18475
+ line.startsWith('--- ') ||
18476
+ line.startsWith('+++ ') ||
18477
+ line.startsWith('similarity ') ||
18478
+ line.startsWith('rename ') ||
18479
+ line.startsWith('copy ') ||
18480
+ line.startsWith('new file ') ||
18481
+ line.startsWith('deleted file ') ||
18482
+ line.startsWith('old mode ') ||
18483
+ line.startsWith('new mode ') ||
18484
+ line.startsWith('Binary files '));
18485
+ }
18486
+ /**
18487
+ * Flush a pending change block (removals + additions accumulated from a
18488
+ * contiguous `-`/`+` run) into paired rows. Pads the shorter side with
18489
+ * empty placeholders so columns stay aligned.
18490
+ */
18491
+ function flushChangeBlock(removals, additions, rows) {
18492
+ const max = Math.max(removals.length, additions.length);
18493
+ for (let i = 0; i < max; i++) {
18494
+ const left = removals[i] || EMPTY_LEFT;
18495
+ const right = additions[i] || EMPTY_RIGHT;
18496
+ rows.push({ left, right });
18497
+ }
18498
+ removals.length = 0;
18499
+ additions.length = 0;
18500
+ }
18501
+ function buildSplitDiffRows(unifiedLines) {
18502
+ const rows = [];
18503
+ let oldLineNo = 0;
18504
+ let newLineNo = 0;
18505
+ let inHunk = false;
18506
+ const removals = [];
18507
+ const additions = [];
18508
+ const flushHeader = (text) => {
18509
+ flushChangeBlock(removals, additions, rows);
18510
+ rows.push({
18511
+ left: { text, kind: 'header' },
18512
+ right: { text, kind: 'header' },
18513
+ });
18514
+ };
18515
+ for (const raw of unifiedLines) {
18516
+ if (raw.startsWith('@@')) {
18517
+ flushChangeBlock(removals, additions, rows);
18518
+ const [oldStart, newStart] = parseHunkHeader(raw);
18519
+ oldLineNo = oldStart;
18520
+ newLineNo = newStart;
18521
+ inHunk = true;
18522
+ rows.push({
18523
+ left: { text: raw, kind: 'header' },
18524
+ right: { text: raw, kind: 'header' },
18525
+ });
18526
+ continue;
18527
+ }
18528
+ if (!inHunk || isDiffHeader(raw)) {
18529
+ flushHeader(raw);
18530
+ continue;
18531
+ }
18532
+ if (raw.startsWith('-')) {
18533
+ removals.push({
18534
+ text: raw.slice(1),
18535
+ lineNumber: oldLineNo,
18536
+ kind: 'remove',
18537
+ });
18538
+ oldLineNo += 1;
18539
+ continue;
18540
+ }
18541
+ if (raw.startsWith('+')) {
18542
+ additions.push({
18543
+ text: raw.slice(1),
18544
+ lineNumber: newLineNo,
18545
+ kind: 'add',
18546
+ });
18547
+ newLineNo += 1;
18548
+ continue;
18549
+ }
18550
+ // Context line (or `` marker, which we
18551
+ // treat like a context row so it lands on both sides — readers
18552
+ // expect to see it in either column).
18553
+ flushChangeBlock(removals, additions, rows);
18554
+ const text = raw.startsWith(' ') ? raw.slice(1) : raw;
18555
+ rows.push({
18556
+ left: { text, lineNumber: oldLineNo, kind: 'context' },
18557
+ right: { text, lineNumber: newLineNo, kind: 'context' },
18558
+ });
18559
+ oldLineNo += 1;
18560
+ newLineNo += 1;
18561
+ }
18562
+ flushChangeBlock(removals, additions, rows);
18563
+ return rows;
18564
+ }
18565
+
17343
18566
  /**
17344
18567
  * Promoted-view selection rectification on filter changes (P4.5).
17345
18568
  *
@@ -17589,10 +18812,25 @@ const LOG_INK_MIN_COLUMNS = 80;
17589
18812
  const LOG_INK_MIN_ROWS = 24;
17590
18813
  const LOG_INK_DEFAULT_COLUMNS = 120;
17591
18814
  const LOG_INK_DEFAULT_ROWS = 40;
18815
+ /**
18816
+ * Terminal-row threshold below which the inspector switches to a
18817
+ * tabbed layout (commit-detail vs actions). Picked empirically: at
18818
+ * 28 rows the inspector's full stack (~30 rows when fully populated)
18819
+ * starts clipping the actions section; below that, the tabbed mode
18820
+ * gives both views their own air.
18821
+ */
18822
+ const INSPECTOR_TABBED_BELOW_ROWS = 28;
17592
18823
  function getLogInkLayout(input) {
17593
18824
  const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
17594
18825
  const rows = input.rows || LOG_INK_DEFAULT_ROWS;
17595
- const detailWidth = Math.max(30, Math.min(56, Math.floor(columns * 0.34)));
18826
+ // Inspector width at rest 20-32 cells (~22% of width), focused
18827
+ // 36-60 cells (~40% of width). Narrow rest state keeps the commit
18828
+ // graph dominant; focus expansion gives the inspector room for long
18829
+ // commit bodies / file lists / action labels. Mirrors the sidebar
18830
+ // pattern (sidebarFocused above): instant transition per render.
18831
+ const detailWidth = input.inspectorFocused
18832
+ ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
18833
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
17596
18834
  // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
17597
18835
  // (~36% of width). The transition is instant per render — focus tab to
17598
18836
  // expand, focus away to collapse.
@@ -17607,6 +18845,7 @@ function getLogInkLayout(input) {
17607
18845
  rows,
17608
18846
  sidebarWidth,
17609
18847
  tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
18848
+ inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
17610
18849
  };
17611
18850
  }
17612
18851
 
@@ -17761,7 +19000,8 @@ function createLogInkTheme(options = {}) {
17761
19000
  /**
17762
19001
  * Format a branch's relationship to its upstream.
17763
19002
  * - no upstream → "no upstream"
17764
- * - even → "even with <upstream>"
19003
+ * - even → "" (the boring default — keep the row tight; the row
19004
+ * marker already encodes "synced")
17765
19005
  * - divergent → "↑<ahead> ↓<behind> <upstream>" (only the non-zero side
17766
19006
  * is rendered so the line stays tight). ASCII mode falls back to the
17767
19007
  * legacy `+N/-N` form.
@@ -17771,7 +19011,7 @@ function formatBranchDivergence(branch, options = {}) {
17771
19011
  return 'no upstream';
17772
19012
  }
17773
19013
  if (branch.ahead === 0 && branch.behind === 0) {
17774
- return `even with ${branch.upstream}`;
19014
+ return '';
17775
19015
  }
17776
19016
  if (options.ascii) {
17777
19017
  return `+${branch.ahead}/-${branch.behind} ${branch.upstream}`;
@@ -17785,14 +19025,76 @@ function formatBranchDivergence(branch, options = {}) {
17785
19025
  }
17786
19026
  /**
17787
19027
  * Single-cell marker shown to the left of a branch name in lists.
17788
- * `*` = current, `◌` = no upstream (detached from a remote), space otherwise.
19028
+ *
19029
+ * - `*` — current branch (regardless of remote state)
19030
+ * - `◌` — no upstream
19031
+ * - `≡` — has upstream + synced (ahead === 0 && behind === 0)
19032
+ * - `↕` — has upstream + diverged (any non-zero ahead/behind)
19033
+ * - ` ` — fallback / no info
19034
+ *
19035
+ * ASCII fallbacks (legible without box-drawing/arrow glyphs):
19036
+ * - `?` for "no upstream", `=` for synced, `~` for diverged.
17789
19037
  */
17790
19038
  function branchRowMarker(branch, options = {}) {
17791
19039
  if (branch.current)
17792
19040
  return '*';
17793
19041
  if (!branch.upstream)
17794
19042
  return options.ascii ? '?' : '◌';
17795
- return ' ';
19043
+ const ahead = branch.ahead ?? 0;
19044
+ const behind = branch.behind ?? 0;
19045
+ if (ahead === 0 && behind === 0) {
19046
+ return options.ascii ? '=' : '≡';
19047
+ }
19048
+ return options.ascii ? '~' : '↕';
19049
+ }
19050
+ /**
19051
+ * Compact, human-friendly relative timestamp for the branch row.
19052
+ * Inputs:
19053
+ * - `iso` — committer-date in `YYYY-MM-DD` form (as produced by
19054
+ * `for-each-ref` with `committerdate:short`).
19055
+ * - `now` — reference instant; pass it explicitly so callers can pin it
19056
+ * for deterministic tests.
19057
+ *
19058
+ * Outputs (rounded toward the nearest unit):
19059
+ * - `today`, `1d ago`, `2d ago` … up to 13d
19060
+ * - `2w ago` … up to 8w
19061
+ * - `2mo ago` … up to 12mo
19062
+ * - `2y ago` for older
19063
+ * - `''` for malformed inputs (caller renders nothing).
19064
+ *
19065
+ * "in the future" inputs (clock skew, bad data) collapse to `today`.
19066
+ */
19067
+ function formatBranchLastTouched(iso, now) {
19068
+ if (!iso)
19069
+ return '';
19070
+ // Tolerate either `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS…` ISO strings.
19071
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
19072
+ if (!match)
19073
+ return '';
19074
+ const year = Number.parseInt(match[1], 10);
19075
+ const month = Number.parseInt(match[2], 10);
19076
+ const day = Number.parseInt(match[3], 10);
19077
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
19078
+ return '';
19079
+ // Compare at day granularity in UTC so a branch touched "yesterday"
19080
+ // never reads "today" depending on the operator's timezone.
19081
+ const branchUtc = Date.UTC(year, month - 1, day);
19082
+ const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
19083
+ const diffMs = nowUtc - branchUtc;
19084
+ const oneDay = 24 * 60 * 60 * 1000;
19085
+ const days = Math.floor(diffMs / oneDay);
19086
+ if (days <= 0)
19087
+ return 'today';
19088
+ if (days < 14)
19089
+ return `${days}d ago`;
19090
+ const weeks = Math.floor(days / 7);
19091
+ if (weeks < 9)
19092
+ return `${weeks}w ago`;
19093
+ const months = Math.floor(days / 30);
19094
+ if (months < 12)
19095
+ return `${months}mo ago`;
19096
+ const years = Math.floor(days / 365);
19097
+ return `${years}y ago`;
17796
19098
  }
17797
19099
  /**
17798
19100
  * Pick the glyph + color for a PR state badge.
@@ -18222,7 +19524,7 @@ function createChangelogArgv(input) {
18222
19524
  ...input,
18223
19525
  };
18224
19526
  }
18225
- function compactOutputLines$2(output) {
19527
+ function compactOutputLines$3(output) {
18226
19528
  return output
18227
19529
  .split('\n')
18228
19530
  .map((line) => line.trim())
@@ -18246,7 +19548,7 @@ async function captureStdout(action) {
18246
19548
  }
18247
19549
  }
18248
19550
  function formatCapturedAiOutput(output) {
18249
- const lines = compactOutputLines$2(output);
19551
+ const lines = compactOutputLines$3(output);
18250
19552
  const telemetry = lines.filter((line) => line.includes('[llm:summary]'));
18251
19553
  const content = lines.filter((line) => !line.includes('[llm]') && !line.includes('[llm:summary]'));
18252
19554
  const editable = content.join('\n');
@@ -18543,8 +19845,65 @@ function parsePullRequestInfo(output) {
18543
19845
  if (!trimmed) {
18544
19846
  return undefined;
18545
19847
  }
18546
- return JSON.parse(trimmed);
19848
+ const raw = JSON.parse(trimmed);
19849
+ const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
19850
+ ? String(raw.author.login)
19851
+ : undefined;
19852
+ return {
19853
+ number: raw.number,
19854
+ title: raw.title,
19855
+ url: raw.url,
19856
+ state: raw.state,
19857
+ isDraft: raw.isDraft,
19858
+ headRefName: raw.headRefName,
19859
+ baseRefName: raw.baseRefName,
19860
+ body: typeof raw.body === 'string' ? raw.body : undefined,
19861
+ author,
19862
+ reviewDecision: typeof raw.reviewDecision === 'string' ? raw.reviewDecision : undefined,
19863
+ mergeable: typeof raw.mergeable === 'string' ? raw.mergeable : undefined,
19864
+ mergeStateStatus: typeof raw.mergeStateStatus === 'string' ? raw.mergeStateStatus : undefined,
19865
+ statusCheckRollup: Array.isArray(raw.statusCheckRollup)
19866
+ ? raw.statusCheckRollup.map((entry) => ({
19867
+ name: String(entry.name || entry.context || 'check'),
19868
+ status: typeof entry.status === 'string' ? entry.status : undefined,
19869
+ conclusion: typeof entry.conclusion === 'string' ? entry.conclusion : undefined,
19870
+ }))
19871
+ : undefined,
19872
+ reviews: Array.isArray(raw.reviews)
19873
+ ? raw.reviews.map((entry) => {
19874
+ const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
19875
+ ? String(entry.author.login)
19876
+ : '';
19877
+ return {
19878
+ author,
19879
+ state: typeof entry.state === 'string' ? entry.state : '',
19880
+ };
19881
+ }).filter((review) => review.author)
19882
+ : undefined,
19883
+ };
18547
19884
  }
19885
+ /**
19886
+ * `gh pr view --json` field list. Centralized so the data fetcher and
19887
+ * any future re-fetch (e.g., refresh after a merge action) request the
19888
+ * same shape — the parser depends on every field being present, even
19889
+ * if optional, so they're safe to deserialize.
19890
+ */
19891
+ const PULL_REQUEST_VIEW_JSON_FIELDS = [
19892
+ 'number',
19893
+ 'title',
19894
+ 'url',
19895
+ 'state',
19896
+ 'isDraft',
19897
+ 'headRefName',
19898
+ 'baseRefName',
19899
+ 'body',
19900
+ 'author',
19901
+ 'reviewDecision',
19902
+ 'mergeable',
19903
+ 'mergeStateStatus',
19904
+ 'statusCheckRollup',
19905
+ 'reviews',
19906
+ ].join(',');
18548
19907
  async function getPullRequestOverview(git, runner = defaultGhRunner) {
18549
19908
  const [repository, currentBranchOutput] = await Promise.all([
18550
19909
  getGitHubRepository(git),
@@ -18576,7 +19935,7 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
18576
19935
  'pr',
18577
19936
  'view',
18578
19937
  '--json',
18579
- 'number,title,url,state,isDraft,headRefName,baseRefName',
19938
+ PULL_REQUEST_VIEW_JSON_FIELDS,
18580
19939
  ]);
18581
19940
  return {
18582
19941
  available: true,
@@ -18743,7 +20102,7 @@ function providerBranchName(branch) {
18743
20102
  return branch.shortName;
18744
20103
  }
18745
20104
 
18746
- function compactOutputLines$1(output) {
20105
+ function compactOutputLines$2(output) {
18747
20106
  return output
18748
20107
  .split('\n')
18749
20108
  .map((line) => line.trim())
@@ -18758,7 +20117,7 @@ async function runAction$5(action, successMessage) {
18758
20117
  };
18759
20118
  }
18760
20119
  catch (error) {
18761
- const lines = compactOutputLines$1(error.message);
20120
+ const lines = compactOutputLines$2(error.message);
18762
20121
  return {
18763
20122
  ok: false,
18764
20123
  message: lines[0] || 'History action failed.',
@@ -18900,7 +20259,7 @@ async function compareCommits(git, from, to) {
18900
20259
  }
18901
20260
  try {
18902
20261
  const output = await git.raw(['diff', '--stat', '--color=never', `${from.hash}..${to.hash}`]);
18903
- const lines = compactOutputLines$1(output);
20262
+ const lines = compactOutputLines$2(output);
18904
20263
  return {
18905
20264
  ok: true,
18906
20265
  message: `Compared ${from.shortHash}..${to.shortHash}`,
@@ -18982,6 +20341,63 @@ function resetToCommit(git, commit, mode) {
18982
20341
  : result.details,
18983
20342
  }));
18984
20343
  }
20344
+ /**
20345
+ * Create a new local branch pointed at <commit>, without switching to it.
20346
+ *
20347
+ * This is the "create branch from cursored commit" history action — the
20348
+ * user types the new branch name into an input prompt and we run
20349
+ * `git branch <name> <sha>` (NOT `git switch -c`, which is what
20350
+ * `branchActions.createBranch` does for the create-branch-at-HEAD flow).
20351
+ * The split exists because GitKraken-style "create branch here" is
20352
+ * specifically about marking a historical commit, not about switching
20353
+ * onto a new working branch.
20354
+ *
20355
+ * Note for the inspector follow-up: workflow surfacing is driven by the
20356
+ * registry in `inkWorkflows.ts`, not a hardcoded action list — adding
20357
+ * `create-branch-here` there is enough for the inspector / palette to
20358
+ * pick this up.
20359
+ */
20360
+ function createBranchFromCommit(git, name, commit) {
20361
+ const trimmedName = name.trim();
20362
+ if (!commit) {
20363
+ return Promise.resolve({
20364
+ ok: false,
20365
+ message: 'No commit selected.',
20366
+ });
20367
+ }
20368
+ if (!trimmedName) {
20369
+ return Promise.resolve({
20370
+ ok: false,
20371
+ message: 'Branch name required.',
20372
+ });
20373
+ }
20374
+ return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['branch', trimmedName, commit.hash]), `Created branch ${trimmedName} at ${commit.shortHash}`)));
20375
+ }
20376
+ /**
20377
+ * Create a lightweight tag pointed at <commit>.
20378
+ *
20379
+ * Mirrors `createBranchFromCommit` for the tag side: the user types a
20380
+ * tag name into an input prompt and we run `git tag <name> <sha>`
20381
+ * (lightweight, no `-a`/`-m`). Annotated tags remain available through
20382
+ * the existing `+` flow on the tags view; this is the per-commit
20383
+ * shortcut.
20384
+ */
20385
+ function createTagAtCommit(git, name, commit) {
20386
+ const trimmedName = name.trim();
20387
+ if (!commit) {
20388
+ return Promise.resolve({
20389
+ ok: false,
20390
+ message: 'No commit selected.',
20391
+ });
20392
+ }
20393
+ if (!trimmedName) {
20394
+ return Promise.resolve({
20395
+ ok: false,
20396
+ message: 'Tag name required.',
20397
+ });
20398
+ }
20399
+ return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['tag', trimmedName, commit.hash]), `Created tag ${trimmedName} at ${commit.shortHash}`)));
20400
+ }
18985
20401
  function startInteractiveRebase(git, commit) {
18986
20402
  if (!commit) {
18987
20403
  return Promise.resolve({
@@ -19067,6 +20483,61 @@ function createPullRequest(input, runner = defaultGhRunner) {
19067
20483
  };
19068
20484
  });
19069
20485
  }
20486
+ function isPullRequestMergeStrategy(value) {
20487
+ return value === 'merge' || value === 'squash' || value === 'rebase';
20488
+ }
20489
+ function buildMergePullRequestArgs(strategy) {
20490
+ // `--auto` and `--admin` are intentionally omitted — they're rarely
20491
+ // what a user wants from a TUI and require explicit gh auth scopes.
20492
+ // `--delete-branch` is opt-in via a future flag; default leaves the
20493
+ // branch in place so the user can verify before cleanup.
20494
+ return ['pr', 'merge', `--${strategy}`];
20495
+ }
20496
+ function mergePullRequest(strategy, runner = defaultGhRunner) {
20497
+ return runGhAction(runner, buildMergePullRequestArgs(strategy), (output) => ({
20498
+ ok: true,
20499
+ message: output.trim() || `Merged pull request with ${strategy}`,
20500
+ }));
20501
+ }
20502
+ function closePullRequest(runner = defaultGhRunner) {
20503
+ return runGhAction(runner, ['pr', 'close'], (output) => ({
20504
+ ok: true,
20505
+ message: output.trim() || 'Closed pull request',
20506
+ }));
20507
+ }
20508
+ /**
20509
+ * `gh pr review --approve` requires the user's gh auth to have scope
20510
+ * to write reviews — same scope that the in-browser approve button
20511
+ * uses. The runner surfaces auth failures via the standard error path.
20512
+ */
20513
+ function approvePullRequest(runner = defaultGhRunner) {
20514
+ return runGhAction(runner, ['pr', 'review', '--approve'], (output) => ({
20515
+ ok: true,
20516
+ message: output.trim() || 'Approved pull request',
20517
+ }));
20518
+ }
20519
+ /**
20520
+ * Request changes — `gh pr review` requires a body with this verb so
20521
+ * the empty-body case is rejected upstream by the input prompt.
20522
+ */
20523
+ function requestChangesPullRequest(body, runner = defaultGhRunner) {
20524
+ if (!body.trim()) {
20525
+ return Promise.resolve({ ok: false, message: 'Review body required for change-request' });
20526
+ }
20527
+ return runGhAction(runner, ['pr', 'review', '--request-changes', '--body', body], (output) => ({
20528
+ ok: true,
20529
+ message: output.trim() || 'Requested changes',
20530
+ }));
20531
+ }
20532
+ function commentPullRequest(body, runner = defaultGhRunner) {
20533
+ if (!body.trim()) {
20534
+ return Promise.resolve({ ok: false, message: 'Comment body required' });
20535
+ }
20536
+ return runGhAction(runner, ['pr', 'comment', '--body', body], (output) => ({
20537
+ ok: true,
20538
+ message: output.trim() || 'Comment added',
20539
+ }));
20540
+ }
19070
20541
 
19071
20542
  async function runAction$4(action, successMessage) {
19072
20543
  try {
@@ -19691,7 +21162,7 @@ function applyLogTuiAction(state, action) {
19691
21162
  }
19692
21163
  }
19693
21164
 
19694
- function compactOutputLines(output) {
21165
+ function compactOutputLines$1(output) {
19695
21166
  return output
19696
21167
  .split('\n')
19697
21168
  .map((line) => line.trim())
@@ -19706,7 +21177,7 @@ async function runAction(action, successMessage) {
19706
21177
  };
19707
21178
  }
19708
21179
  catch (error) {
19709
- const details = compactOutputLines(error.message);
21180
+ const details = compactOutputLines$1(error.message);
19710
21181
  return {
19711
21182
  ok: false,
19712
21183
  message: details[0] || 'Git operation action failed.',
@@ -21385,6 +22856,356 @@ async function startInteractiveLog(git, rows, streams = {}) {
21385
22856
  });
21386
22857
  }
21387
22858
 
22859
+ function compactOutputLines(output) {
22860
+ return output
22861
+ .split('\n')
22862
+ .map((line) => line.trim())
22863
+ .filter(Boolean);
22864
+ }
22865
+ async function safeUnlink(path) {
22866
+ try {
22867
+ await fs.promises.unlink(path);
22868
+ }
22869
+ catch (error) {
22870
+ // ENOENT is fine — the temp file was never created or already
22871
+ // cleaned up. Anything else we silently swallow because the
22872
+ // worst-case impact is a single ~1KB file in $TMPDIR.
22873
+ error.code;
22874
+ }
22875
+ }
22876
+ /**
22877
+ * Write a unified-diff patch to a temp file and feed it to
22878
+ * `git apply` (or `git apply --cached` when target === 'index').
22879
+ *
22880
+ * This is the runner behind the `apply-hunk-worktree` /
22881
+ * `apply-hunk-index` workflow actions — the input handler builds
22882
+ * `patchText` from the cursored hunk via `extractDiffHunk` and the
22883
+ * runtime hands it here.
22884
+ *
22885
+ * `--whitespace=nowarn` keeps `git apply` quiet about trailing
22886
+ * whitespace differences (the most common false positive when the
22887
+ * patch comes from a stash made on a different platform). Real
22888
+ * conflicts still surface via the non-zero exit code.
22889
+ *
22890
+ * The patch is written to a temp file rather than piped on stdin
22891
+ * because some `simple-git` adapters don't expose a clean stdin
22892
+ * channel for `git.raw`; the tempfile path keeps the runner
22893
+ * portable across environments.
22894
+ */
22895
+ async function applyHunkPatch(git, patchText, options) {
22896
+ if (!patchText.trim()) {
22897
+ return {
22898
+ ok: false,
22899
+ message: 'No hunk under cursor to apply.',
22900
+ };
22901
+ }
22902
+ const targetLabel = options.target === 'index' ? 'index' : 'worktree';
22903
+ const tempPath = path.join(os.tmpdir(), `coco-hunk-${crypto$1.randomUUID()}.patch`);
22904
+ try {
22905
+ await fs.promises.writeFile(tempPath, patchText, 'utf8');
22906
+ const args = ['apply'];
22907
+ if (options.target === 'index') {
22908
+ args.push('--cached');
22909
+ }
22910
+ args.push('--whitespace=nowarn');
22911
+ args.push(tempPath);
22912
+ try {
22913
+ await git.raw(args);
22914
+ return {
22915
+ ok: true,
22916
+ message: `Applied hunk to ${targetLabel}`,
22917
+ };
22918
+ }
22919
+ catch (error) {
22920
+ const lines = compactOutputLines(error.message);
22921
+ return {
22922
+ ok: false,
22923
+ message: lines[0] || `Failed to apply hunk to ${targetLabel}`,
22924
+ details: lines.slice(1, 6),
22925
+ };
22926
+ }
22927
+ }
22928
+ catch (error) {
22929
+ return {
22930
+ ok: false,
22931
+ message: `Could not stage hunk for apply: ${error.message}`,
22932
+ };
22933
+ }
22934
+ finally {
22935
+ await safeUnlink(tempPath);
22936
+ }
22937
+ }
22938
+
22939
+ function formatStashHeaderIdentity(ref, stashes) {
22940
+ if (!ref) {
22941
+ return { subtitle: 'no stash', bodyLine: 'Stash:' };
22942
+ }
22943
+ const index = stashes?.findIndex((entry) => entry.ref === ref) ?? -1;
22944
+ const entry = index >= 0 ? stashes[index] : undefined;
22945
+ if (!entry) {
22946
+ return {
22947
+ subtitle: ref,
22948
+ bodyLine: `Stash: ${ref}`,
22949
+ };
22950
+ }
22951
+ const onBranch = entry.branch && entry.branch !== '<unknown>' ? ` on ${entry.branch}` : '';
22952
+ const message = entry.message?.trim() || '(no message)';
22953
+ return {
22954
+ subtitle: `@{${index}} ${message}${onBranch}`,
22955
+ bodyLine: `Stash: ${ref}${onBranch} — ${message}`,
22956
+ };
22957
+ }
22958
+
22959
+ /**
22960
+ * Normalize gh's two parallel signals (`status` for in-flight check
22961
+ * runs, `conclusion` for completed runs and status contexts) into a
22962
+ * single status enum the renderer can map to a glyph + color.
22963
+ */
22964
+ function normalizePullRequestCheckStatus(check) {
22965
+ const status = (check.status || '').toUpperCase();
22966
+ const conclusion = (check.conclusion || '').toUpperCase();
22967
+ // In-flight check runs: gh emits `status: IN_PROGRESS|QUEUED` with
22968
+ // no conclusion yet. `PENDING` covers status-context runs that are
22969
+ // still waiting on a reporter.
22970
+ if (!conclusion && (status === 'IN_PROGRESS' || status === 'QUEUED' || status === 'PENDING')) {
22971
+ return 'pending';
22972
+ }
22973
+ switch (conclusion || status) {
22974
+ case 'SUCCESS':
22975
+ return 'success';
22976
+ case 'FAILURE':
22977
+ case 'ERROR':
22978
+ case 'TIMED_OUT':
22979
+ case 'ACTION_REQUIRED':
22980
+ return 'failure';
22981
+ case 'NEUTRAL':
22982
+ return 'neutral';
22983
+ case 'SKIPPED':
22984
+ case 'CANCELLED':
22985
+ return 'skipped';
22986
+ default:
22987
+ return 'pending';
22988
+ }
22989
+ }
22990
+ /**
22991
+ * Glyph for a normalized check status. ASCII fallbacks keep the panel
22992
+ * usable on legacy terminals where the geometric shapes block isn't
22993
+ * rendered.
22994
+ */
22995
+ function pullRequestCheckGlyph(status, options = {}) {
22996
+ if (options.ascii) {
22997
+ switch (status) {
22998
+ case 'success': return '+';
22999
+ case 'failure': return 'x';
23000
+ case 'pending': return '.';
23001
+ case 'neutral': return '-';
23002
+ case 'skipped': return '/';
23003
+ }
23004
+ }
23005
+ switch (status) {
23006
+ case 'success': return '✓';
23007
+ case 'failure': return '✗';
23008
+ case 'pending': return '◌';
23009
+ case 'neutral': return '○';
23010
+ case 'skipped': return '∼';
23011
+ }
23012
+ }
23013
+ function summarizePullRequestChecks(checks) {
23014
+ const summary = {
23015
+ total: 0, success: 0, failure: 0, pending: 0, neutral: 0, skipped: 0,
23016
+ };
23017
+ if (!checks)
23018
+ return summary;
23019
+ for (const check of checks) {
23020
+ summary.total += 1;
23021
+ summary[normalizePullRequestCheckStatus(check)] += 1;
23022
+ }
23023
+ return summary;
23024
+ }
23025
+ /**
23026
+ * One-line summary like `5 checks · 4 ✓ · 1 ◌` for the panel header.
23027
+ * Hides zero-count categories so the line stays scannable.
23028
+ */
23029
+ function formatPullRequestChecksSummary(summary, options = {}) {
23030
+ if (summary.total === 0) {
23031
+ return 'No status checks reported';
23032
+ }
23033
+ const parts = [`${summary.total} ${summary.total === 1 ? 'check' : 'checks'}`];
23034
+ const push = (count, status) => {
23035
+ if (count > 0)
23036
+ parts.push(`${count} ${pullRequestCheckGlyph(status, options)}`);
23037
+ };
23038
+ push(summary.success, 'success');
23039
+ push(summary.failure, 'failure');
23040
+ push(summary.pending, 'pending');
23041
+ push(summary.neutral, 'neutral');
23042
+ push(summary.skipped, 'skipped');
23043
+ return parts.join(' · ');
23044
+ }
23045
+ function buildPullRequestCheckRows(checks, options = {}) {
23046
+ if (!checks)
23047
+ return [];
23048
+ return checks.map((check) => {
23049
+ const status = normalizePullRequestCheckStatus(check);
23050
+ return {
23051
+ glyph: pullRequestCheckGlyph(status, options),
23052
+ name: check.name,
23053
+ status,
23054
+ detail: (check.conclusion || check.status || '').toLowerCase(),
23055
+ };
23056
+ });
23057
+ }
23058
+ function summarizePullRequestReviews(reviews, reviewDecision) {
23059
+ const summary = {
23060
+ total: 0, approved: 0, changesRequested: 0, commented: 0, dismissed: 0, pending: 0,
23061
+ decisionLabel: reviewDecision || undefined,
23062
+ };
23063
+ if (!reviews)
23064
+ return summary;
23065
+ for (const review of reviews) {
23066
+ summary.total += 1;
23067
+ switch (review.state.toUpperCase()) {
23068
+ case 'APPROVED':
23069
+ summary.approved += 1;
23070
+ break;
23071
+ case 'CHANGES_REQUESTED':
23072
+ summary.changesRequested += 1;
23073
+ break;
23074
+ case 'COMMENTED':
23075
+ summary.commented += 1;
23076
+ break;
23077
+ case 'DISMISSED':
23078
+ summary.dismissed += 1;
23079
+ break;
23080
+ case 'PENDING':
23081
+ summary.pending += 1;
23082
+ break;
23083
+ }
23084
+ }
23085
+ return summary;
23086
+ }
23087
+ function formatPullRequestReviewsSummary(summary) {
23088
+ const decision = summary.decisionLabel
23089
+ ? summary.decisionLabel.replace(/_/g, ' ').toLowerCase()
23090
+ : undefined;
23091
+ if (summary.total === 0) {
23092
+ return decision ? `No reviews · ${decision}` : 'No reviews submitted';
23093
+ }
23094
+ const parts = [`${summary.total} ${summary.total === 1 ? 'review' : 'reviews'}`];
23095
+ if (summary.approved > 0)
23096
+ parts.push(`${summary.approved} approved`);
23097
+ if (summary.changesRequested > 0)
23098
+ parts.push(`${summary.changesRequested} changes requested`);
23099
+ if (summary.commented > 0)
23100
+ parts.push(`${summary.commented} commented`);
23101
+ if (summary.pending > 0)
23102
+ parts.push(`${summary.pending} pending`);
23103
+ if (summary.dismissed > 0)
23104
+ parts.push(`${summary.dismissed} dismissed`);
23105
+ if (decision)
23106
+ parts.push(`decision: ${decision}`);
23107
+ return parts.join(' · ');
23108
+ }
23109
+ /**
23110
+ * One-line state badge for the header, e.g. `OPEN · draft` or `MERGED`.
23111
+ * Mergeable / merge-state is appended as a secondary chip when the PR
23112
+ * is open so the user sees `MERGEABLE` / `CONFLICTING` at a glance.
23113
+ */
23114
+ function formatPullRequestStateLine(pr) {
23115
+ const parts = [pr.state];
23116
+ if (pr.isDraft)
23117
+ parts.push('draft');
23118
+ if (pr.state === 'OPEN' && pr.mergeable) {
23119
+ parts.push(pr.mergeable.toLowerCase());
23120
+ }
23121
+ if (pr.state === 'OPEN' && pr.mergeStateStatus && pr.mergeStateStatus !== 'CLEAN') {
23122
+ parts.push(pr.mergeStateStatus.toLowerCase());
23123
+ }
23124
+ return parts.join(' · ');
23125
+ }
23126
+
23127
+ /**
23128
+ * Hardcoded per-entity action lists surfaced inside the right-hand
23129
+ * inspector panel. The inspector used to repeat the repo / branch /
23130
+ * status content the top header and left sidebar already show; we drop
23131
+ * that trailer in favor of an actionable cheat-sheet so the user knows
23132
+ * exactly which keystrokes apply to whatever they have under the cursor.
23133
+ *
23134
+ * Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
23135
+ * - Most per-entity actions live in `inkInput.ts` as direct keystroke
23136
+ * handlers (e.g. `c` cherry-pick, `R` revert) rather than as
23137
+ * globally-registered bindings, so the registry would be a partial
23138
+ * view at best.
23139
+ * - The bindings registry's `contexts` model (normal / search / focus
23140
+ * name) does not cleanly map to inspector entity types like "branch"
23141
+ * or "tag". Filtering it would mean replicating the same per-view
23142
+ * scoping logic the input dispatcher already encodes.
23143
+ * - New per-entity actions are added infrequently — the maintenance
23144
+ * cost of mirroring them here is low and keeps this file the single
23145
+ * source of truth for "what shows in the inspector".
23146
+ *
23147
+ * If you wire up a new per-entity keystroke in `inkInput.ts` — for
23148
+ * example a "create branch from this commit" or "create tag from this
23149
+ * commit" action — add the matching row to the relevant array below so
23150
+ * it shows up in the inspector automatically.
23151
+ */
23152
+ const HISTORY_COMMIT_ACTIONS = [
23153
+ { key: 'enter', label: 'Open diff' },
23154
+ { key: 'c', label: 'Cherry-pick' },
23155
+ { key: 'R', label: 'Revert', destructive: true },
23156
+ { key: 'Z', label: 'Reset to commit', destructive: true },
23157
+ { key: 'i', label: 'Interactive rebase', destructive: true },
23158
+ { key: 'y', label: 'Yank hash' },
23159
+ { key: 'Y', label: 'Yank short hash' },
23160
+ { key: 'O', label: 'Open in browser' },
23161
+ ];
23162
+ const BRANCH_ACTIONS = [
23163
+ { key: 'enter', label: 'Checkout' },
23164
+ { key: '+', label: 'New branch' },
23165
+ { key: 'R', label: 'Rename' },
23166
+ { key: 'u', label: 'Set upstream' },
23167
+ { key: 'D', label: 'Delete', destructive: true },
23168
+ { key: 'P', label: 'Push current' },
23169
+ { key: 'F', label: 'Fetch all' },
23170
+ { key: 'y', label: 'Yank name' },
23171
+ ];
23172
+ const TAG_ACTIONS = [
23173
+ { key: '+', label: 'New tag' },
23174
+ { key: 'P', label: 'Push tag' },
23175
+ { key: 'T', label: 'Delete', destructive: true },
23176
+ { key: 'R', label: 'Delete remote', destructive: true },
23177
+ { key: 'y', label: 'Yank name' },
23178
+ ];
23179
+ const STASH_ACTIONS = [
23180
+ { key: 'enter', label: 'Open diff' },
23181
+ { key: 'a', label: 'Apply' },
23182
+ { key: 'p', label: 'Pop' },
23183
+ { key: 'X', label: 'Drop', destructive: true },
23184
+ { key: 'y', label: 'Yank ref' },
23185
+ ];
23186
+ const WORKTREE_ACTIONS = [
23187
+ { key: 'W', label: 'Remove', destructive: true },
23188
+ { key: 'y', label: 'Yank path' },
23189
+ ];
23190
+ function getInspectorActions(context) {
23191
+ switch (context) {
23192
+ case 'history-commit':
23193
+ return HISTORY_COMMIT_ACTIONS;
23194
+ case 'branch':
23195
+ return BRANCH_ACTIONS;
23196
+ case 'tag':
23197
+ return TAG_ACTIONS;
23198
+ case 'stash':
23199
+ return STASH_ACTIONS;
23200
+ case 'worktree':
23201
+ return WORKTREE_ACTIONS;
23202
+ default: {
23203
+ const exhaustive = context;
23204
+ throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
23205
+ }
23206
+ }
23207
+ }
23208
+
21388
23209
  function sectionLines(title, diff) {
21389
23210
  const lines = diff.split('\n').map((line) => line.trimEnd());
21390
23211
  return [
@@ -21600,6 +23421,89 @@ function diffLineProps(line, theme) {
21600
23421
  }
21601
23422
  return {};
21602
23423
  }
23424
+ /**
23425
+ * Minimum terminal width below which the split diff falls back to
23426
+ * unified rendering (#785). Each column needs ~50 columns for code to
23427
+ * read comfortably plus border + padding overhead, so anything narrower
23428
+ * than ~120 columns gets the unified view regardless of the user's
23429
+ * preference. The preference is preserved — switching back to a wide
23430
+ * terminal restores split mode automatically.
23431
+ */
23432
+ const MIN_SPLIT_DIFF_WIDTH = 120;
23433
+ function isSplitDiffViable(state, width) {
23434
+ return state.diffViewMode === 'split' && width >= MIN_SPLIT_DIFF_WIDTH;
23435
+ }
23436
+ /**
23437
+ * Style props for one side of a split-diff row, derived from the row's
23438
+ * `kind` rather than the leading character (because the helper has
23439
+ * already stripped the leading +/-/space). Keeps the colors aligned with
23440
+ * `diffLineProps`.
23441
+ */
23442
+ function splitDiffSideProps(kind, theme) {
23443
+ if (kind === 'header') {
23444
+ if (theme.noColor)
23445
+ return { dimColor: true };
23446
+ return { color: theme.colors.accent };
23447
+ }
23448
+ if (kind === 'empty') {
23449
+ return { dimColor: true };
23450
+ }
23451
+ if (theme.noColor) {
23452
+ return { dimColor: kind === 'context' };
23453
+ }
23454
+ if (kind === 'add')
23455
+ return { color: theme.colors.gitAdded };
23456
+ if (kind === 'remove')
23457
+ return { color: theme.colors.gitDeleted };
23458
+ return {};
23459
+ }
23460
+ /**
23461
+ * Format one column of a split-diff row: an optional 4-digit line
23462
+ * number prefix + the line text, padded/truncated to the column width.
23463
+ * Empty rows render a faint `·` placeholder so the alignment gap is
23464
+ * visible at a glance.
23465
+ */
23466
+ function formatSplitDiffCell(side, columnWidth) {
23467
+ if (side.kind === 'empty') {
23468
+ const placeholder = ' · ';
23469
+ return placeholder.padEnd(columnWidth);
23470
+ }
23471
+ if (side.kind === 'header') {
23472
+ return truncate$1(side.text, columnWidth).padEnd(columnWidth);
23473
+ }
23474
+ const lineNo = side.lineNumber !== undefined ? String(side.lineNumber).padStart(4) : ' ';
23475
+ // Strip the trailing newline that some diffs include. Keeps column
23476
+ // widths predictable.
23477
+ const text = side.text.replace(/\n$/, '');
23478
+ // 4 digits + 1 space gutter = 5 chars; reserve that off the column
23479
+ // before truncating the text.
23480
+ const textRoom = Math.max(1, columnWidth - 5);
23481
+ return `${lineNo} ${truncate$1(text, textRoom)}`.padEnd(columnWidth);
23482
+ }
23483
+ /**
23484
+ * Render the split-diff body as a list of two-column rows. The caller
23485
+ * is responsible for slicing the unified-line array to the visible
23486
+ * window — the helper just transforms that slice into Ink nodes.
23487
+ */
23488
+ function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, theme, keyPrefix) {
23489
+ const { Box, Text } = components;
23490
+ const rows = buildSplitDiffRows(unifiedSlice);
23491
+ // Reserve 3 columns of gutter (1 left padding from the Box + 1 column
23492
+ // separator + 1 right padding) so neither side touches the border.
23493
+ const usable = Math.max(20, width - 4);
23494
+ const gutter = 1;
23495
+ const half = Math.max(10, Math.floor((usable - gutter) / 2));
23496
+ return rows.map((row, index) => {
23497
+ const leftProps = splitDiffSideProps(row.left.kind, theme);
23498
+ const rightProps = splitDiffSideProps(row.right.kind, theme);
23499
+ const leftText = formatSplitDiffCell(row.left, half);
23500
+ const rightText = formatSplitDiffCell(row.right, half);
23501
+ return h(Box, {
23502
+ key: `${keyPrefix}-${startOffset + index}`,
23503
+ flexDirection: 'row',
23504
+ }, h(Box, { width: half, flexShrink: 0 }, h(Text, leftProps, leftText)), h(Box, { width: gutter, flexShrink: 0 }, h(Text, { dimColor: true }, ' ')), h(Box, { width: half, flexShrink: 0 }, h(Text, rightProps, rightText)));
23505
+ });
23506
+ }
21603
23507
  /**
21604
23508
  * Pick a theme color for a single name-status code (`A`, `M`, `D`,
21605
23509
  * `R100`, etc.) so the inspector and commit-diff file list render with
@@ -21653,68 +23557,6 @@ function sidebarTabLabel(tab) {
21653
23557
  return tab;
21654
23558
  }
21655
23559
  }
21656
- function sidebarLines(context, contextStatus, tab, width, state, theme) {
21657
- if (tab === 'status') {
21658
- const worktree = context.worktree;
21659
- if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
21660
- return ['Loading status...'];
21661
- }
21662
- if (!worktree) {
21663
- return ['Status unavailable'];
21664
- }
21665
- return [
21666
- `${worktree.stagedCount} staged`,
21667
- `${worktree.unstagedCount} unstaged`,
21668
- `${worktree.untrackedCount} untracked`,
21669
- '',
21670
- ...worktree.files.slice(0, 12).map((file) => `${file.indexStatus}${file.worktreeStatus} ${truncate$1(file.path, width - 3)}`),
21671
- ];
21672
- }
21673
- if (tab === 'branches') {
21674
- const branches = context.branches;
21675
- if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
21676
- return ['Loading branches...'];
21677
- }
21678
- if (!branches) {
21679
- return ['Branches unavailable'];
21680
- }
21681
- const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
21682
- return [
21683
- `Current: ${branches.currentBranch || '<detached>'}`,
21684
- branches.dirty ? 'Worktree: dirty' : 'Worktree: clean',
21685
- '',
21686
- ...sortedBranches.slice(0, 8).map((branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${truncate$1(branch.shortName, width - 4)}`),
21687
- ...sortedBranches.slice(0, 4).map((branch) => ` ${truncate$1(formatBranchDivergence(branch, { ascii: theme.ascii }), width - 2)}`),
21688
- ];
21689
- }
21690
- if (tab === 'tags') {
21691
- if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
21692
- return ['Loading tags...'];
21693
- }
21694
- const sortedTags = sortTags(context.tags?.tags || [], state.tagSort);
21695
- return sortedTags.length
21696
- ? sortedTags.slice(0, 12).map((tag) => `${truncate$1(tag.name, 16)} ${truncate$1(tag.subject, Math.max(8, width - 18))}`)
21697
- : ['No tags found'];
21698
- }
21699
- if (tab === 'stashes') {
21700
- if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
21701
- return ['Loading stashes...'];
21702
- }
21703
- return context.stashes?.stashes.length
21704
- ? context.stashes.stashes.slice(0, 12).map((stash) => `${stash.ref} ${truncate$1(stash.message, Math.max(8, width - stash.ref.length - 1))}`)
21705
- : ['No stashes found'];
21706
- }
21707
- if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
21708
- return ['Loading worktrees...'];
21709
- }
21710
- return context.worktreeList?.worktrees.length
21711
- ? context.worktreeList.worktrees.slice(0, 12).map((worktree) => {
21712
- const marker = worktree.current ? '*' : ' ';
21713
- const state = worktree.dirty ? 'dirty' : 'clean';
21714
- return `${marker} ${truncate$1(worktree.branch || worktree.path, Math.max(8, width - 8))} ${state}`;
21715
- })
21716
- : ['No linked worktrees'];
21717
- }
21718
23560
  async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
21719
23561
  const input = streams.input || process.stdin;
21720
23562
  const output = streams.output || process.stdout;
@@ -22056,6 +23898,13 @@ function LogInkApp(deps) {
22056
23898
  if (saved && saved !== state.userSidebarTab) {
22057
23899
  dispatch({ type: 'restoreSidebarTab', value: saved });
22058
23900
  }
23901
+ // Diff view mode persistence (#785). Same per-repo cache pattern
23902
+ // as the sidebar tab — restore the user's last preference if
23903
+ // they had one. New repos / fresh installs default to unified.
23904
+ const savedDiffMode = getSavedDiffViewMode(repoRoot);
23905
+ if (savedDiffMode && savedDiffMode !== state.diffViewMode) {
23906
+ dispatch({ type: 'setDiffViewMode', value: savedDiffMode });
23907
+ }
22059
23908
  }
22060
23909
  catch {
22061
23910
  // Not in a worktree, or revparse failed; nothing to restore.
@@ -22069,6 +23918,12 @@ function LogInkApp(deps) {
22069
23918
  return;
22070
23919
  saveSidebarTab(repoRoot, state.userSidebarTab);
22071
23920
  }, [state.userSidebarTab]);
23921
+ React.useEffect(() => {
23922
+ const repoRoot = repoRootRef.current;
23923
+ if (!repoRoot)
23924
+ return;
23925
+ saveDiffViewMode(repoRoot, state.diffViewMode);
23926
+ }, [state.diffViewMode]);
22072
23927
  // P-stash-explorer: load `git stash show -p <ref>` once the diff view
22073
23928
  // becomes active with diffSource='stash'. Best-effort — empty stashes
22074
23929
  // or read errors fall through to a "no diff" hint at the render site.
@@ -22150,6 +24005,80 @@ function LogInkApp(deps) {
22150
24005
  active = false;
22151
24006
  };
22152
24007
  }, [git, selected?.hash]);
24008
+ // #806 follow-up — auto-jump the history view to whichever branch /
24009
+ // tag the user is currently cursoring in the sidebar (or the
24010
+ // dedicated branches / tags view). Debounced so cursor-scrolling
24011
+ // through a long branch list doesn't dispatch on every keystroke.
24012
+ // No-op when the cursored ref's tip isn't in the loaded commit
24013
+ // window (under compact mode the cursored branch's tip may not be
24014
+ // fetched yet); a status hint surfaces in that case so the user
24015
+ // knows to toggle full graph or load older commits.
24016
+ React.useEffect(() => {
24017
+ const onBranchTab = state.activeView === 'branches' ||
24018
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
24019
+ const onTagTab = state.activeView === 'tags' ||
24020
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24021
+ if (!onBranchTab && !onTagTab)
24022
+ return;
24023
+ let cancelled = false;
24024
+ const timer = setTimeout(() => {
24025
+ if (cancelled)
24026
+ return;
24027
+ let targetHash;
24028
+ let targetLabel;
24029
+ if (onBranchTab) {
24030
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24031
+ const visible = state.filter
24032
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24033
+ : all;
24034
+ const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24035
+ if (branch) {
24036
+ targetHash = branch.hash;
24037
+ targetLabel = `branch ${branch.shortName}`;
24038
+ }
24039
+ }
24040
+ else if (onTagTab) {
24041
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
24042
+ const visible = state.filter
24043
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24044
+ : all;
24045
+ const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24046
+ if (tag) {
24047
+ targetHash = tag.hash;
24048
+ targetLabel = `tag ${tag.name}`;
24049
+ }
24050
+ }
24051
+ if (!targetHash)
24052
+ return;
24053
+ const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24054
+ if (loaded) {
24055
+ dispatch({ type: 'selectCommitByHash', hash: targetHash });
24056
+ // Confirmation status message so the user gets feedback even
24057
+ // when the dedicated branches / tags view is occupying the
24058
+ // main panel and the history cursor moves invisibly behind it.
24059
+ dispatch({
24060
+ type: 'setStatus',
24061
+ value: `Synced history to ${targetLabel} tip`,
24062
+ });
24063
+ }
24064
+ else {
24065
+ dispatch({
24066
+ type: 'setStatus',
24067
+ value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24068
+ });
24069
+ }
24070
+ }, 150);
24071
+ return () => {
24072
+ cancelled = true;
24073
+ clearTimeout(timer);
24074
+ };
24075
+ }, [
24076
+ dispatch, context.branches, context.tags,
24077
+ state.activeView, state.focus, state.sidebarTab,
24078
+ state.selectedBranchIndex, state.selectedTagIndex,
24079
+ state.branchSort, state.tagSort, state.filter,
24080
+ state.filteredCommits,
24081
+ ]);
22153
24082
  React.useEffect(() => {
22154
24083
  let active = true;
22155
24084
  async function loadWorktreeDiff() {
@@ -22361,6 +24290,27 @@ function LogInkApp(deps) {
22361
24290
  // disappears. Called from the y-confirm path for delete-branch / delete-
22362
24291
  // tag / drop-stash / remove-worktree / abort-operation.
22363
24292
  const runWorkflowAction = React.useCallback(async (id, payload) => {
24293
+ // Hunk-apply payload format: `<target>\n<patchText>` — the input
24294
+ // handler synthesizes both pieces (target from the keystroke,
24295
+ // patch text from extractDiffHunk against the live diff lines)
24296
+ // and packs them into the single `payload` field. Splitting on
24297
+ // the first newline keeps the patch body intact.
24298
+ const runApplyHunk = (expectedTarget, raw) => {
24299
+ if (!raw) {
24300
+ return Promise.resolve({ ok: false, message: 'No hunk under cursor to apply.' });
24301
+ }
24302
+ const newlineIndex = raw.indexOf('\n');
24303
+ if (newlineIndex < 0) {
24304
+ return Promise.resolve({ ok: false, message: 'Malformed hunk-apply payload.' });
24305
+ }
24306
+ const target = raw.slice(0, newlineIndex) === 'index' ? 'index' : 'worktree';
24307
+ const patchText = raw.slice(newlineIndex + 1);
24308
+ // The input handler is the source of truth for target — but if a
24309
+ // palette-injected payload mismatches the workflow id, prefer
24310
+ // the workflow id so the user sees the action they asked for.
24311
+ const effectiveTarget = expectedTarget || target;
24312
+ return applyHunkPatch(git, patchText, { target: effectiveTarget });
24313
+ };
22364
24314
  const handlers = {
22365
24315
  'create-branch': async () => {
22366
24316
  const name = payload?.trim();
@@ -22466,6 +24416,68 @@ function LogInkApp(deps) {
22466
24416
  message: commit.message,
22467
24417
  });
22468
24418
  },
24419
+ 'revert-commit': async () => {
24420
+ const commit = getSelectedInkCommit(state);
24421
+ if (!commit)
24422
+ return { ok: false, message: 'No commit selected' };
24423
+ return revertCommit(git, {
24424
+ hash: commit.hash,
24425
+ shortHash: commit.shortHash,
24426
+ message: commit.message,
24427
+ });
24428
+ },
24429
+ 'reset-to-commit': async () => {
24430
+ const commit = getSelectedInkCommit(state);
24431
+ if (!commit)
24432
+ return { ok: false, message: 'No commit selected' };
24433
+ // Mode arrives via the action's `payload` field — the input
24434
+ // handler runs the reset-mode prompt (kind: 'reset-mode') and
24435
+ // routes the typed value here. Default to `mixed` (git's own
24436
+ // default) when the user submitted an empty value.
24437
+ const raw = payload?.trim().toLowerCase() || 'mixed';
24438
+ if (!isResetMode(raw)) {
24439
+ return { ok: false, message: `Unknown reset mode: ${raw}. Use soft, mixed, or hard.` };
24440
+ }
24441
+ return resetToCommit(git, {
24442
+ hash: commit.hash,
24443
+ shortHash: commit.shortHash,
24444
+ message: commit.message,
24445
+ }, raw);
24446
+ },
24447
+ 'interactive-rebase': async () => {
24448
+ const commit = getSelectedInkCommit(state);
24449
+ if (!commit)
24450
+ return { ok: false, message: 'No commit selected' };
24451
+ return startInteractiveRebase(git, {
24452
+ hash: commit.hash,
24453
+ shortHash: commit.shortHash,
24454
+ message: commit.message,
24455
+ });
24456
+ },
24457
+ 'create-branch-here': async () => {
24458
+ const commit = getSelectedInkCommit(state);
24459
+ const name = payload?.trim();
24460
+ if (!commit)
24461
+ return { ok: false, message: 'No commit selected' };
24462
+ if (!name)
24463
+ return { ok: false, message: 'Branch name required' };
24464
+ return createBranchFromCommit(git, name, {
24465
+ hash: commit.hash,
24466
+ shortHash: commit.shortHash,
24467
+ });
24468
+ },
24469
+ 'create-tag-here': async () => {
24470
+ const commit = getSelectedInkCommit(state);
24471
+ const name = payload?.trim();
24472
+ if (!commit)
24473
+ return { ok: false, message: 'No commit selected' };
24474
+ if (!name)
24475
+ return { ok: false, message: 'Tag name required' };
24476
+ return createTagAtCommit(git, name, {
24477
+ hash: commit.hash,
24478
+ shortHash: commit.shortHash,
24479
+ });
24480
+ },
22469
24481
  'checkout-file-from-commit': async () => {
22470
24482
  // payload is "<sha> <path>" so we pass both through a single
22471
24483
  // string field on the action.
@@ -22481,6 +24493,8 @@ function LogInkApp(deps) {
22481
24493
  return { ok: false, message: 'No commit file under cursor' };
22482
24494
  return checkoutFileFromCommit(git, sha, path);
22483
24495
  },
24496
+ 'apply-hunk-worktree': async () => runApplyHunk('worktree', payload),
24497
+ 'apply-hunk-index': async () => runApplyHunk('index', payload),
22484
24498
  'remove-worktree': async () => {
22485
24499
  const all = context.worktreeList?.worktrees || [];
22486
24500
  // Resolve the target from the visible (filtered) list so a
@@ -22516,6 +24530,17 @@ function LogInkApp(deps) {
22516
24530
  if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
22517
24531
  return { ok: false, message: 'No GitHub remote detected for this repo' };
22518
24532
  }
24533
+ // History view: prefer the cursored commit's URL so `O` from
24534
+ // a commit context lands the user on the commit page rather
24535
+ // than the repo root or the current PR. The user-visible
24536
+ // intent of `O` is "open whatever I'm cursoring on the web";
24537
+ // a commit is what the cursor is on in the history view.
24538
+ if (state.activeView === 'history') {
24539
+ const commit = getSelectedInkCommit(state);
24540
+ if (commit) {
24541
+ return openProviderUrl(repo, { type: 'commit', commit: commit.hash });
24542
+ }
24543
+ }
22519
24544
  const pr = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
22520
24545
  if (pr) {
22521
24546
  return openProviderUrl(repo, { type: 'pull-request', number: pr.number });
@@ -22569,6 +24594,32 @@ function LogInkApp(deps) {
22569
24594
  return { ok: false, message: 'Stash message required' };
22570
24595
  return createStash(git, message);
22571
24596
  },
24597
+ // #783 — full PR action panel handlers. Each wraps the matching
24598
+ // pullRequestActions verb. Strategy / body arrives via `payload`
24599
+ // — input prompts validate before they reach here, but the
24600
+ // strategy guard stays as a defensive belt-and-suspenders since
24601
+ // a future palette path could call us with a raw value.
24602
+ 'merge-pr': async () => {
24603
+ const strategy = (payload || 'merge').toLowerCase();
24604
+ if (!isPullRequestMergeStrategy(strategy)) {
24605
+ return { ok: false, message: `Unknown merge strategy: ${strategy}. Use merge, squash, or rebase.` };
24606
+ }
24607
+ return mergePullRequest(strategy);
24608
+ },
24609
+ 'close-pr': async () => closePullRequest(),
24610
+ 'approve-pr': async () => approvePullRequest(),
24611
+ 'request-changes-pr': async () => {
24612
+ const body = payload?.trim();
24613
+ if (!body)
24614
+ return { ok: false, message: 'Review body required for change-request' };
24615
+ return requestChangesPullRequest(body);
24616
+ },
24617
+ 'comment-pr': async () => {
24618
+ const body = payload?.trim();
24619
+ if (!body)
24620
+ return { ok: false, message: 'Comment body required' };
24621
+ return commentPullRequest(body);
24622
+ },
22572
24623
  };
22573
24624
  const handler = handlers[id];
22574
24625
  if (!handler) {
@@ -22577,9 +24628,21 @@ function LogInkApp(deps) {
22577
24628
  }
22578
24629
  const result = await handler();
22579
24630
  dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
22580
- // Silent refresh so the deleted item disappears from the list without
22581
- // flickering the surfaces through a 'loading' phase.
22582
- await refreshContext({ silent: true });
24631
+ // Checkout-branch is the one workflow where we want a *visible*
24632
+ // refresh so the user sees the branches sidebar repaint with the
24633
+ // new current branch (per #806 follow-up). Snap the cursor to
24634
+ // position 0 first so when the refresh completes and the new
24635
+ // current branch lands at the top (per #809's pin-current rule),
24636
+ // the cursor is already there waiting.
24637
+ if (id === 'checkout-branch' && result?.ok) {
24638
+ dispatch({ type: 'resetBranchSelection' });
24639
+ await refreshContext();
24640
+ }
24641
+ else {
24642
+ // Silent refresh so the deleted item disappears from the list
24643
+ // without flickering the surfaces through a 'loading' phase.
24644
+ await refreshContext({ silent: true });
24645
+ }
22583
24646
  }, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
22584
24647
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
22585
24648
  state.tagSort]);
@@ -22859,6 +24922,51 @@ function LogInkApp(deps) {
22859
24922
  });
22860
24923
  })();
22861
24924
  }, [dispatch, git, logArgv, state.historyFetchArgs]);
24925
+ // Graph mode toggle (`g` key, #791 follow-up). The header label flips
24926
+ // between "compact graph" and "full graph", but unless we re-fetch with
24927
+ // the right `view`, the underlying rows still come from the user's
24928
+ // initial argv (default `--first-parent --no-merges`) and the renderer
24929
+ // has no topology to draw — defeating the per-lane / junction work.
24930
+ // Mirrors the historyFetchArgs effect: skip first run, request-id ref
24931
+ // for stale-completion guard, swap rows in place via replaceRows.
24932
+ const toggleGraphEffectInitialized = React.useRef(false);
24933
+ const toggleGraphRequestRef = React.useRef(0);
24934
+ React.useEffect(() => {
24935
+ if (!logArgv)
24936
+ return;
24937
+ if (!toggleGraphEffectInitialized.current) {
24938
+ toggleGraphEffectInitialized.current = true;
24939
+ return;
24940
+ }
24941
+ const requestId = toggleGraphRequestRef.current + 1;
24942
+ toggleGraphRequestRef.current = requestId;
24943
+ const merged = buildToggleGraphArgs(logArgv, state.fullGraph);
24944
+ dispatch({
24945
+ type: 'setStatus',
24946
+ value: state.fullGraph
24947
+ ? 'Loading full topology…'
24948
+ : 'Loading compact history…',
24949
+ });
24950
+ void (async () => {
24951
+ const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
24952
+ if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
24953
+ return;
24954
+ }
24955
+ if (!nextRows) {
24956
+ dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
24957
+ return;
24958
+ }
24959
+ dispatch({ type: 'replaceRows', rows: nextRows });
24960
+ const matched = getCommitRows(nextRows).length;
24961
+ setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
24962
+ dispatch({
24963
+ type: 'setStatus',
24964
+ value: state.fullGraph
24965
+ ? `Showing ${matched} commits across all branches`
24966
+ : `Showing ${matched} commits (compact)`,
24967
+ });
24968
+ })();
24969
+ }, [dispatch, git, logArgv, state.fullGraph]);
22862
24970
  const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
22863
24971
  .map((line, index) => (line.startsWith('@@') ? index : -1))
22864
24972
  .filter((index) => index >= 0)), [filePreview]);
@@ -22953,6 +25061,17 @@ function LogInkApp(deps) {
22953
25061
  ? selected?.hash
22954
25062
  : undefined,
22955
25063
  worktreeDirty,
25064
+ // H / gH need the actual diff text (not just hunk offsets) to
25065
+ // slice the cursored hunk into a `git apply` patch. Stash uses
25066
+ // the full `git stash show -p` output; commit-diff uses the
25067
+ // per-file `filePreview.hunks` array. Either way, extractDiffHunk
25068
+ // walks `@@` headers and synthesizes a fresh diff --git / --- /
25069
+ // +++ header set using the path the caller already resolved.
25070
+ diffLinesForHunkApply: state.diffSource === 'stash'
25071
+ ? stashDiffLines
25072
+ : state.diffSource === 'commit'
25073
+ ? filePreview?.hunks
25074
+ : undefined,
22956
25075
  }).forEach((event) => {
22957
25076
  if (event.type === 'exit') {
22958
25077
  exit();
@@ -23005,6 +25124,7 @@ function LogInkApp(deps) {
23005
25124
  columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
23006
25125
  rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
23007
25126
  sidebarFocused: state.focus === 'sidebar',
25127
+ inspectorFocused: state.focus === 'detail',
23008
25128
  });
23009
25129
  if (layout.tooSmall) {
23010
25130
  return h(Box, {
@@ -23019,7 +25139,7 @@ function LogInkApp(deps) {
23019
25139
  if (showOnboarding) {
23020
25140
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
23021
25141
  }
23022
- 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, 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, theme)), renderFooter(h, { Box, Text }, state, theme, idleTip));
25142
+ 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));
23023
25143
  }
23024
25144
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
23025
25145
  const { Box, Text } = components;
@@ -23072,7 +25192,7 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
23072
25192
  ? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
23073
25193
  : undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
23074
25194
  }
23075
- function renderSidebar(h, components, state, context, contextStatus, width, theme) {
25195
+ function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme) {
23076
25196
  const { Box, Text } = components;
23077
25197
  const focused = state.focus === 'sidebar';
23078
25198
  const tabs = getLogInkSidebarTabs();
@@ -23096,7 +25216,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
23096
25216
  dimColor: !isActive,
23097
25217
  }, headerText));
23098
25218
  if (isActive) {
23099
- blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme));
25219
+ blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
23100
25220
  }
23101
25221
  return blocks;
23102
25222
  });
@@ -23115,15 +25235,113 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
23115
25235
  * surface; every other tab falls through to `sidebarLines` for its
23116
25236
  * string-based summary.
23117
25237
  */
23118
- function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme) {
25238
+ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
25239
+ // Available rows for the active tab's list. The sidebar chrome
25240
+ // takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
25241
+ // spacers); the branches tab eats 3 more for its summary header
25242
+ // (Current / Worktree / spacer). Floor of 8 keeps short terminals
25243
+ // usable; tall terminals (40+ rows) get noticeably more items.
25244
+ const sidebarChrome = 10;
25245
+ const branchHeaderRows = tab === 'branches' ? 3 : 0;
25246
+ const visibleListCount = Math.max(8, bodyRows - sidebarChrome - branchHeaderRows);
23119
25247
  if (tab === 'status') {
23120
25248
  return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
23121
25249
  }
23122
- const lines = sidebarLines(context, contextStatus, tab, width - 6, state, theme);
23123
- return lines.map((line, index) => h(Text, {
23124
- key: `tab-content-${tab}-${index}`,
23125
- dimColor: !line.trim(),
23126
- }, truncate$1(` ${line}`, width - 4)));
25250
+ // Branches / tags / stashes / worktrees: render selectable rows so
25251
+ // ↑/↓ navigates within the sidebar list and Enter / per-entity keys
25252
+ // act on the cursored item without needing to drill into the
25253
+ // dedicated view (#791 follow-up — in-sidebar selection).
25254
+ const focused = state.focus === 'sidebar' && state.sidebarTab === tab;
25255
+ if (tab === 'branches') {
25256
+ if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
25257
+ return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
25258
+ }
25259
+ const branches = context.branches;
25260
+ if (!branches) {
25261
+ return [h(Text, { key: 'tab-branches-empty', dimColor: true }, ' Branches unavailable')];
25262
+ }
25263
+ const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
25264
+ const headerRows = [
25265
+ h(Text, { key: 'tab-branches-current', dimColor: true }, truncate$1(` Current: ${branches.currentBranch || '<detached>'}`, width - 4)),
25266
+ h(Text, { key: 'tab-branches-state', dimColor: true }, ` Worktree: ${branches.dirty ? 'dirty' : 'clean'}`),
25267
+ h(Text, { key: 'tab-branches-spacer' }, ''),
25268
+ ];
25269
+ return [
25270
+ ...headerRows,
25271
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
25272
+ ];
25273
+ }
25274
+ if (tab === 'tags') {
25275
+ if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
25276
+ return [h(Text, { key: 'tab-tags-loading', dimColor: true }, ' Loading tags…')];
25277
+ }
25278
+ const tags = sortTags(context.tags?.tags || [], state.tagSort);
25279
+ if (tags.length === 0) {
25280
+ return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
25281
+ }
25282
+ return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
25283
+ }
25284
+ if (tab === 'stashes') {
25285
+ if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
25286
+ return [h(Text, { key: 'tab-stashes-loading', dimColor: true }, ' Loading stashes…')];
25287
+ }
25288
+ const stashes = context.stashes?.stashes || [];
25289
+ if (stashes.length === 0) {
25290
+ return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
25291
+ }
25292
+ return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
25293
+ }
25294
+ // worktrees
25295
+ if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
25296
+ return [h(Text, { key: 'tab-worktrees-loading', dimColor: true }, ' Loading worktrees…')];
25297
+ }
25298
+ const worktrees = context.worktreeList?.worktrees || [];
25299
+ if (worktrees.length === 0) {
25300
+ return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
25301
+ }
25302
+ return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
25303
+ const marker = worktree.current ? '*' : ' ';
25304
+ const wstate = worktree.dirty ? 'dirty' : 'clean';
25305
+ return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
25306
+ }, 'tab-worktrees', visibleListCount);
25307
+ }
25308
+ /**
25309
+ * Render a sliding-window list of selectable sidebar rows. The cursor
25310
+ * highlights the row at `selectedIndex` only when `focused` is true so
25311
+ * an unfocused sidebar doesn't compete visually with the active panel.
25312
+ * Sliding window keeps the cursor in view as the user navigates a long
25313
+ * list; truncation hints surface the count of hidden rows.
25314
+ */
25315
+ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
25316
+ if (items.length === 0)
25317
+ return [];
25318
+ const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
25319
+ const elements = [];
25320
+ if (window.truncatedAbove > 0) {
25321
+ elements.push(h(Text, {
25322
+ key: `${keyPrefix}-trunc-above`,
25323
+ dimColor: true,
25324
+ }, truncate$1(` … ${window.truncatedAbove} more above`, width - 4)));
25325
+ }
25326
+ for (let offset = 0; offset < window.size; offset += 1) {
25327
+ const index = window.start + offset;
25328
+ if (index >= items.length)
25329
+ break;
25330
+ const isSelected = focused && index === selectedIndex;
25331
+ const text = toRowText(items[index], index);
25332
+ elements.push(h(Text, {
25333
+ key: `${keyPrefix}-row-${index}`,
25334
+ backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
25335
+ inverse: isSelected,
25336
+ }, truncate$1(` ${text}`, width - 4)));
25337
+ }
25338
+ if (window.truncatedBelow > 0) {
25339
+ elements.push(h(Text, {
25340
+ key: `${keyPrefix}-trunc-below`,
25341
+ dimColor: true,
25342
+ }, truncate$1(` … ${window.truncatedBelow} more below`, width - 4)));
25343
+ }
25344
+ return elements;
23127
25345
  }
23128
25346
  function renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme) {
23129
25347
  if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
@@ -23181,6 +25399,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
23181
25399
  if (state.activeView === 'worktrees') {
23182
25400
  return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
23183
25401
  }
25402
+ if (state.activeView === 'pull-request') {
25403
+ return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
25404
+ }
23184
25405
  return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
23185
25406
  }
23186
25407
  function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
@@ -23230,15 +25451,46 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
23230
25451
  }))
23231
25452
  : visible.items.map((item, index) => {
23232
25453
  if (item.type === 'graph') {
25454
+ if (item.laneSegments && !theme.ascii) {
25455
+ return h(Text, { key: `graph-${index}-${item.graph}` }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`));
25456
+ }
23233
25457
  return h(Text, {
23234
25458
  key: `graph-${index}-${item.graph}`,
23235
25459
  color: theme.noColor ? undefined : theme.colors.muted,
23236
25460
  dimColor: theme.noColor,
23237
25461
  }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
23238
25462
  }
23239
- return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index);
25463
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
23240
25464
  }));
23241
25465
  }
25466
+ /**
25467
+ * Render `LaneSegment[]` as a flat list of Text spans, one per lane
25468
+ * (#791 stage 2). Each segment paints in its lane's palette color so
25469
+ * the eye can follow a branch column-by-column; segments without a
25470
+ * lane id (spaces, padding, decorations) fall back to the muted graph
25471
+ * color so they visually recede.
25472
+ *
25473
+ * Final padding is appended as its own span so callers do not need to
25474
+ * pre-pad the graph string before computing lane segments.
25475
+ */
25476
+ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
25477
+ const muted = theme.noColor ? undefined : theme.colors.muted;
25478
+ const elements = [];
25479
+ let totalLen = 0;
25480
+ segments.forEach((seg, idx) => {
25481
+ const laneColor = getLaneColor(seg.laneId, theme);
25482
+ elements.push(h(Text, {
25483
+ key: `${keyPrefix}-${idx}`,
25484
+ color: laneColor ?? muted,
25485
+ dimColor: theme.noColor && seg.laneId === undefined,
25486
+ }, seg.text));
25487
+ totalLen += seg.text.length;
25488
+ });
25489
+ if (padTo > totalLen) {
25490
+ elements.push(h(Text, { key: `${keyPrefix}-pad` }, ' '.repeat(padTo - totalLen)));
25491
+ }
25492
+ return elements;
25493
+ }
23242
25494
  /**
23243
25495
  * Render a single commit row with each segment in its own colored span.
23244
25496
  * Graph chars render in `theme.colors.muted` so the topology visually
@@ -23251,8 +25503,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
23251
25503
  * Truncation is per-segment so the variable-length message field gets
23252
25504
  * the leftover budget after fixed segments are accounted for.
23253
25505
  */
23254
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index) {
23255
- const renderedGraph = substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii });
25506
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
23256
25507
  const refs = formatInkRefLabels(commit.refs);
23257
25508
  const totalWidth = 140;
23258
25509
  const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
@@ -23261,11 +25512,17 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
23261
25512
  const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
23262
25513
  const accent = theme.noColor ? undefined : theme.colors.accent;
23263
25514
  const muted = theme.noColor ? undefined : theme.colors.muted;
25515
+ // Lane-colored graph spans when full graph mode + non-ASCII rendering
25516
+ // is in play; otherwise fall back to the legacy single-muted span so
25517
+ // compact mode and legacy terminals stay visually unchanged.
25518
+ const graphChildren = laneSegments && !theme.ascii
25519
+ ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `c${index}`)
25520
+ : [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
23264
25521
  return h(Text, {
23265
25522
  key: `${commit.hash}-${index}`,
23266
25523
  backgroundColor: selectedBg,
23267
25524
  inverse: selected,
23268
- }, h(Text, { color: muted, dimColor: theme.noColor }, renderedGraph), ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message), refs ? h(Text, { color: accent }, refs) : null);
25525
+ }, ...graphChildren, ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message), refs ? h(Text, { color: accent }, refs) : null);
23269
25526
  }
23270
25527
  /**
23271
25528
  * Render the synthetic "(+) new commit" affordance shown above the real
@@ -23498,11 +25755,35 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
23498
25755
  const cursor = isSelected ? '>' : ' ';
23499
25756
  const marker = branchRowMarker(branch, { ascii: theme.ascii });
23500
25757
  const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
25758
+ const lastTouched = formatBranchLastTouched(branch.date, new Date());
25759
+ // Split the row into spans so the timestamp stays dim even on the
25760
+ // currently-selected (bold) row. The leading marker + name keep
25761
+ // their original column widths; the timestamp is right-padded so
25762
+ // the divergence column stays aligned across rows.
25763
+ const namePadded = branch.shortName.padEnd(28);
25764
+ const timestampPadded = lastTouched.padEnd(8);
25765
+ const lineDim = !isSelected && !branch.current;
25766
+ const head = `${cursor} ${marker} ${namePadded} `;
25767
+ const trailingDivergence = divergence ? ` ${divergence}` : '';
25768
+ // Truncate the assembled line cooperatively so we never overflow
25769
+ // the panel; the timestamp is short and the divergence is the
25770
+ // most expendable, but the existing 140 cap is ample.
25771
+ const fullText = `${head}${timestampPadded}${trailingDivergence}`;
25772
+ const truncated = truncate$1(fullText, 140);
25773
+ // If truncation chopped into the timestamp/divergence portion,
25774
+ // fall back to a single Text to keep the visible width honest.
25775
+ if (truncated !== fullText) {
25776
+ return h(Text, {
25777
+ key: `branch-${index}`,
25778
+ bold: isSelected,
25779
+ dimColor: lineDim,
25780
+ }, truncated);
25781
+ }
23501
25782
  return h(Text, {
23502
25783
  key: `branch-${index}`,
23503
25784
  bold: isSelected,
23504
- dimColor: !isSelected && !branch.current,
23505
- }, truncate$1(`${cursor} ${marker} ${branch.shortName.padEnd(28)} ${divergence}`, 140));
25785
+ dimColor: lineDim,
25786
+ }, head, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
23506
25787
  });
23507
25788
  return h(Box, {
23508
25789
  borderColor: focusBorderColor(theme, focused),
@@ -23655,6 +25936,98 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
23655
25936
  width,
23656
25937
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Worktrees', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
23657
25938
  }
25939
+ /**
25940
+ * Pull-request action panel (#783) — renders the current branch's PR
25941
+ * with header, checks table, reviews summary, and a body preview.
25942
+ * Action keys (m / x / a / R / c / O) are wired in inkInput.ts and
25943
+ * surfaced via the footer; this renderer is read-only.
25944
+ *
25945
+ * Three loading / fallback states matter:
25946
+ * - Provider data still loading → "Loading pull request..."
25947
+ * - GitHub remote present but no PR for the current branch → empty
25948
+ * state hint pointing the user at `C` to create one.
25949
+ * - GitHub CLI missing / unauthenticated → unavailable hint.
25950
+ */
25951
+ function renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
25952
+ const { Box, Text } = components;
25953
+ const focused = state.focus === 'commits';
25954
+ const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
25955
+ const pullRequestOverview = context.pullRequest;
25956
+ // Use the dedicated `pullRequest` overview only — the `provider`
25957
+ // shape carries a slimmer ProviderPullRequestStatus that lacks
25958
+ // url / headRefName / body / mergeable / reviews. The dedicated
25959
+ // overview hits `gh pr view --json` with the full enriched field
25960
+ // list (PULL_REQUEST_VIEW_JSON_FIELDS) so the panel has everything.
25961
+ const pr = pullRequestOverview?.currentPullRequest;
25962
+ const muted = theme.noColor ? undefined : theme.colors.muted;
25963
+ const accent = theme.noColor ? undefined : theme.colors.accent;
25964
+ const containerProps = {
25965
+ borderColor: focusBorderColor(theme, focused),
25966
+ borderStyle: theme.borderStyle,
25967
+ flexDirection: 'column',
25968
+ flexShrink: 0,
25969
+ paddingX: 1,
25970
+ width,
25971
+ };
25972
+ if (loading && !pr) {
25973
+ return h(Box, containerProps, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Pull request', focused)), h(Text, { dimColor: true }, 'loading')), h(Text, { dimColor: true }, formatLogInkLoading({ resource: 'pull request' })));
25974
+ }
25975
+ if (!pr) {
25976
+ const hint = pullRequestOverview?.message
25977
+ || 'No pull request detected for this branch. Press `C` (or `:create-pr`) to create one.';
25978
+ return h(Box, containerProps, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Pull request', focused)), h(Text, { dimColor: true }, 'no PR')), h(Text, { dimColor: true }, truncate$1(hint, width - 4)));
25979
+ }
25980
+ const checks = summarizePullRequestChecks(pr.statusCheckRollup);
25981
+ const reviews = summarizePullRequestReviews(pr.reviews, pr.reviewDecision);
25982
+ const checkRows = buildPullRequestCheckRows(pr.statusCheckRollup, { ascii: theme.ascii });
25983
+ const checkColor = (s) => {
25984
+ if (theme.noColor)
25985
+ return undefined;
25986
+ if (s === 'success')
25987
+ return theme.colors.success;
25988
+ if (s === 'failure')
25989
+ return theme.colors.danger;
25990
+ if (s === 'pending')
25991
+ return theme.colors.warning;
25992
+ return theme.colors.muted;
25993
+ };
25994
+ // Reserve a few rows for the header/section labels; the rest go to
25995
+ // the checks table. Body preview gets the leftover rows so the
25996
+ // surface stays vertically balanced even on tall terminals.
25997
+ const checkBudget = Math.max(3, Math.min(checkRows.length, Math.floor(bodyRows / 2)));
25998
+ const visibleChecks = checkRows.slice(0, checkBudget);
25999
+ const truncatedChecks = checkRows.length - visibleChecks.length;
26000
+ const bodyPreviewBudget = Math.max(2, bodyRows - 8 - visibleChecks.length);
26001
+ const bodyLines = (pr.body || '').split(/\r?\n/).filter((line) => line.trim().length > 0);
26002
+ const visibleBodyLines = bodyLines.slice(0, bodyPreviewBudget);
26003
+ const truncatedBodyLines = bodyLines.length - visibleBodyLines.length;
26004
+ const headerRight = `#${pr.number} · ${pr.headRefName} → ${pr.baseRefName}`;
26005
+ const stateLine = formatPullRequestStateLine(pr);
26006
+ const author = pr.author ? `by @${pr.author}` : '';
26007
+ return h(Box, containerProps, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Pull request', focused)), h(Text, { dimColor: true }, headerRight)), h(Text, undefined, truncate$1(pr.title, width - 4)), h(Text, { dimColor: true }, truncate$1(`${stateLine}${author ? ` · ${author}` : ''}`, width - 4)), h(Text, undefined, ''),
26008
+ // Checks section
26009
+ h(Text, { bold: true, color: accent }, 'Checks'), h(Text, { dimColor: true }, truncate$1(` ${formatPullRequestChecksSummary(checks, { ascii: theme.ascii })}`, width - 4)), ...visibleChecks.map((row, index) => h(Text, {
26010
+ key: `pr-check-${index}`,
26011
+ color: checkColor(row.status),
26012
+ }, truncate$1(` ${row.glyph} ${row.name.padEnd(28)} ${row.detail}`, width - 4))), ...(truncatedChecks > 0
26013
+ ? [h(Text, { key: 'pr-checks-trunc', dimColor: true }, truncate$1(` … ${truncatedChecks} more`, width - 4))]
26014
+ : []), h(Text, undefined, ''),
26015
+ // Reviews section
26016
+ h(Text, { bold: true, color: accent }, 'Reviews'), h(Text, { dimColor: true }, truncate$1(` ${formatPullRequestReviewsSummary(reviews)}`, width - 4)), h(Text, undefined, ''),
26017
+ // Body preview
26018
+ ...(visibleBodyLines.length > 0
26019
+ ? [
26020
+ h(Text, { key: 'pr-body-label', bold: true, color: accent }, 'Description'),
26021
+ ...visibleBodyLines.map((line, index) => h(Text, {
26022
+ key: `pr-body-${index}`,
26023
+ color: muted,
26024
+ }, truncate$1(` ${line}`, width - 4))),
26025
+ ...(truncatedBodyLines > 0
26026
+ ? [h(Text, { key: 'pr-body-trunc', dimColor: true }, truncate$1(` … ${truncatedBodyLines} more lines`, width - 4))]
26027
+ : []),
26028
+ ]
26029
+ : []));
26030
+ }
23658
26031
  /**
23659
26032
  * Filter input cursor for the promoted views (branches/tags/stash).
23660
26033
  * History already shows the same `filter: foo_` affordance in its header
@@ -23685,6 +26058,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23685
26058
  // cherry-picks the file at the cursor.
23686
26059
  if (state.diffSource === 'stash') {
23687
26060
  const lines = stashDiffLines || [];
26061
+ const splitActive = isSplitDiffViable(state, width);
26062
+ const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
23688
26063
  const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
23689
26064
  const stashFiles = parseStashDiffFiles(lines);
23690
26065
  const fileCount = stashFiles.length;
@@ -23705,11 +26080,19 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23705
26080
  const currentFileIndex = currentFile
23706
26081
  ? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
23707
26082
  : -1;
23708
- const headerLines = stashDiffLoading
23709
- ? [`Loading diff for ${state.stashDiffRef || 'stash'}...`]
26083
+ // Look up the active stash entry so the panel header can show a
26084
+ // human-identifier instead of the raw `stash@{<iso-date>}` ref.
26085
+ // The git ref is the timestamp form (we fetch with --date=iso for
26086
+ // stable parsing) which reads as noise in the title bar; the
26087
+ // message + branch + index combination is what the user wrote down
26088
+ // when they ran `git stash`. Body still shows the full ref so it
26089
+ // stays unambiguous.
26090
+ const stashIdentity = formatStashHeaderIdentity(state.stashDiffRef, context.stashes?.stashes);
26091
+ const baseHeaderLines = stashDiffLoading
26092
+ ? [`Loading diff for ${stashIdentity.subtitle}...`]
23710
26093
  : lines.length
23711
26094
  ? [
23712
- `Stash: ${state.stashDiffRef || ''}`,
26095
+ stashIdentity.bodyLine,
23713
26096
  fileCount > 0 && currentFile
23714
26097
  ? `File ${currentFileIndex + 1}/${fileCount}: ${currentFile.path}`
23715
26098
  : 'No files in this stash.',
@@ -23717,6 +26100,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23717
26100
  '',
23718
26101
  ]
23719
26102
  : ['No diff to display for this stash.'];
26103
+ const headerLines = splitRequestedButTooNarrow
26104
+ ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
26105
+ : baseHeaderLines;
26106
+ const stashBodyNodes = stashDiffLoading || !lines.length
26107
+ ? []
26108
+ : splitActive
26109
+ ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
26110
+ : visibleLines.map((line, index) => h(Text, {
26111
+ key: `stash-diff-line-${state.diffPreviewOffset + index}`,
26112
+ ...diffLineProps(line, theme),
26113
+ }, truncate$1(line, width - 4)));
23720
26114
  return h(Box, {
23721
26115
  borderColor: focusBorderColor(theme, focused),
23722
26116
  borderStyle: theme.borderStyle,
@@ -23724,15 +26118,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23724
26118
  flexShrink: 0,
23725
26119
  paddingX: 1,
23726
26120
  width,
23727
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash diff', focused)), h(Text, { dimColor: true }, state.stashDiffRef || 'no stash')), ...headerLines.map((line, index) => h(Text, {
26121
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(splitActive ? 'Stash diff (split)' : 'Stash diff', focused)), h(Text, { dimColor: true }, stashIdentity.subtitle)), ...headerLines.map((line, index) => h(Text, {
23728
26122
  key: `stash-diff-header-${index}`,
23729
26123
  dimColor: index > 0,
23730
- }, truncate$1(line, width - 4))), ...(stashDiffLoading || !lines.length
23731
- ? []
23732
- : visibleLines.map((line, index) => h(Text, {
23733
- key: `stash-diff-line-${state.diffPreviewOffset + index}`,
23734
- ...diffLineProps(line, theme),
23735
- }, truncate$1(line, width - 4)))));
26124
+ }, truncate$1(line, width - 4))), ...stashBodyNodes);
23736
26125
  }
23737
26126
  // diffSource disambiguates: 'commit' was set when the user opened the
23738
26127
  // diff via history → Enter (read-only commit-diff explore), 'worktree'
@@ -23743,6 +26132,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23743
26132
  (state.diffSource === undefined && !worktreeFile && Boolean(selectedDetailFile));
23744
26133
  if (useCommitDiff) {
23745
26134
  const previewHunks = filePreview?.hunks || [];
26135
+ const splitActive = isSplitDiffViable(state, width);
26136
+ const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
23746
26137
  const visiblePreviewHunks = previewHunks.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
23747
26138
  const hunkCount = commitDiffHunkOffsets?.length || 0;
23748
26139
  const currentHunkIndex = hunkCount > 0
@@ -23753,7 +26144,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23753
26144
  const currentHunkLabel = hunkCount > 0
23754
26145
  ? `Hunk ${Math.min(hunkCount - currentHunkIndex, hunkCount)}/${hunkCount}`
23755
26146
  : 'No hunks for this file.';
23756
- const headerLines = filePreviewLoading
26147
+ const baseHeaderLines = filePreviewLoading
23757
26148
  ? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
23758
26149
  : previewHunks.length
23759
26150
  ? [
@@ -23763,6 +26154,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23763
26154
  '',
23764
26155
  ]
23765
26156
  : ['No diff preview available for this file.'];
26157
+ const headerLines = splitRequestedButTooNarrow
26158
+ ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
26159
+ : baseHeaderLines;
26160
+ const commitBodyNodes = filePreviewLoading || !previewHunks.length
26161
+ ? []
26162
+ : splitActive
26163
+ ? renderSplitDiffBody(h, components, visiblePreviewHunks, state.diffPreviewOffset, width, theme, 'commit-diff-split')
26164
+ : visiblePreviewHunks.map((line, index) => h(Text, {
26165
+ key: `diff-surface-line-${state.diffPreviewOffset + index}`,
26166
+ ...diffLineProps(line, theme),
26167
+ }, truncate$1(line, 140)));
23766
26168
  return h(Box, {
23767
26169
  borderColor: focusBorderColor(theme, focused),
23768
26170
  borderStyle: theme.borderStyle,
@@ -23770,15 +26172,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23770
26172
  flexShrink: 0,
23771
26173
  paddingX: 1,
23772
26174
  width,
23773
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)), h(Text, { dimColor: true }, selectedDetailFile?.path || 'no file')), ...headerLines.map((line, index) => h(Text, {
26175
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(splitActive ? 'Diff (split)' : 'Diff', focused)), h(Text, { dimColor: true }, selectedDetailFile?.path || 'no file')), ...headerLines.map((line, index) => h(Text, {
23774
26176
  key: `diff-surface-header-${index}`,
23775
26177
  dimColor: index > 0,
23776
- }, truncate$1(line, 140))), ...(filePreviewLoading || !previewHunks.length
23777
- ? []
23778
- : visiblePreviewHunks.map((line, index) => h(Text, {
23779
- key: `diff-surface-line-${state.diffPreviewOffset + index}`,
23780
- ...diffLineProps(line, theme),
23781
- }, truncate$1(line, 140)))));
26178
+ }, truncate$1(line, 140))), ...commitBodyNodes);
23782
26179
  }
23783
26180
  const diffLines = worktreeDiff?.lines || [];
23784
26181
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
@@ -23819,7 +26216,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23819
26216
  }, truncate$1(line, 140)))
23820
26217
  : []));
23821
26218
  }
23822
- function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme) {
26219
+ function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme) {
23823
26220
  const focused = state.focus === 'detail';
23824
26221
  if (state.showHelp) {
23825
26222
  return renderHelpPanel(h, components, state, width, theme, focused);
@@ -23876,16 +26273,11 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
23876
26273
  if (state.activeView === 'stash') {
23877
26274
  return renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
23878
26275
  }
23879
- return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme, focused);
26276
+ return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
23880
26277
  }
23881
- function renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, theme, focused) {
26278
+ function renderHistoryInspector(h, components, state, context, _contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, tabbed, theme, focused) {
23882
26279
  const { Box, Text } = components;
23883
26280
  const selected = getSelectedInkCommit(state);
23884
- const workflowSections = getLogInkWorkflowSections({
23885
- ...context,
23886
- contextLoading: isLogInkContextLoading(contextStatus),
23887
- selectedCommit: selected,
23888
- });
23889
26281
  if (!detail) {
23890
26282
  const fallbackLines = [
23891
26283
  selected?.message || 'No commit selected.',
@@ -23901,7 +26293,7 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
23901
26293
  }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
23902
26294
  key: `detail-${index}`,
23903
26295
  dimColor: index > 1,
23904
- }, truncate$1(line, width - 4))));
26296
+ }, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
23905
26297
  }
23906
26298
  const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
23907
26299
  // P5.1 — link the commit hash and each ref out to GitHub when we know
@@ -23912,18 +26304,26 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
23912
26304
  const refNodes = detail.refs.length
23913
26305
  ? renderInspectorRefs(h, Text, detail.refs, repository)
23914
26306
  : null;
26307
+ // Inspector reorder (PR — drop duplicative Workflows trailer):
26308
+ // 1. Commit message (the headline of what you're looking at)
26309
+ // 2. Metadata (hash / author / date / refs / stats)
26310
+ // 3. Body preview (up to 8 lines now that the trailer is gone)
26311
+ // 4. Changed files list (cursored entry highlights)
26312
+ // 5. Actions cheat-sheet (per-entity keystrokes; destructive marked)
26313
+ // The Workflows: trailer that used to repeat the repo / branch /
26314
+ // status from the top header and left sidebar is intentionally gone.
23915
26315
  const headerNodes = [
23916
26316
  h(Text, { key: 'detail-msg' }, truncate$1(detail.message, width - 4)),
23917
26317
  h(Text, { key: 'detail-spacer-1' }, ''),
23918
26318
  h(Text, { key: 'detail-commit', dimColor: true }, 'Commit: ', commitLink),
23919
26319
  h(Text, { key: 'detail-author', dimColor: true }, truncate$1(`Author: ${detail.author}`, width - 4)),
23920
- h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
26320
+ h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
23921
26321
  refNodes
23922
- ? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
23923
- : h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
23924
- h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(statLine, width - 4)),
26322
+ ? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
26323
+ : h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
26324
+ h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(`Stats: ${statLine}`, width - 4)),
23925
26325
  h(Text, { key: 'detail-spacer-2' }, ''),
23926
- ...(detail.body ? detail.body.split('\n').slice(0, 6) : ['No commit body.']).map((line, index) => h(Text, {
26326
+ ...(detail.body ? detail.body.split('\n').slice(0, 8) : ['No commit body.']).map((line, index) => h(Text, {
23927
26327
  key: `detail-body-${index}`,
23928
26328
  dimColor: true,
23929
26329
  }, truncate$1(line, width - 4))),
@@ -23932,24 +26332,85 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
23932
26332
  ];
23933
26333
  const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
23934
26334
  const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
23935
- const trailerLines = [
23936
- '',
23937
- 'Workflows:',
23938
- ...workflowSections.flatMap((section) => [
23939
- section.title,
23940
- ...section.lines.slice(0, 3).map((line) => ` ${line}`),
23941
- ]).slice(0, 12),
23942
- ];
26335
+ // Tabbed mode (#806 follow-up — short terminals): render only the
26336
+ // active inspector tab with a `[Inspector] Actions` header so the
26337
+ // user knows what they're seeing and how to switch (`[/]` while
26338
+ // focus is on the inspector). Tall terminals stack both sections
26339
+ // as before.
26340
+ if (tabbed) {
26341
+ const activeTab = state.inspectorTab;
26342
+ const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
26343
+ bold: activeTab === 'inspector',
26344
+ dimColor: activeTab !== 'inspector',
26345
+ }, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
26346
+ bold: activeTab === 'actions',
26347
+ dimColor: activeTab !== 'actions',
26348
+ }, activeTab === 'actions' ? '[Actions]' : ' Actions '));
26349
+ return h(Box, {
26350
+ borderColor: focusBorderColor(theme, focused),
26351
+ borderStyle: theme.borderStyle,
26352
+ flexDirection: 'column',
26353
+ width,
26354
+ paddingX: 1,
26355
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
26356
+ ? [...headerNodes, ...fileListNodes]
26357
+ : renderInspectorActionsSection(h, Text, 'history-commit', width, theme)));
26358
+ }
23943
26359
  return h(Box, {
23944
26360
  borderColor: focusBorderColor(theme, focused),
23945
26361
  borderStyle: theme.borderStyle,
23946
26362
  flexDirection: 'column',
23947
26363
  width,
23948
26364
  paddingX: 1,
23949
- }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...trailerLines.map((line, index) => h(Text, {
23950
- key: `detail-trailer-${index}`,
23951
- dimColor: index > 0,
23952
- }, truncate$1(line, width - 4))));
26365
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
26366
+ }
26367
+ /**
26368
+ * Render the trailing "Actions:" section that surfaces which keystrokes
26369
+ * apply to whatever the inspector is focused on. Keys are colored with
26370
+ * `theme.colors.accent` so they pop as the actionable element. Destructive
26371
+ * actions get the danger color plus a `[!]` marker so they don't blend
26372
+ * into the cherry-pick / yank rows.
26373
+ *
26374
+ * Truncates labels when the inspector is narrow (down to the 26-cell
26375
+ * minimum from `getLogInkLayout`) so an overflowing label never wraps and
26376
+ * collides with the next row.
26377
+ */
26378
+ function renderInspectorActionsSection(h, Text, context, width, theme) {
26379
+ const actions = getInspectorActions(context);
26380
+ if (!actions.length)
26381
+ return [];
26382
+ // Width budget for each row: subtract padding + " " gutter, the key
26383
+ // column (left-padded to 5 cells so labels align), the " " gap
26384
+ // between key and label, and the optional " [!]" suffix (5 cells).
26385
+ const KEY_COLUMN = 5;
26386
+ const GAP = ' ';
26387
+ const DESTRUCTIVE_SUFFIX = ' [!]';
26388
+ const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
26389
+ const nodes = [
26390
+ h(Text, { key: 'actions-spacer' }, ''),
26391
+ h(Text, { key: 'actions-title' }, 'Actions:'),
26392
+ ...actions.map((action, index) => {
26393
+ const keyCell = action.key.padEnd(KEY_COLUMN);
26394
+ const label = truncate$1(action.label, labelBudget);
26395
+ const children = [
26396
+ h(Text, {
26397
+ key: `actions-${index}-key`,
26398
+ color: action.destructive ? theme.colors.danger : theme.colors.accent,
26399
+ }, keyCell),
26400
+ GAP,
26401
+ label,
26402
+ ];
26403
+ if (action.destructive) {
26404
+ children.push(h(Text, {
26405
+ key: `actions-${index}-mark`,
26406
+ color: theme.colors.danger,
26407
+ dimColor: false,
26408
+ }, DESTRUCTIVE_SUFFIX));
26409
+ }
26410
+ return h(Text, { key: `actions-${index}` }, ...children);
26411
+ }),
26412
+ ];
26413
+ return nodes;
23953
26414
  }
23954
26415
  /**
23955
26416
  * Build a commit URL for the repo when GitHub provider info is available.
@@ -24253,16 +26714,34 @@ function renderInputPromptPanel(h, components, state, width, theme, focused) {
24253
26714
  if (!prompt) {
24254
26715
  return h(Box, { width });
24255
26716
  }
26717
+ const accent = theme.noColor ? undefined : theme.colors.accent;
26718
+ // Multi-line prompts (#806) split on newline and render one Text
26719
+ // row per buffer line — the cursor sits at the end of the last
26720
+ // line via the trailing `_`. Single-line prompts collapse to the
26721
+ // original one-row layout for muscle-memory continuity.
26722
+ const promptLines = prompt.multiline ? prompt.value.split('\n') : [prompt.value];
26723
+ if (promptLines.length === 0) {
26724
+ promptLines.push('');
26725
+ }
26726
+ const valueRows = promptLines.map((line, index) => {
26727
+ const isLast = index === promptLines.length - 1;
26728
+ const display = isLast ? `${line}_` : line;
26729
+ return h(Text, {
26730
+ key: `prompt-line-${index}`,
26731
+ bold: true,
26732
+ color: accent,
26733
+ }, truncate$1(display, width - 4));
26734
+ });
26735
+ const hint = prompt.multiline
26736
+ ? 'Enter newline · Ctrl+d submit · Esc cancel · Ctrl+u clear'
26737
+ : 'Enter submit · Esc cancel · Ctrl+u clear';
24256
26738
  return h(Box, {
24257
26739
  borderColor: focusBorderColor(theme, focused),
24258
26740
  borderStyle: theme.borderStyle,
24259
26741
  flexDirection: 'column',
24260
26742
  width,
24261
26743
  paddingX: 1,
24262
- }, h(Text, { bold: true }, panelTitle('Prompt', focused)), h(Text, { dimColor: true }, truncate$1(prompt.label, width - 4)), h(Text, undefined, ''), h(Text, {
24263
- bold: true,
24264
- color: theme.noColor ? undefined : theme.colors.accent,
24265
- }, truncate$1(`${prompt.value}_`, width - 4)), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Enter to submit · Esc to cancel · Ctrl+u to clear'));
26744
+ }, h(Text, { bold: true }, panelTitle('Prompt', focused)), h(Text, { dimColor: true }, truncate$1(prompt.label, width - 4)), h(Text, undefined, ''), ...valueRows, h(Text, undefined, ''), h(Text, { dimColor: true }, hint));
24266
26745
  }
24267
26746
  function renderConfirmationPanel(h, components, state, width, theme, focused) {
24268
26747
  const { Box, Text } = components;
@@ -24436,16 +26915,31 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
24436
26915
  ? [h(Text, { key: 'palette-recent-hint', dimColor: true }, '· marks recently-used')]
24437
26916
  : []), ...itemLines);
24438
26917
  }
24439
- function renderFooter(h, components, state, theme, idleTip) {
26918
+ function renderFooter(h, components, state, context, theme, idleTip) {
24440
26919
  const { Box, Text } = components;
26920
+ // Sidebar item count drives the per-tab footer hints — when items are
26921
+ // present the footer surfaces in-sidebar ops (checkout / apply / pop /
26922
+ // drop), otherwise it falls back to the generic "enter open" hint.
26923
+ const sidebarItemCount = (() => {
26924
+ switch (state.sidebarTab) {
26925
+ case 'branches': return context.branches?.localBranches.length;
26926
+ case 'tags': return context.tags?.tags.length;
26927
+ case 'stashes': return context.stashes?.stashes.length;
26928
+ case 'worktrees': return context.worktreeList?.worktrees.length;
26929
+ default: return undefined;
26930
+ }
26931
+ })();
24441
26932
  const hints = getLogInkFooterHints({
24442
26933
  activeView: state.activeView,
24443
26934
  diffSource: state.diffSource,
26935
+ diffViewMode: state.diffViewMode,
24444
26936
  filterMode: state.filterMode,
24445
26937
  focus: state.focus,
24446
26938
  pendingKey: state.pendingKey,
24447
26939
  showCommandPalette: state.showCommandPalette,
24448
26940
  showHelp: state.showHelp,
26941
+ sidebarTab: state.sidebarTab,
26942
+ sidebarItemCount,
24449
26943
  });
24450
26944
  // Real status messages always win; idle tips only fill the slot when it
24451
26945
  // would otherwise be empty.
@@ -24490,7 +26984,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
24490
26984
  const git = options.git || getRepo();
24491
26985
  const rows = options.rows || (await getLogRows(git, logArgv));
24492
26986
  await startInkInteractiveLog(git, rows, {}, {
24493
- appLabel: 'coco ui',
26987
+ appLabel: 'coco',
24494
26988
  idleTips: config.logTui?.idleTips,
24495
26989
  initialView: 'history',
24496
26990
  logArgv,
@@ -24503,7 +26997,7 @@ async function startCocoUi(argv) {
24503
26997
  const logArgv = createLogArgvFromUiArgv(argv);
24504
26998
  const rows = await getLogRows(git, logArgv);
24505
26999
  await startInkInteractiveLog(git, rows, {}, {
24506
- appLabel: 'coco ui',
27000
+ appLabel: 'coco',
24507
27001
  idleTips: config.logTui?.idleTips,
24508
27002
  initialView: argv.view || 'history',
24509
27003
  logArgv,