git-coco 0.37.0 → 0.38.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 +2304 -257
  2. package/dist/index.js +2303 -256
  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.38.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
  });
@@ -14595,6 +14903,34 @@ function getLogInkWorkflowActions() {
14595
14903
  kind: 'destructive',
14596
14904
  requiresConfirmation: true,
14597
14905
  },
14906
+ {
14907
+ // Per-view-only: scoped to commit-diff and stash-diff explores in
14908
+ // inkInput (key: H). The action is non-destructive in the sense
14909
+ // that `git apply` won't lose any data — `git apply -R` undoes
14910
+ // it cleanly — so it bypasses the y-confirm path. The patch text
14911
+ // travels via the action's `payload` field. Empty key keeps the
14912
+ // workflow palette-discoverable without registering a global
14913
+ // hotkey (the palette path can't synthesize the patch text and
14914
+ // surfaces a hint instead — actual dispatch is from H in diff
14915
+ // view).
14916
+ id: 'apply-hunk-worktree',
14917
+ key: '',
14918
+ label: 'Apply hunk to worktree',
14919
+ description: 'Extract the hunk under the cursor and apply it to the working tree via `git apply`.',
14920
+ kind: 'normal',
14921
+ requiresConfirmation: false,
14922
+ },
14923
+ {
14924
+ // Sibling of `apply-hunk-worktree` — same extraction path, but
14925
+ // `git apply --cached` so the patch lands in the index without
14926
+ // touching the worktree. Bound to the `gH` chord in inkInput.
14927
+ id: 'apply-hunk-index',
14928
+ key: '',
14929
+ label: 'Apply hunk to index',
14930
+ description: 'Extract the hunk under the cursor and apply it to the index via `git apply --cached`.',
14931
+ kind: 'normal',
14932
+ requiresConfirmation: false,
14933
+ },
14598
14934
  {
14599
14935
  id: 'open-pr',
14600
14936
  key: 'O',
@@ -14687,6 +15023,88 @@ function getLogInkWorkflowActions() {
14687
15023
  kind: 'destructive',
14688
15024
  requiresConfirmation: true,
14689
15025
  },
15026
+ // #783 — full PR action panel. All five entries are palette-only
15027
+ // (`key: ''`) — actual dispatch is per-view scoped in inkInput so
15028
+ // the keys stay free outside the pull-request view. Merge / close /
15029
+ // approve / request-changes route through the y-confirm path
15030
+ // because each is irreversible (or near-irreversible) once gh
15031
+ // publishes it; comment is a free-form prompt with no extra
15032
+ // confirmation since the body itself is the affirmative action.
15033
+ {
15034
+ id: 'merge-pr',
15035
+ key: '',
15036
+ label: 'Merge pull request',
15037
+ description: 'Merge the current branch\'s pull request (prompts for merge / squash / rebase, then confirms).',
15038
+ kind: 'destructive',
15039
+ requiresConfirmation: true,
15040
+ },
15041
+ {
15042
+ id: 'close-pr',
15043
+ key: '',
15044
+ label: 'Close pull request',
15045
+ description: 'Close the current pull request without merging.',
15046
+ kind: 'destructive',
15047
+ requiresConfirmation: true,
15048
+ },
15049
+ {
15050
+ id: 'approve-pr',
15051
+ key: '',
15052
+ label: 'Approve pull request',
15053
+ description: 'Submit an approving review on the current pull request.',
15054
+ kind: 'normal',
15055
+ requiresConfirmation: true,
15056
+ },
15057
+ {
15058
+ id: 'request-changes-pr',
15059
+ key: '',
15060
+ label: 'Request changes on pull request',
15061
+ description: 'Submit a change-request review (prompts for the review body, then confirms).',
15062
+ kind: 'normal',
15063
+ requiresConfirmation: true,
15064
+ },
15065
+ {
15066
+ id: 'comment-pr',
15067
+ key: '',
15068
+ label: 'Comment on pull request',
15069
+ description: 'Add a comment to the current pull request (prompts for body).',
15070
+ kind: 'normal',
15071
+ requiresConfirmation: false,
15072
+ },
15073
+ {
15074
+ // Per-view-only: scoped to the history view in inkInput so `R`
15075
+ // doesn't fire elsewhere (it's also `R` for rename in branches
15076
+ // and delete-remote-tag in tags). Empty key keeps it
15077
+ // palette-discoverable without registering a global hotkey.
15078
+ id: 'revert-commit',
15079
+ key: '',
15080
+ label: 'Revert commit',
15081
+ description: 'Revert the cursored commit by adding an inverse commit on top of HEAD.',
15082
+ kind: 'destructive',
15083
+ requiresConfirmation: true,
15084
+ },
15085
+ {
15086
+ // Per-view-only: scoped to the history view in inkInput. Triggers
15087
+ // a mode prompt (soft / mixed / hard) before the reset runs so
15088
+ // `Z` alone never silently rewrites history.
15089
+ id: 'reset-to-commit',
15090
+ key: '',
15091
+ label: 'Reset to commit',
15092
+ description: 'Move the current branch tip to the cursored commit (prompts for soft / mixed / hard).',
15093
+ kind: 'destructive',
15094
+ requiresConfirmation: true,
15095
+ },
15096
+ {
15097
+ // Per-view-only: scoped to the history view in inkInput. `i`
15098
+ // (lowercase) is used instead of `I` so the existing `I`
15099
+ // ai-commit-summary workflow stays reachable on the history
15100
+ // view — `i` matches the `git rebase -i` flag mnemonic anyway.
15101
+ id: 'interactive-rebase',
15102
+ key: '',
15103
+ label: 'Interactive rebase',
15104
+ description: 'Start an interactive rebase from the cursored commit (opens $GIT_EDITOR for the todo list).',
15105
+ kind: 'destructive',
15106
+ requiresConfirmation: true,
15107
+ },
14690
15108
  {
14691
15109
  id: 'ai-commit-summary',
14692
15110
  key: 'I',
@@ -14708,6 +15126,15 @@ function getLogInkWorkflowActions() {
14708
15126
  ];
14709
15127
  }
14710
15128
  function getLogInkWorkflowActionByKey(inputValue) {
15129
+ // Workflow actions with an empty `key` are palette-only — they
15130
+ // exist so the command palette can surface them but should never
15131
+ // match a raw keystroke. Without this guard, any unbound key
15132
+ // (left/right arrow, function keys) that arrives with an empty
15133
+ // inputValue would `find()` the first empty-key entry —
15134
+ // `cherry-pick-commit` — and pop its confirmation dialog.
15135
+ if (!inputValue) {
15136
+ return undefined;
15137
+ }
14711
15138
  return getLogInkWorkflowActions().find((action) => action.key === inputValue);
14712
15139
  }
14713
15140
  function getLogInkWorkflowActionById(id) {
@@ -14837,6 +15264,13 @@ const LOG_INK_KEY_BINDINGS = [
14837
15264
  description: 'Toggle compact and full graph display.',
14838
15265
  contexts: ['normal', 'commits'],
14839
15266
  },
15267
+ {
15268
+ id: 'toggleDiffViewMode',
15269
+ keys: ['d'],
15270
+ label: 'split/unified',
15271
+ description: 'Toggle the diff view between unified and side-by-side split rendering. Falls back to unified on narrow terminals.',
15272
+ contexts: ['commits'],
15273
+ },
14840
15274
  {
14841
15275
  id: 'navigateHome',
14842
15276
  keys: ['gh'],
@@ -14893,6 +15327,13 @@ const LOG_INK_KEY_BINDINGS = [
14893
15327
  description: 'Push the linked worktrees view.',
14894
15328
  contexts: ['normal'],
14895
15329
  },
15330
+ {
15331
+ id: 'navigatePullRequest',
15332
+ keys: ['gp'],
15333
+ label: 'pull request',
15334
+ description: 'Push the dedicated pull-request action panel for the current branch.',
15335
+ contexts: ['normal'],
15336
+ },
14896
15337
  {
14897
15338
  id: 'navigateBack',
14898
15339
  keys: ['<', 'esc'],
@@ -15021,6 +15462,7 @@ const GLOBAL_BINDING_IDS = [
15021
15462
  'navigateTags',
15022
15463
  'navigateStash',
15023
15464
  'navigateWorktrees',
15465
+ 'navigatePullRequest',
15024
15466
  'navigateBack',
15025
15467
  ];
15026
15468
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -15081,8 +15523,37 @@ function getLogInkFooterHints(options) {
15081
15523
  };
15082
15524
  }
15083
15525
  if (options.focus === 'sidebar') {
15526
+ // Per-tab hints when the active tab has selectable items — the user
15527
+ // can act on the cursored entity without leaving the workstation
15528
+ // view. Status tab + empty content tabs fall back to the generic
15529
+ // "enter open" hint that drills into the dedicated view.
15530
+ const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
15531
+ if (itemsPresent && options.sidebarTab === 'branches') {
15532
+ return {
15533
+ contextual: ['↑/↓ branches', '←/→ tab', 'enter checkout', 'D delete', 'R rename', 'u upstream'],
15534
+ global: NORMAL_GLOBAL_HINTS,
15535
+ };
15536
+ }
15537
+ if (itemsPresent && options.sidebarTab === 'stashes') {
15538
+ return {
15539
+ contextual: ['↑/↓ stashes', '←/→ tab', 'enter diff', 'a apply', 'p pop', 'X drop'],
15540
+ global: NORMAL_GLOBAL_HINTS,
15541
+ };
15542
+ }
15543
+ if (itemsPresent && options.sidebarTab === 'tags') {
15544
+ return {
15545
+ contextual: ['↑/↓ tags', '←/→ tab', '+ new', 'P push', 'T delete'],
15546
+ global: NORMAL_GLOBAL_HINTS,
15547
+ };
15548
+ }
15549
+ if (itemsPresent && options.sidebarTab === 'worktrees') {
15550
+ return {
15551
+ contextual: ['↑/↓ worktrees', '←/→ tab', 'W remove'],
15552
+ global: NORMAL_GLOBAL_HINTS,
15553
+ };
15554
+ }
15084
15555
  return {
15085
- contextual: ['[/] tab', '1-5 jump', 'tab focus'],
15556
+ contextual: ['←/→ tab', '1-5 jump', 'enter open', 'tab focus'],
15086
15557
  global: NORMAL_GLOBAL_HINTS,
15087
15558
  };
15088
15559
  }
@@ -15099,17 +15570,23 @@ function getLogInkFooterHints(options) {
15099
15570
  };
15100
15571
  }
15101
15572
  if (options.activeView === 'diff') {
15573
+ // Surface what `d` will switch *to* — labels the next mode rather
15574
+ // than the current one so the hint reads as a verb. The split-mode
15575
+ // hint is only shown for the read-only diff sources (commit/stash);
15576
+ // the worktree diff stays unified-only for now.
15577
+ const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
15102
15578
  if (options.diffSource === 'stash') {
15103
15579
  return {
15104
- contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'o edit', 'y yank', 'esc back'],
15580
+ contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', 'o edit', splitToggleHint, 'y yank', 'esc back'],
15105
15581
  global: NORMAL_GLOBAL_HINTS,
15106
15582
  };
15107
15583
  }
15108
15584
  if (options.diffSource === 'commit') {
15109
15585
  // Commit-diff explore: read-only diff, but `c` cherry-picks the
15110
- // cursored file from the commit into the worktree.
15586
+ // cursored file from the commit into the worktree, and `H`
15587
+ // (or `gH` for index) applies just the cursored hunk.
15111
15588
  return {
15112
- contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'y/Y yank', 'esc back'],
15589
+ contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'y/Y yank', 'esc back'],
15113
15590
  global: NORMAL_GLOBAL_HINTS,
15114
15591
  };
15115
15592
  }
@@ -15148,8 +15625,23 @@ function getLogInkFooterHints(options) {
15148
15625
  global: NORMAL_GLOBAL_HINTS,
15149
15626
  };
15150
15627
  }
15628
+ if (options.activeView === 'pull-request') {
15629
+ return {
15630
+ // #783 — full PR action panel. Five mutating ops scoped to this
15631
+ // view: m / x / a / R / c, plus O for open-in-browser (already
15632
+ // a global). Each routes through y-confirm or an input prompt;
15633
+ // none fire silently.
15634
+ contextual: ['m merge', 'x close', 'a approve', 'R changes', 'c comment', 'O open', 'esc back'],
15635
+ global: NORMAL_GLOBAL_HINTS,
15636
+ };
15637
+ }
15151
15638
  return {
15152
- contextual: ['↑/↓ move', 'enter diff', 'c cherry-pick', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15639
+ // History view default hints. Mutating ops (`c` cherry-pick, `R`
15640
+ // revert, `Z` reset, `i` interactive-rebase) all route through a
15641
+ // y-confirm or mode prompt — none fire silently from the keystroke.
15642
+ // Grouped into a compact `c/R/Z/i mutate` chip so the footer stays
15643
+ // scannable; full descriptions live in `?` help and the palette.
15644
+ contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15153
15645
  global: NORMAL_GLOBAL_HINTS,
15154
15646
  };
15155
15647
  }
@@ -15292,39 +15784,6 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
15292
15784
  .map((entry) => entry.command);
15293
15785
  }
15294
15786
 
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
15787
  /**
15329
15788
  * OSC 8 hyperlink helpers for the Ink TUI (P5.1).
15330
15789
  *
@@ -15396,6 +15855,84 @@ function formatHyperlink(text, url, env = process.env) {
15396
15855
  return `${OSC_PREFIX}${url}${ST}${text}${OSC_PREFIX}${ST}`;
15397
15856
  }
15398
15857
 
15858
+ /**
15859
+ * Extract a single hunk from a unified-patch diff so it can be fed to
15860
+ * `git apply` (or `git apply --cached`) for a hunk-level cherry-pick.
15861
+ *
15862
+ * The TUI's diff explore views render two flavors of patch text:
15863
+ *
15864
+ * - stash-diff: full `git stash show -p` output, which includes
15865
+ * `diff --git`, `---`, `+++`, and one or more `@@ ... @@` hunks
15866
+ * per file.
15867
+ * - commit-diff: the per-file `filePreview.hunks` array, which is
15868
+ * hunks-only (no `diff --git` / `---` / `+++` headers).
15869
+ *
15870
+ * Either way, this helper walks `lines` from `cursorOffset` backwards
15871
+ * to find the most recent `@@` header, walks forward to the end of
15872
+ * that hunk's body, and synthesizes a fresh `diff --git` /
15873
+ * `---` / `+++` set using the caller-provided path. The output is a
15874
+ * complete, self-contained patch suitable for `git apply` without
15875
+ * having to preserve original headers from `lines`.
15876
+ */
15877
+ const HUNK_HEADER_PREFIX = '@@';
15878
+ const DIFF_GIT_PREFIX = 'diff --git ';
15879
+ /**
15880
+ * Find the index of the `@@` hunk header at or before `cursorOffset`.
15881
+ * Returns -1 when the cursor sits before the first hunk in the patch
15882
+ * (i.e. on a `diff --git` / `---` / `+++` header line) — caller treats
15883
+ * that as "no hunk at cursor" and surfaces a status message.
15884
+ */
15885
+ function findHunkHeaderAtOrBefore(lines, cursorOffset) {
15886
+ const start = Math.min(cursorOffset, lines.length - 1);
15887
+ for (let i = start; i >= 0; i -= 1) {
15888
+ if (lines[i]?.startsWith(HUNK_HEADER_PREFIX)) {
15889
+ return i;
15890
+ }
15891
+ }
15892
+ return -1;
15893
+ }
15894
+ /**
15895
+ * Walk forward from a hunk header to either the next `@@` header or
15896
+ * the next `diff --git` line — that's where this hunk's body ends.
15897
+ * The end index is exclusive (the line at `endIndex` is NOT part of
15898
+ * this hunk).
15899
+ */
15900
+ function findHunkBodyEnd(lines, headerIndex) {
15901
+ for (let i = headerIndex + 1; i < lines.length; i += 1) {
15902
+ const line = lines[i];
15903
+ if (line?.startsWith(HUNK_HEADER_PREFIX) || line?.startsWith(DIFF_GIT_PREFIX)) {
15904
+ return i;
15905
+ }
15906
+ }
15907
+ return lines.length;
15908
+ }
15909
+ function extractDiffHunk(input) {
15910
+ const { lines, cursorOffset, path } = input;
15911
+ if (!lines.length || !path) {
15912
+ return null;
15913
+ }
15914
+ const headerIndex = findHunkHeaderAtOrBefore(lines, cursorOffset);
15915
+ if (headerIndex < 0) {
15916
+ return null;
15917
+ }
15918
+ const bodyEnd = findHunkBodyEnd(lines, headerIndex);
15919
+ // Header itself + at least one body line. An empty hunk body would
15920
+ // mean the patch is malformed and `git apply` would reject it; bail
15921
+ // out early so the caller can surface a clear status message.
15922
+ if (bodyEnd <= headerIndex + 1) {
15923
+ return null;
15924
+ }
15925
+ const hunkLines = lines.slice(headerIndex, bodyEnd);
15926
+ const patchText = [
15927
+ `diff --git a/${path} b/${path}`,
15928
+ `--- a/${path}`,
15929
+ `+++ b/${path}`,
15930
+ ...hunkLines,
15931
+ '',
15932
+ ].join('\n');
15933
+ return { patchText };
15934
+ }
15935
+
15399
15936
  /**
15400
15937
  * Sort modes for the promoted views (P4.2).
15401
15938
  *
@@ -15765,6 +16302,7 @@ function createLogInkState(rows, options = {}) {
15765
16302
  sidebarTab: 'status',
15766
16303
  userSidebarTab: 'status',
15767
16304
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
16305
+ diffViewMode: 'unified',
15768
16306
  };
15769
16307
  }
15770
16308
  function getSelectedInkCommit(state) {
@@ -15902,6 +16440,7 @@ function applyLogInkAction(state, action) {
15902
16440
  kind: action.kind,
15903
16441
  label: action.label,
15904
16442
  value: action.initial || '',
16443
+ multiline: action.multiline,
15905
16444
  },
15906
16445
  pendingKey: undefined,
15907
16446
  };
@@ -15934,6 +16473,27 @@ function applyLogInkAction(state, action) {
15934
16473
  }
15935
16474
  case 'setHistoryFetchArgs':
15936
16475
  return { ...state, historyFetchArgs: action.value, pendingKey: undefined };
16476
+ case 'toggleDiffViewMode':
16477
+ // Reset the scroll offsets so the new mode opens at the top — long
16478
+ // lines wrap differently in split mode (the renderer truncates per
16479
+ // column instead of per row), so the saved offset can land on a
16480
+ // different visual line. Snap to the top is simpler than mapping
16481
+ // unified offsets to split offsets.
16482
+ return {
16483
+ ...state,
16484
+ diffViewMode: state.diffViewMode === 'unified' ? 'split' : 'unified',
16485
+ diffPreviewOffset: 0,
16486
+ worktreeDiffOffset: 0,
16487
+ pendingKey: undefined,
16488
+ };
16489
+ case 'setDiffViewMode':
16490
+ return {
16491
+ ...state,
16492
+ diffViewMode: action.value,
16493
+ diffPreviewOffset: 0,
16494
+ worktreeDiffOffset: 0,
16495
+ pendingKey: undefined,
16496
+ };
15937
16497
  case 'moveToBottom':
15938
16498
  return {
15939
16499
  ...state,
@@ -16212,12 +16772,151 @@ function applyLogInkAction(state, action) {
16212
16772
  }
16213
16773
  }
16214
16774
 
16775
+ /**
16776
+ * In-sidebar selection helpers (#791 follow-up — sidebar entity ops).
16777
+ *
16778
+ * The workstation sidebar's branches / tags / stashes / worktrees tabs
16779
+ * used to be read-only previews — to act on an entity the user had to
16780
+ * drill into the dedicated promoted view. With the per-entity ops
16781
+ * gated to also fire on `state.focus === 'sidebar'` plus a matching
16782
+ * `sidebarTab`, j/k navigates the visible list inside the sidebar
16783
+ * itself, Enter performs the primary action (checkout / open diff),
16784
+ * and the existing per-view secondary keys (a/p/X/D/R/u/+P) are now
16785
+ * reachable without leaving the workstation view.
16786
+ *
16787
+ * The sidebar accordion is short — the visible window for an active
16788
+ * tab is capped (defaults below) so a long branch list doesn't
16789
+ * collapse the rest of the chrome. When the cursor scrolls past the
16790
+ * visible window, this module produces a sliding window that keeps it
16791
+ * in view; the dedicated view stays the right home for "show me all
16792
+ * 80 branches at once."
16793
+ */
16794
+ const DEFAULT_SIDEBAR_VISIBLE = 8;
16795
+ /**
16796
+ * Compute the sliding window so that `selected` stays inside it while
16797
+ * the window remains anchored at the top whenever possible (so short
16798
+ * lists don't scroll for no reason). When the cursor moves past the
16799
+ * window, the window slides just enough to keep the cursor in view —
16800
+ * matching the commit history's `clampWindowStart` behaviour for
16801
+ * familiarity.
16802
+ */
16803
+ function getSidebarVisibleWindow(total, selected, visible = DEFAULT_SIDEBAR_VISIBLE) {
16804
+ const size = Math.max(1, Math.min(visible, total));
16805
+ if (total <= visible) {
16806
+ return { start: 0, size, truncatedAbove: 0, truncatedBelow: 0 };
16807
+ }
16808
+ const half = Math.floor(size / 2);
16809
+ const idealStart = selected - half;
16810
+ const maxStart = total - size;
16811
+ const start = Math.max(0, Math.min(idealStart, maxStart));
16812
+ return {
16813
+ start,
16814
+ size,
16815
+ truncatedAbove: start,
16816
+ truncatedBelow: total - (start + size),
16817
+ };
16818
+ }
16819
+ /**
16820
+ * True when an in-sidebar action (j/k move, Enter checkout, etc.)
16821
+ * should fire instead of the generic drill-in / tab-cycle behaviour.
16822
+ *
16823
+ * Status tab is excluded because its preview shows worktree files —
16824
+ * those have their own selection model in the dedicated status view
16825
+ * and the sidebar doesn't surface them as selectable rows.
16826
+ */
16827
+ function sidebarTabHasSelectableItems(sidebarTab, itemCount) {
16828
+ if (!itemCount || itemCount <= 0)
16829
+ return false;
16830
+ return sidebarTab === 'branches' ||
16831
+ sidebarTab === 'tags' ||
16832
+ sidebarTab === 'stashes' ||
16833
+ sidebarTab === 'worktrees';
16834
+ }
16835
+
16215
16836
  function action(actionValue) {
16216
16837
  return {
16217
16838
  type: 'action',
16218
16839
  action: actionValue,
16219
16840
  };
16220
16841
  }
16842
+ /**
16843
+ * Build the events needed to apply the hunk under the diff cursor. The
16844
+ * runtime workflow handler expects payload format `<target>\n<patch>`
16845
+ * — splitting on the first newline keeps the patch body intact for
16846
+ * targets like `worktree` and `index` (no newlines in the prefix).
16847
+ *
16848
+ * Returns [] when the user isn't on a commit-diff / stash-diff explore,
16849
+ * or when no hunk can be extracted at the current cursor offset
16850
+ * (e.g. cursor sits on a `diff --git` header before the first `@@`).
16851
+ * Callers fall back to a contextual status message when this returns [].
16852
+ */
16853
+ function buildApplyHunkEvents(state, context, target) {
16854
+ if (state.activeView !== 'diff')
16855
+ return [];
16856
+ if (state.diffSource !== 'commit' && state.diffSource !== 'stash')
16857
+ return [];
16858
+ const lines = context.diffLinesForHunkApply;
16859
+ if (!lines || lines.length === 0)
16860
+ return [];
16861
+ const path = state.diffSource === 'stash'
16862
+ ? context.stashDiffSelectedPath
16863
+ : context.commitDiffSelectedPath;
16864
+ if (!path)
16865
+ return [];
16866
+ const extracted = extractDiffHunk({
16867
+ lines,
16868
+ cursorOffset: state.diffPreviewOffset,
16869
+ path,
16870
+ });
16871
+ if (!extracted)
16872
+ return [];
16873
+ const id = target === 'index' ? 'apply-hunk-index' : 'apply-hunk-worktree';
16874
+ return [{
16875
+ type: 'runWorkflowAction',
16876
+ id,
16877
+ payload: `${target}\n${extracted.patchText}`,
16878
+ }];
16879
+ }
16880
+ /**
16881
+ * Per-entity action-target predicates. The promoted views (`branches`,
16882
+ * `tags`, `stash`, `worktrees`) each scope a set of ops to their
16883
+ * dedicated surface. The same ops also fire when the user has the
16884
+ * sidebar focused on the matching tab — that's how in-sidebar
16885
+ * selection (#791 follow-up) lets the user checkout / apply / drop
16886
+ * without leaving the workstation view.
16887
+ */
16888
+ function isBranchActionTarget(state) {
16889
+ return (state.activeView === 'branches' && state.focus === 'commits') ||
16890
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
16891
+ }
16892
+ function isTagActionTarget(state) {
16893
+ return (state.activeView === 'tags' && state.focus === 'commits') ||
16894
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
16895
+ }
16896
+ function isStashActionTarget(state) {
16897
+ return (state.activeView === 'stash' && state.focus === 'commits') ||
16898
+ (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
16899
+ }
16900
+ function isWorktreeActionTarget(state) {
16901
+ return (state.activeView === 'worktrees' && state.focus === 'commits') ||
16902
+ (state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
16903
+ }
16904
+ /**
16905
+ * Item count for the active sidebar tab — used by the generic
16906
+ * sidebar-Enter handler to decide whether to defer to the per-entity
16907
+ * Enter (when items are present and the user is cursoring through
16908
+ * them) or to drill into the dedicated view (when the tab is empty
16909
+ * or has no per-entity Enter handler defined).
16910
+ */
16911
+ function getSidebarItemCount(sidebarTab, context) {
16912
+ switch (sidebarTab) {
16913
+ case 'branches': return context.branchCount;
16914
+ case 'tags': return context.tagCount;
16915
+ case 'stashes': return context.stashCount;
16916
+ case 'worktrees': return context.worktreeListCount;
16917
+ default: return undefined;
16918
+ }
16919
+ }
16221
16920
  /**
16222
16921
  * Translate a palette command into the same events its keystroke would have
16223
16922
  * produced. Phase 6 makes `:` a real launcher: this is the single mapping
@@ -16297,6 +16996,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
16297
16996
  return [action({ type: 'pushView', value: 'stash' })];
16298
16997
  case 'navigateWorktrees':
16299
16998
  return [action({ type: 'pushView', value: 'worktrees' })];
16999
+ case 'navigatePullRequest':
17000
+ return [action({ type: 'pushView', value: 'pull-request' })];
16300
17001
  case 'navigateBack':
16301
17002
  return [action({ type: 'popView' })];
16302
17003
  case 'openSelected': {
@@ -16354,10 +17055,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
16354
17055
  case 'clearSearch':
16355
17056
  return [action({ type: 'clearFilter' })];
16356
17057
  case 'cycleSort':
16357
- if (state.activeView === 'branches') {
17058
+ if (isBranchActionTarget(state)) {
16358
17059
  return [action({ type: 'cycleBranchSort' })];
16359
17060
  }
16360
- if (state.activeView === 'tags') {
17061
+ if (isTagActionTarget(state)) {
16361
17062
  return [action({ type: 'cycleTagSort' })];
16362
17063
  }
16363
17064
  return [action({
@@ -16394,6 +17095,77 @@ function hasUnsavedComposeDraft(state) {
16394
17095
  }
16395
17096
  return Boolean(compose.summary.trim() || compose.body.trim());
16396
17097
  }
17098
+ /**
17099
+ * Submit the active input prompt — used by Enter on single-line
17100
+ * prompts and by Ctrl+D on multi-line prompts (#806). Most prompt
17101
+ * kinds dispatch a workflow whose id matches the kind
17102
+ * (`create-branch`, `rename-branch`, etc.). A few are exceptions:
17103
+ * - `reset-mode` (#777) collects soft/mixed/hard and forwards the
17104
+ * mode as the payload to `reset-to-commit`.
17105
+ * - `pr-merge-strategy` (#783) validates the strategy and routes to
17106
+ * `merge-pr` via the y-confirm path.
17107
+ * - `pr-comment` dispatches `comment-pr` directly — the body itself
17108
+ * is the affirmative action.
17109
+ * - `pr-request-changes` routes to `request-changes-pr` via
17110
+ * y-confirm because the review is publicly visible.
17111
+ * Each exception validates here so a typo doesn't surface as a
17112
+ * "workflow not yet wired" status downstream.
17113
+ *
17114
+ * Empty values yield a hint instead of a no-op so the user knows what
17115
+ * to do — the same UX whether they pressed Enter (single-line) or
17116
+ * Ctrl+D (multi-line).
17117
+ */
17118
+ function submitInputPrompt(state) {
17119
+ if (!state.inputPrompt)
17120
+ return [];
17121
+ const value = state.inputPrompt.value.trim();
17122
+ if (!value) {
17123
+ return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
17124
+ }
17125
+ if (state.inputPrompt.kind === 'reset-mode') {
17126
+ const mode = value.toLowerCase();
17127
+ if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
17128
+ return [action({
17129
+ type: 'setStatus',
17130
+ value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
17131
+ })];
17132
+ }
17133
+ return [
17134
+ { type: 'runWorkflowAction', id: 'reset-to-commit', payload: mode },
17135
+ action({ type: 'closeInputPrompt' }),
17136
+ ];
17137
+ }
17138
+ if (state.inputPrompt.kind === 'pr-merge-strategy') {
17139
+ const strategy = value.toLowerCase();
17140
+ if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
17141
+ return [action({
17142
+ type: 'setStatus',
17143
+ value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
17144
+ })];
17145
+ }
17146
+ return [
17147
+ action({ type: 'setPendingConfirmation', value: 'merge-pr', payload: strategy }),
17148
+ action({ type: 'closeInputPrompt' }),
17149
+ ];
17150
+ }
17151
+ if (state.inputPrompt.kind === 'pr-comment') {
17152
+ return [
17153
+ { type: 'runWorkflowAction', id: 'comment-pr', payload: value },
17154
+ action({ type: 'closeInputPrompt' }),
17155
+ ];
17156
+ }
17157
+ if (state.inputPrompt.kind === 'pr-request-changes') {
17158
+ return [
17159
+ action({ type: 'setPendingConfirmation', value: 'request-changes-pr', payload: value }),
17160
+ action({ type: 'closeInputPrompt' }),
17161
+ ];
17162
+ }
17163
+ const id = state.inputPrompt.kind;
17164
+ return [
17165
+ { type: 'runWorkflowAction', id, payload: value },
17166
+ action({ type: 'closeInputPrompt' }),
17167
+ ];
17168
+ }
16397
17169
  function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16398
17170
  if (key.ctrl && inputValue === 'c') {
16399
17171
  if (hasUnsavedComposeDraft(state) && !state.pendingMutationConfirmation) {
@@ -16406,22 +17178,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16406
17178
  // filter/confirmation/compose handlers so a prompt opened from inside
16407
17179
  // any of those still captures focus cleanly.
16408
17180
  if (state.inputPrompt) {
17181
+ const isMultiline = Boolean(state.inputPrompt.multiline);
16409
17182
  if (key.escape) {
16410
17183
  return [
16411
17184
  action({ type: 'closeInputPrompt' }),
16412
17185
  action({ type: 'setStatus', value: 'cancelled' }),
16413
17186
  ];
16414
17187
  }
17188
+ // Multi-line prompts (#806): Ctrl+D submits (Unix EOF convention,
17189
+ // mirrors `git commit -m -` and HEREDOC patterns). Plain Enter
17190
+ // inserts a newline so the user can compose review bodies / PR
17191
+ // comments naturally without opening $EDITOR.
17192
+ if (isMultiline && key.ctrl && inputValue === 'd') {
17193
+ return submitInputPrompt(state);
17194
+ }
17195
+ if (isMultiline && key.return) {
17196
+ return [action({ type: 'appendInputPrompt', value: '\n' })];
17197
+ }
16415
17198
  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
- ];
17199
+ return submitInputPrompt(state);
16425
17200
  }
16426
17201
  if (key.backspace || key.delete) {
16427
17202
  return [action({ type: 'backspaceInputPrompt' })];
@@ -16673,14 +17448,40 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16673
17448
  }
16674
17449
  if (state.pendingKey === 'g' && inputValue === 'z') {
16675
17450
  return [
16676
- action({ type: 'pushView', value: 'stash' }),
16677
- action({ type: 'setStatus', value: 'jumped to stash' }),
17451
+ action({ type: 'pushView', value: 'stash' }),
17452
+ action({ type: 'setStatus', value: 'jumped to stash' }),
17453
+ ];
17454
+ }
17455
+ if (state.pendingKey === 'g' && inputValue === 'w') {
17456
+ return [
17457
+ action({ type: 'pushView', value: 'worktrees' }),
17458
+ action({ type: 'setStatus', value: 'jumped to worktrees' }),
17459
+ ];
17460
+ }
17461
+ // `gp` jumps to the dedicated pull-request action panel (#783).
17462
+ // Lowercase `p` matches the pattern of other navigation chords
17463
+ // (gh / gs / gd / gc / gb / gt / gz / gw). The panel renders the
17464
+ // current branch's PR via `gh pr view --json` enriched fields and
17465
+ // exposes m / x / a / R / c action keys scoped to the view.
17466
+ if (state.pendingKey === 'g' && inputValue === 'p') {
17467
+ return [
17468
+ action({ type: 'pushView', value: 'pull-request' }),
17469
+ action({ type: 'setStatus', value: 'jumped to pull request' }),
16678
17470
  ];
16679
17471
  }
16680
- if (state.pendingKey === 'g' && inputValue === 'w') {
17472
+ // `gH` chord: apply the cursored hunk to the index (`git apply
17473
+ // --cached`). Sibling of bare `H` which targets the worktree.
17474
+ // Discoverable via the footer hint on diff views and the help
17475
+ // overlay; the explicit chord keeps `H` (single keystroke) for
17476
+ // the more common worktree case.
17477
+ if (state.pendingKey === 'g' && inputValue === 'H') {
17478
+ const events = buildApplyHunkEvents(state, context, 'index');
17479
+ if (events.length) {
17480
+ return [action({ type: 'setPendingKey', value: undefined }), ...events];
17481
+ }
16681
17482
  return [
16682
- action({ type: 'pushView', value: 'worktrees' }),
16683
- action({ type: 'setStatus', value: 'jumped to worktrees' }),
17483
+ action({ type: 'setPendingKey', value: undefined }),
17484
+ action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
16684
17485
  ];
16685
17486
  }
16686
17487
  if (inputValue === 'g') {
@@ -16692,6 +17493,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16692
17493
  }
16693
17494
  return [action({ type: 'setPendingKey', value: 'g' })];
16694
17495
  }
17496
+ // `d` on the diff view toggles between unified and side-by-side split
17497
+ // rendering (#785). Scoped to the diff view so the letter stays free
17498
+ // for other surfaces. The chord branch above already claimed `gd`,
17499
+ // so by the time we get here `pendingKey` is not `g`.
17500
+ if (inputValue === 'd' && state.activeView === 'diff') {
17501
+ const next = state.diffViewMode === 'unified' ? 'split' : 'unified';
17502
+ return [
17503
+ action({ type: 'toggleDiffViewMode' }),
17504
+ action({
17505
+ type: 'setStatus',
17506
+ value: next === 'split'
17507
+ ? 'Switched to side-by-side diff'
17508
+ : 'Switched to unified diff',
17509
+ }),
17510
+ ];
17511
+ }
16695
17512
  if (inputValue === '\\') {
16696
17513
  return [action({ type: 'toggleGraph' })];
16697
17514
  }
@@ -16714,10 +17531,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16714
17531
  return [{ type: 'refreshContext' }];
16715
17532
  }
16716
17533
  if (inputValue === 's') {
16717
- if (state.activeView === 'branches') {
17534
+ if (isBranchActionTarget(state)) {
16718
17535
  return [action({ type: 'cycleBranchSort' })];
16719
17536
  }
16720
- if (state.activeView === 'tags') {
17537
+ if (isTagActionTarget(state)) {
16721
17538
  return [action({ type: 'cycleTagSort' })];
16722
17539
  }
16723
17540
  // Falls through so other views (history/status/diff/compose/stash) still
@@ -16788,6 +17605,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16788
17605
  if (key.tab) {
16789
17606
  return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
16790
17607
  }
17608
+ // ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
17609
+ // Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
17610
+ // vertical axis (↑/↓ below) is "within the active tab's items".
17611
+ // [/] still works as a keyboard alternative for users who prefer
17612
+ // non-arrow keys.
17613
+ if (key.leftArrow && state.focus === 'sidebar') {
17614
+ return [action({ type: 'previousSidebarTab' })];
17615
+ }
17616
+ if (key.rightArrow && state.focus === 'sidebar') {
17617
+ return [action({ type: 'nextSidebarTab' })];
17618
+ }
16791
17619
  if (key.upArrow || inputValue === 'k') {
16792
17620
  if (state.focus === 'detail' && context.detailFileCount) {
16793
17621
  return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
@@ -16817,16 +17645,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16817
17645
  previewLineCount: context.previewLineCount,
16818
17646
  })];
16819
17647
  }
16820
- if (state.activeView === 'branches' && context.branchCount) {
17648
+ if (isBranchActionTarget(state) && context.branchCount) {
16821
17649
  return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
16822
17650
  }
16823
- if (state.activeView === 'tags' && context.tagCount) {
17651
+ if (isTagActionTarget(state) && context.tagCount) {
16824
17652
  return [action({ type: 'moveTag', delta: -1, count: context.tagCount })];
16825
17653
  }
16826
- if (state.activeView === 'stash' && context.stashCount) {
17654
+ if (isStashActionTarget(state) && context.stashCount) {
16827
17655
  return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
16828
17656
  }
16829
- if (state.activeView === 'worktrees' && context.worktreeListCount) {
17657
+ if (isWorktreeActionTarget(state) && context.worktreeListCount) {
16830
17658
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
16831
17659
  }
16832
17660
  if (state.activeView === 'history' &&
@@ -16836,6 +17664,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16836
17664
  context.worktreeDirty) {
16837
17665
  return [action({ type: 'focusPendingCommit' })];
16838
17666
  }
17667
+ // Sidebar fallback: when no entity claim above succeeds (status
17668
+ // tab or empty content tab), ↑ falls through to cycling sidebar
17669
+ // tabs so the user always has a way to navigate. With ←/→ above
17670
+ // already handling tab switching, this is mostly a vim-style
17671
+ // safety net for `k`.
16839
17672
  return [
16840
17673
  action(state.focus === 'sidebar'
16841
17674
  ? { type: 'previousSidebarTab' }
@@ -16870,16 +17703,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16870
17703
  previewLineCount: context.previewLineCount,
16871
17704
  })];
16872
17705
  }
16873
- if (state.activeView === 'branches' && context.branchCount) {
17706
+ if (isBranchActionTarget(state) && context.branchCount) {
16874
17707
  return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
16875
17708
  }
16876
- if (state.activeView === 'tags' && context.tagCount) {
17709
+ if (isTagActionTarget(state) && context.tagCount) {
16877
17710
  return [action({ type: 'moveTag', delta: 1, count: context.tagCount })];
16878
17711
  }
16879
- if (state.activeView === 'stash' && context.stashCount) {
17712
+ if (isStashActionTarget(state) && context.stashCount) {
16880
17713
  return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
16881
17714
  }
16882
- if (state.activeView === 'worktrees' && context.worktreeListCount) {
17715
+ if (isWorktreeActionTarget(state) && context.worktreeListCount) {
16883
17716
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
16884
17717
  }
16885
17718
  return [
@@ -16983,30 +17816,42 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16983
17816
  }
16984
17817
  }
16985
17818
  // 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.
17819
+ // (status / branches / tags / stash) but only when the sidebar tab
17820
+ // either has no per-entity Enter handler defined (status, tags,
17821
+ // worktrees) or has zero items (so the dedicated view's empty-state
17822
+ // tells the user what to do next).
17823
+ //
17824
+ // When the sidebar IS focused on a content tab WITH items, this
17825
+ // handler defers to the per-entity Enter below (checkout-branch for
17826
+ // branches, navigateOpenDiffForStash for stashes) so the user can
17827
+ // act on the cursored item without leaving the workstation view —
17828
+ // the in-sidebar selection win from #791 follow-up.
16990
17829
  //
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.
17830
+ // The drill-in moves focus out of the sidebar into the newly opened
17831
+ // list — otherwise ↑/↓ keep navigating the sidebar instead of the
17832
+ // just-opened view, which made the drill-in feel half-done.
16994
17833
  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
- ];
17834
+ const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
17835
+ const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
17836
+ sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
17837
+ if (!hasInSidebarPrimaryAction) {
17838
+ const tabToView = {
17839
+ status: 'status',
17840
+ branches: 'branches',
17841
+ tags: 'tags',
17842
+ stashes: 'stash',
17843
+ worktrees: 'worktrees',
17844
+ };
17845
+ const target = tabToView[state.sidebarTab];
17846
+ if (target) {
17847
+ return [
17848
+ action({ type: 'pushView', value: target }),
17849
+ action({ type: 'setFocus', value: 'commits' }),
17850
+ ];
17851
+ }
17852
+ return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
17008
17853
  }
17009
- return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
17854
+ // Fall through per-entity Enter handler below claims the keystroke.
17010
17855
  }
17011
17856
  if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
17012
17857
  return [action({
@@ -17015,8 +17860,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17015
17860
  })];
17016
17861
  }
17017
17862
  // 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) {
17863
+ // action — no confirmation prompt. Fires from either the dedicated
17864
+ // branches view or from the sidebar when the branches tab is focused
17865
+ // with items.
17866
+ if (key.return && isBranchActionTarget(state) && context.branchCount) {
17020
17867
  return [{ type: 'runWorkflowAction', id: 'checkout-branch' }];
17021
17868
  }
17022
17869
  // `+` opens a create-branch / create-tag prompt depending on context.
@@ -17043,32 +17890,33 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17043
17890
  }
17044
17891
  // Per-view stash actions: `a` apply (keep the stash), `p` pop (apply
17045
17892
  // 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) {
17893
+ // routes through the y-confirm path. Scoped to the stash target so
17894
+ // the letters stay free elsewhere — the target predicate also fires
17895
+ // when the sidebar's stashes tab is focused with items.
17896
+ if (inputValue === 'a' && isStashActionTarget(state) && context.stashCount) {
17049
17897
  return [{ type: 'runWorkflowAction', id: 'apply-stash' }];
17050
17898
  }
17051
- if (inputValue === 'p' && state.activeView === 'stash' && context.stashCount) {
17899
+ if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
17052
17900
  return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
17053
17901
  }
17054
17902
  // 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
17903
+ // is scoped to the tags target so it doesn't collide with `p` for
17056
17904
  // pop-stash. Note: this also takes precedence over the global
17057
17905
  // push-current-branch workflow's `P` key.
17058
- if (inputValue === 'P' && state.activeView === 'tags' && context.tagCount) {
17906
+ if (inputValue === 'P' && isTagActionTarget(state) && context.tagCount) {
17059
17907
  return [{ type: 'runWorkflowAction', id: 'push-tag' }];
17060
17908
  }
17061
17909
  // Per-view branches actions: `R` renames the selected branch, `u`
17062
17910
  // sets its upstream. Both open the input prompt so the user can type
17063
17911
  // the new value. Pre-fills are handled by the prompt's `initial`.
17064
- if (inputValue === 'R' && state.activeView === 'branches' && context.branchCount) {
17912
+ if (inputValue === 'R' && isBranchActionTarget(state) && context.branchCount) {
17065
17913
  return [action({
17066
17914
  type: 'openInputPrompt',
17067
17915
  kind: 'rename-branch',
17068
17916
  label: 'Rename branch to',
17069
17917
  })];
17070
17918
  }
17071
- if (inputValue === 'u' && state.activeView === 'branches' && context.branchCount) {
17919
+ if (inputValue === 'u' && isBranchActionTarget(state) && context.branchCount) {
17072
17920
  return [action({
17073
17921
  type: 'openInputPrompt',
17074
17922
  kind: 'set-upstream',
@@ -17076,11 +17924,48 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17076
17924
  })];
17077
17925
  }
17078
17926
  // 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) {
17927
+ // confirmation). Scoped per-target so this letter is free elsewhere
17928
+ // (especially the `R` rename binding on the branches target).
17929
+ if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
17082
17930
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
17083
17931
  }
17932
+ // #783 — full PR action panel keys, scoped to the pull-request view.
17933
+ // All five wrap a `gh pr <verb>` invocation; merge / request-changes /
17934
+ // comment open prompts first, the rest route through the y-confirm
17935
+ // path because they're irreversible (or near-irreversible).
17936
+ if (inputValue === 'm' && state.activeView === 'pull-request') {
17937
+ return [action({
17938
+ type: 'openInputPrompt',
17939
+ kind: 'pr-merge-strategy',
17940
+ label: 'Merge strategy (merge / squash / rebase)',
17941
+ })];
17942
+ }
17943
+ if (inputValue === 'x' && state.activeView === 'pull-request') {
17944
+ return [action({ type: 'setPendingConfirmation', value: 'close-pr' })];
17945
+ }
17946
+ if (inputValue === 'a' && state.activeView === 'pull-request') {
17947
+ return [action({ type: 'setPendingConfirmation', value: 'approve-pr' })];
17948
+ }
17949
+ if (inputValue === 'R' && state.activeView === 'pull-request') {
17950
+ // Free-form review body — multi-line so the reviewer can structure
17951
+ // their feedback naturally without opening $EDITOR (#806).
17952
+ return [action({
17953
+ type: 'openInputPrompt',
17954
+ kind: 'pr-request-changes',
17955
+ label: 'Request changes — review body (Enter newline · Ctrl+D submit)',
17956
+ multiline: true,
17957
+ })];
17958
+ }
17959
+ if (inputValue === 'c' && state.activeView === 'pull-request') {
17960
+ // Free-form comment body — multi-line for the same reason as
17961
+ // pr-request-changes.
17962
+ return [action({
17963
+ type: 'openInputPrompt',
17964
+ kind: 'pr-comment',
17965
+ label: 'Comment body (Enter newline · Ctrl+D submit)',
17966
+ multiline: true,
17967
+ })];
17968
+ }
17084
17969
  // Global stash hotkey: `S` opens a stash-message prompt and
17085
17970
  // `createStash` runs once submitted. Available everywhere there's
17086
17971
  // not a more modal handler in front of it.
@@ -17138,6 +18023,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17138
18023
  payload: `${context.commitDiffSelectedSha} ${context.commitDiffSelectedPath}`,
17139
18024
  })];
17140
18025
  }
18026
+ // `H` on a commit-diff or stash-diff explore extracts the hunk under
18027
+ // the cursor and applies it to the working tree (`git apply`). The
18028
+ // sibling `gH` chord targets the index (`git apply --cached`). Both
18029
+ // bypass the y-confirm path because `git apply` is non-destructive
18030
+ // (it'll fail loudly on conflict and `git apply -R` undoes a clean
18031
+ // apply).
18032
+ if (inputValue === 'H') {
18033
+ const events = buildApplyHunkEvents(state, context, 'worktree');
18034
+ if (events.length) {
18035
+ return events;
18036
+ }
18037
+ if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
18038
+ return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
18039
+ }
18040
+ }
17141
18041
  // `c` on the history view cherry-picks the full selected commit on
17142
18042
  // top of the current branch. Routed through the y-confirm flow since
17143
18043
  // it can produce conflicts and is a real working-tree mutation.
@@ -17148,6 +18048,46 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17148
18048
  !state.pendingCommitFocused) {
17149
18049
  return [action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' })];
17150
18050
  }
18051
+ // `R` reverts the cursored commit by adding an inverse commit on top
18052
+ // of HEAD. Same y-confirm gate as cherry-pick — non-rewriting but
18053
+ // still a real mutation.
18054
+ if (inputValue === 'R' &&
18055
+ state.activeView === 'history' &&
18056
+ state.focus === 'commits' &&
18057
+ state.filteredCommits.length > 0 &&
18058
+ !state.pendingCommitFocused) {
18059
+ return [action({ type: 'setPendingConfirmation', value: 'revert-commit' })];
18060
+ }
18061
+ // `Z` resets the current branch tip to the cursored commit. Opens a
18062
+ // mode prompt (soft / mixed / hard) instead of jumping straight to
18063
+ // confirmation because the choice changes the destructiveness
18064
+ // dramatically — `--hard` discards working-tree changes. The prompt
18065
+ // submission special-cases `kind === 'reset-mode'` to forward the
18066
+ // mode through `reset-to-commit` (see prompt-submit handler above).
18067
+ // No `initial` value: existing prompts append to initial rather than
18068
+ // replacing it, which would surprise the user typing the mode.
18069
+ if (inputValue === 'Z' &&
18070
+ state.activeView === 'history' &&
18071
+ state.focus === 'commits' &&
18072
+ state.filteredCommits.length > 0 &&
18073
+ !state.pendingCommitFocused) {
18074
+ return [action({
18075
+ type: 'openInputPrompt',
18076
+ kind: 'reset-mode',
18077
+ label: 'Reset mode (soft / mixed / hard)',
18078
+ })];
18079
+ }
18080
+ // `i` (lowercase) starts an interactive rebase from the cursored
18081
+ // commit's parent. Lowercase keeps the existing global `I`
18082
+ // ai-commit-summary workflow reachable on the history view; `i`
18083
+ // also matches the `git rebase -i` flag mnemonic.
18084
+ if (inputValue === 'i' &&
18085
+ state.activeView === 'history' &&
18086
+ state.focus === 'commits' &&
18087
+ state.filteredCommits.length > 0 &&
18088
+ !state.pendingCommitFocused) {
18089
+ return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
18090
+ }
17151
18091
  // `y` / `Y` yank the contextually relevant identifier from the active
17152
18092
  // view to the system clipboard:
17153
18093
  // history → cursored commit hash (Y for short hash)
@@ -17163,13 +18103,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17163
18103
  if (state.activeView === 'history' && state.filteredCommits.length > 0) {
17164
18104
  return [{ type: 'yankFromActiveView', short }];
17165
18105
  }
17166
- if (state.activeView === 'branches' && context.branchCount) {
18106
+ if (isBranchActionTarget(state) && context.branchCount) {
17167
18107
  return [{ type: 'yankFromActiveView' }];
17168
18108
  }
17169
- if (state.activeView === 'tags' && context.tagCount) {
18109
+ if (isTagActionTarget(state) && context.tagCount) {
17170
18110
  return [{ type: 'yankFromActiveView' }];
17171
18111
  }
17172
- if (state.activeView === 'stash' && context.stashCount && context.stashSelectedRef) {
18112
+ if (isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
17173
18113
  return [{ type: 'yankFromActiveView' }];
17174
18114
  }
17175
18115
  if (state.activeView === 'status' && context.worktreeSelectedPath) {
@@ -17187,8 +18127,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17187
18127
  // Enter on a stash row pushes the diff view scoped to that stash.
17188
18128
  // The runtime loads `git stash show -p <ref>` once the view is
17189
18129
  // 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) {
18130
+ // context lookup here. Fires from either the dedicated stash view or
18131
+ // from the sidebar when the stashes tab is focused with items.
18132
+ if (key.return && isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
17192
18133
  return [action({
17193
18134
  type: 'navigateOpenDiffForStash',
17194
18135
  ref: context.stashSelectedRef,
@@ -17249,7 +18190,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17249
18190
  * fall back to "already seen" so we never block startup.
17250
18191
  */
17251
18192
  const MARKER_BASENAME = 'onboarding.seen';
17252
- function resolveCacheDir$1() {
18193
+ function resolveCacheDir$2() {
17253
18194
  const xdg = process.env.XDG_CACHE_HOME;
17254
18195
  if (xdg && xdg.trim().length > 0) {
17255
18196
  return path__namespace$1.join(xdg, 'coco');
@@ -17257,7 +18198,7 @@ function resolveCacheDir$1() {
17257
18198
  return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
17258
18199
  }
17259
18200
  function getOnboardingMarkerPath() {
17260
- return path__namespace$1.join(resolveCacheDir$1(), MARKER_BASENAME);
18201
+ return path__namespace$1.join(resolveCacheDir$2(), MARKER_BASENAME);
17261
18202
  }
17262
18203
  function hasSeenOnboarding() {
17263
18204
  try {
@@ -17281,6 +18222,52 @@ function markOnboardingSeen() {
17281
18222
  }
17282
18223
  }
17283
18224
 
18225
+ /**
18226
+ * Persist the user's preferred diff view mode (unified vs side-by-side
18227
+ * split — #785) per repo. Mirrors `inkSidebarPersistence.ts` so the
18228
+ * cache layout, error model, and key derivation stay consistent across
18229
+ * settings: best-effort, XDG-friendly, no PII in the cache filename.
18230
+ */
18231
+ const VALID_MODES = ['unified', 'split'];
18232
+ function resolveCacheDir$1() {
18233
+ const xdg = process.env.XDG_CACHE_HOME;
18234
+ if (xdg && xdg.trim().length > 0) {
18235
+ return path__namespace$1.join(xdg, 'coco');
18236
+ }
18237
+ return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
18238
+ }
18239
+ function repoKey$1(repoPath) {
18240
+ // sha1 is used here as a non-security cache-key derivation — we just
18241
+ // need a deterministic short identifier for the marker filename. No
18242
+ // PII or auth context is hashed.
18243
+ // DevSkim: ignore DS126858
18244
+ return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
18245
+ }
18246
+ function getDiffViewModeMarkerPath(repoPath) {
18247
+ return path__namespace$1.join(resolveCacheDir$1(), `diff-view-mode.${repoKey$1(repoPath)}`);
18248
+ }
18249
+ function getSavedDiffViewMode(repoPath) {
18250
+ try {
18251
+ const raw = fs__namespace$1.readFileSync(getDiffViewModeMarkerPath(repoPath), 'utf8').trim();
18252
+ return VALID_MODES.includes(raw)
18253
+ ? raw
18254
+ : undefined;
18255
+ }
18256
+ catch {
18257
+ return undefined;
18258
+ }
18259
+ }
18260
+ function saveDiffViewMode(repoPath, mode) {
18261
+ const marker = getDiffViewModeMarkerPath(repoPath);
18262
+ try {
18263
+ fs__namespace$1.mkdirSync(path__namespace$1.dirname(marker), { recursive: true });
18264
+ fs__namespace$1.writeFileSync(marker, mode);
18265
+ }
18266
+ catch {
18267
+ // Best-effort persistence; swallow.
18268
+ }
18269
+ }
18270
+
17284
18271
  /**
17285
18272
  * Persist which sidebar tab the user last had active, keyed per repo so
17286
18273
  * switching projects doesn't reset every other repo's preference. The
@@ -17340,6 +18327,144 @@ function saveSidebarTab(repoPath, tab) {
17340
18327
  }
17341
18328
  }
17342
18329
 
18330
+ /**
18331
+ * Pair-alignment helper for the side-by-side diff view (#785).
18332
+ *
18333
+ * Takes the unified-diff line array that the renderer already paints (one
18334
+ * line per element, the leading character drives `+`/`-`/context coloring)
18335
+ * and re-shapes it into two-column rows the split renderer can lay out
18336
+ * without further parsing. Pure / synchronous so it can be exercised from
18337
+ * tests without spinning up Ink.
18338
+ *
18339
+ * Algorithm:
18340
+ * 1. Walk lines in order. `@@` headers seed a new hunk and reset the
18341
+ * `oldLineNo` / `newLineNo` cursors from the header range.
18342
+ * 2. Inside a hunk, group the consecutive runs of `-` and `+` lines that
18343
+ * follow each other. Each run of removals + the immediately-following
18344
+ * run of additions forms a "change block" that pairs up element-wise:
18345
+ * row[i] = { left: removals[i], right: additions[i] }. When one side
18346
+ * is shorter, pad with `kind: 'empty'` rows so the columns stay
18347
+ * aligned.
18348
+ * 3. Context lines emit as a paired row with the same text on both
18349
+ * sides and the synthesized line numbers from each cursor.
18350
+ * 4. Diff metadata (`diff `, `index `, `--- `, `+++ `, etc.) emit as
18351
+ * `kind: 'header'` rows so the split view still has a section break.
18352
+ * 5. A context line that interrupts a change block forces the in-flight
18353
+ * block to flush before the context row is emitted — pairs are never
18354
+ * drawn across context boundaries (matches lazygit / fugitive
18355
+ * behavior, and is what the issue specifies).
18356
+ *
18357
+ * Long lines are not wrapped here — the renderer truncates per column at
18358
+ * paint time so this helper stays pure and trivially testable.
18359
+ */
18360
+ const EMPTY_LEFT = { text: '', kind: 'empty' };
18361
+ const EMPTY_RIGHT = { text: '', kind: 'empty' };
18362
+ /**
18363
+ * Parse the start line numbers out of an `@@ -A,B +C,D @@` header. Returns
18364
+ * `[oldStart, newStart]`; either falls back to 1 when the header is
18365
+ * malformed (which only happens with synthetic / hand-crafted patches).
18366
+ */
18367
+ function parseHunkHeader(line) {
18368
+ const match = /@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(line);
18369
+ if (!match) {
18370
+ return [1, 1];
18371
+ }
18372
+ return [Number(match[1]) || 1, Number(match[2]) || 1];
18373
+ }
18374
+ function isDiffHeader(line) {
18375
+ return (line.startsWith('diff ') ||
18376
+ line.startsWith('index ') ||
18377
+ line.startsWith('--- ') ||
18378
+ line.startsWith('+++ ') ||
18379
+ line.startsWith('similarity ') ||
18380
+ line.startsWith('rename ') ||
18381
+ line.startsWith('copy ') ||
18382
+ line.startsWith('new file ') ||
18383
+ line.startsWith('deleted file ') ||
18384
+ line.startsWith('old mode ') ||
18385
+ line.startsWith('new mode ') ||
18386
+ line.startsWith('Binary files '));
18387
+ }
18388
+ /**
18389
+ * Flush a pending change block (removals + additions accumulated from a
18390
+ * contiguous `-`/`+` run) into paired rows. Pads the shorter side with
18391
+ * empty placeholders so columns stay aligned.
18392
+ */
18393
+ function flushChangeBlock(removals, additions, rows) {
18394
+ const max = Math.max(removals.length, additions.length);
18395
+ for (let i = 0; i < max; i++) {
18396
+ const left = removals[i] || EMPTY_LEFT;
18397
+ const right = additions[i] || EMPTY_RIGHT;
18398
+ rows.push({ left, right });
18399
+ }
18400
+ removals.length = 0;
18401
+ additions.length = 0;
18402
+ }
18403
+ function buildSplitDiffRows(unifiedLines) {
18404
+ const rows = [];
18405
+ let oldLineNo = 0;
18406
+ let newLineNo = 0;
18407
+ let inHunk = false;
18408
+ const removals = [];
18409
+ const additions = [];
18410
+ const flushHeader = (text) => {
18411
+ flushChangeBlock(removals, additions, rows);
18412
+ rows.push({
18413
+ left: { text, kind: 'header' },
18414
+ right: { text, kind: 'header' },
18415
+ });
18416
+ };
18417
+ for (const raw of unifiedLines) {
18418
+ if (raw.startsWith('@@')) {
18419
+ flushChangeBlock(removals, additions, rows);
18420
+ const [oldStart, newStart] = parseHunkHeader(raw);
18421
+ oldLineNo = oldStart;
18422
+ newLineNo = newStart;
18423
+ inHunk = true;
18424
+ rows.push({
18425
+ left: { text: raw, kind: 'header' },
18426
+ right: { text: raw, kind: 'header' },
18427
+ });
18428
+ continue;
18429
+ }
18430
+ if (!inHunk || isDiffHeader(raw)) {
18431
+ flushHeader(raw);
18432
+ continue;
18433
+ }
18434
+ if (raw.startsWith('-')) {
18435
+ removals.push({
18436
+ text: raw.slice(1),
18437
+ lineNumber: oldLineNo,
18438
+ kind: 'remove',
18439
+ });
18440
+ oldLineNo += 1;
18441
+ continue;
18442
+ }
18443
+ if (raw.startsWith('+')) {
18444
+ additions.push({
18445
+ text: raw.slice(1),
18446
+ lineNumber: newLineNo,
18447
+ kind: 'add',
18448
+ });
18449
+ newLineNo += 1;
18450
+ continue;
18451
+ }
18452
+ // Context line (or `` marker, which we
18453
+ // treat like a context row so it lands on both sides — readers
18454
+ // expect to see it in either column).
18455
+ flushChangeBlock(removals, additions, rows);
18456
+ const text = raw.startsWith(' ') ? raw.slice(1) : raw;
18457
+ rows.push({
18458
+ left: { text, lineNumber: oldLineNo, kind: 'context' },
18459
+ right: { text, lineNumber: newLineNo, kind: 'context' },
18460
+ });
18461
+ oldLineNo += 1;
18462
+ newLineNo += 1;
18463
+ }
18464
+ flushChangeBlock(removals, additions, rows);
18465
+ return rows;
18466
+ }
18467
+
17343
18468
  /**
17344
18469
  * Promoted-view selection rectification on filter changes (P4.5).
17345
18470
  *
@@ -17761,7 +18886,8 @@ function createLogInkTheme(options = {}) {
17761
18886
  /**
17762
18887
  * Format a branch's relationship to its upstream.
17763
18888
  * - no upstream → "no upstream"
17764
- * - even → "even with <upstream>"
18889
+ * - even → "" (the boring default — keep the row tight; the row
18890
+ * marker already encodes "synced")
17765
18891
  * - divergent → "↑<ahead> ↓<behind> <upstream>" (only the non-zero side
17766
18892
  * is rendered so the line stays tight). ASCII mode falls back to the
17767
18893
  * legacy `+N/-N` form.
@@ -17771,7 +18897,7 @@ function formatBranchDivergence(branch, options = {}) {
17771
18897
  return 'no upstream';
17772
18898
  }
17773
18899
  if (branch.ahead === 0 && branch.behind === 0) {
17774
- return `even with ${branch.upstream}`;
18900
+ return '';
17775
18901
  }
17776
18902
  if (options.ascii) {
17777
18903
  return `+${branch.ahead}/-${branch.behind} ${branch.upstream}`;
@@ -17785,14 +18911,76 @@ function formatBranchDivergence(branch, options = {}) {
17785
18911
  }
17786
18912
  /**
17787
18913
  * Single-cell marker shown to the left of a branch name in lists.
17788
- * `*` = current, `◌` = no upstream (detached from a remote), space otherwise.
18914
+ *
18915
+ * - `*` — current branch (regardless of remote state)
18916
+ * - `◌` — no upstream
18917
+ * - `≡` — has upstream + synced (ahead === 0 && behind === 0)
18918
+ * - `↕` — has upstream + diverged (any non-zero ahead/behind)
18919
+ * - ` ` — fallback / no info
18920
+ *
18921
+ * ASCII fallbacks (legible without box-drawing/arrow glyphs):
18922
+ * - `?` for "no upstream", `=` for synced, `~` for diverged.
17789
18923
  */
17790
18924
  function branchRowMarker(branch, options = {}) {
17791
18925
  if (branch.current)
17792
18926
  return '*';
17793
18927
  if (!branch.upstream)
17794
18928
  return options.ascii ? '?' : '◌';
17795
- return ' ';
18929
+ const ahead = branch.ahead ?? 0;
18930
+ const behind = branch.behind ?? 0;
18931
+ if (ahead === 0 && behind === 0) {
18932
+ return options.ascii ? '=' : '≡';
18933
+ }
18934
+ return options.ascii ? '~' : '↕';
18935
+ }
18936
+ /**
18937
+ * Compact, human-friendly relative timestamp for the branch row.
18938
+ * Inputs:
18939
+ * - `iso` — committer-date in `YYYY-MM-DD` form (as produced by
18940
+ * `for-each-ref` with `committerdate:short`).
18941
+ * - `now` — reference instant; pass it explicitly so callers can pin it
18942
+ * for deterministic tests.
18943
+ *
18944
+ * Outputs (rounded toward the nearest unit):
18945
+ * - `today`, `1d ago`, `2d ago` … up to 13d
18946
+ * - `2w ago` … up to 8w
18947
+ * - `2mo ago` … up to 12mo
18948
+ * - `2y ago` for older
18949
+ * - `''` for malformed inputs (caller renders nothing).
18950
+ *
18951
+ * "in the future" inputs (clock skew, bad data) collapse to `today`.
18952
+ */
18953
+ function formatBranchLastTouched(iso, now) {
18954
+ if (!iso)
18955
+ return '';
18956
+ // Tolerate either `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS…` ISO strings.
18957
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
18958
+ if (!match)
18959
+ return '';
18960
+ const year = Number.parseInt(match[1], 10);
18961
+ const month = Number.parseInt(match[2], 10);
18962
+ const day = Number.parseInt(match[3], 10);
18963
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
18964
+ return '';
18965
+ // Compare at day granularity in UTC so a branch touched "yesterday"
18966
+ // never reads "today" depending on the operator's timezone.
18967
+ const branchUtc = Date.UTC(year, month - 1, day);
18968
+ const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
18969
+ const diffMs = nowUtc - branchUtc;
18970
+ const oneDay = 24 * 60 * 60 * 1000;
18971
+ const days = Math.floor(diffMs / oneDay);
18972
+ if (days <= 0)
18973
+ return 'today';
18974
+ if (days < 14)
18975
+ return `${days}d ago`;
18976
+ const weeks = Math.floor(days / 7);
18977
+ if (weeks < 9)
18978
+ return `${weeks}w ago`;
18979
+ const months = Math.floor(days / 30);
18980
+ if (months < 12)
18981
+ return `${months}mo ago`;
18982
+ const years = Math.floor(days / 365);
18983
+ return `${years}y ago`;
17796
18984
  }
17797
18985
  /**
17798
18986
  * Pick the glyph + color for a PR state badge.
@@ -18222,7 +19410,7 @@ function createChangelogArgv(input) {
18222
19410
  ...input,
18223
19411
  };
18224
19412
  }
18225
- function compactOutputLines$2(output) {
19413
+ function compactOutputLines$3(output) {
18226
19414
  return output
18227
19415
  .split('\n')
18228
19416
  .map((line) => line.trim())
@@ -18246,7 +19434,7 @@ async function captureStdout(action) {
18246
19434
  }
18247
19435
  }
18248
19436
  function formatCapturedAiOutput(output) {
18249
- const lines = compactOutputLines$2(output);
19437
+ const lines = compactOutputLines$3(output);
18250
19438
  const telemetry = lines.filter((line) => line.includes('[llm:summary]'));
18251
19439
  const content = lines.filter((line) => !line.includes('[llm]') && !line.includes('[llm:summary]'));
18252
19440
  const editable = content.join('\n');
@@ -18543,8 +19731,65 @@ function parsePullRequestInfo(output) {
18543
19731
  if (!trimmed) {
18544
19732
  return undefined;
18545
19733
  }
18546
- return JSON.parse(trimmed);
19734
+ const raw = JSON.parse(trimmed);
19735
+ const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
19736
+ ? String(raw.author.login)
19737
+ : undefined;
19738
+ return {
19739
+ number: raw.number,
19740
+ title: raw.title,
19741
+ url: raw.url,
19742
+ state: raw.state,
19743
+ isDraft: raw.isDraft,
19744
+ headRefName: raw.headRefName,
19745
+ baseRefName: raw.baseRefName,
19746
+ body: typeof raw.body === 'string' ? raw.body : undefined,
19747
+ author,
19748
+ reviewDecision: typeof raw.reviewDecision === 'string' ? raw.reviewDecision : undefined,
19749
+ mergeable: typeof raw.mergeable === 'string' ? raw.mergeable : undefined,
19750
+ mergeStateStatus: typeof raw.mergeStateStatus === 'string' ? raw.mergeStateStatus : undefined,
19751
+ statusCheckRollup: Array.isArray(raw.statusCheckRollup)
19752
+ ? raw.statusCheckRollup.map((entry) => ({
19753
+ name: String(entry.name || entry.context || 'check'),
19754
+ status: typeof entry.status === 'string' ? entry.status : undefined,
19755
+ conclusion: typeof entry.conclusion === 'string' ? entry.conclusion : undefined,
19756
+ }))
19757
+ : undefined,
19758
+ reviews: Array.isArray(raw.reviews)
19759
+ ? raw.reviews.map((entry) => {
19760
+ const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
19761
+ ? String(entry.author.login)
19762
+ : '';
19763
+ return {
19764
+ author,
19765
+ state: typeof entry.state === 'string' ? entry.state : '',
19766
+ };
19767
+ }).filter((review) => review.author)
19768
+ : undefined,
19769
+ };
18547
19770
  }
19771
+ /**
19772
+ * `gh pr view --json` field list. Centralized so the data fetcher and
19773
+ * any future re-fetch (e.g., refresh after a merge action) request the
19774
+ * same shape — the parser depends on every field being present, even
19775
+ * if optional, so they're safe to deserialize.
19776
+ */
19777
+ const PULL_REQUEST_VIEW_JSON_FIELDS = [
19778
+ 'number',
19779
+ 'title',
19780
+ 'url',
19781
+ 'state',
19782
+ 'isDraft',
19783
+ 'headRefName',
19784
+ 'baseRefName',
19785
+ 'body',
19786
+ 'author',
19787
+ 'reviewDecision',
19788
+ 'mergeable',
19789
+ 'mergeStateStatus',
19790
+ 'statusCheckRollup',
19791
+ 'reviews',
19792
+ ].join(',');
18548
19793
  async function getPullRequestOverview(git, runner = defaultGhRunner) {
18549
19794
  const [repository, currentBranchOutput] = await Promise.all([
18550
19795
  getGitHubRepository(git),
@@ -18576,7 +19821,7 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
18576
19821
  'pr',
18577
19822
  'view',
18578
19823
  '--json',
18579
- 'number,title,url,state,isDraft,headRefName,baseRefName',
19824
+ PULL_REQUEST_VIEW_JSON_FIELDS,
18580
19825
  ]);
18581
19826
  return {
18582
19827
  available: true,
@@ -18743,7 +19988,7 @@ function providerBranchName(branch) {
18743
19988
  return branch.shortName;
18744
19989
  }
18745
19990
 
18746
- function compactOutputLines$1(output) {
19991
+ function compactOutputLines$2(output) {
18747
19992
  return output
18748
19993
  .split('\n')
18749
19994
  .map((line) => line.trim())
@@ -18758,7 +20003,7 @@ async function runAction$5(action, successMessage) {
18758
20003
  };
18759
20004
  }
18760
20005
  catch (error) {
18761
- const lines = compactOutputLines$1(error.message);
20006
+ const lines = compactOutputLines$2(error.message);
18762
20007
  return {
18763
20008
  ok: false,
18764
20009
  message: lines[0] || 'History action failed.',
@@ -18900,7 +20145,7 @@ async function compareCommits(git, from, to) {
18900
20145
  }
18901
20146
  try {
18902
20147
  const output = await git.raw(['diff', '--stat', '--color=never', `${from.hash}..${to.hash}`]);
18903
- const lines = compactOutputLines$1(output);
20148
+ const lines = compactOutputLines$2(output);
18904
20149
  return {
18905
20150
  ok: true,
18906
20151
  message: `Compared ${from.shortHash}..${to.shortHash}`,
@@ -19067,6 +20312,61 @@ function createPullRequest(input, runner = defaultGhRunner) {
19067
20312
  };
19068
20313
  });
19069
20314
  }
20315
+ function isPullRequestMergeStrategy(value) {
20316
+ return value === 'merge' || value === 'squash' || value === 'rebase';
20317
+ }
20318
+ function buildMergePullRequestArgs(strategy) {
20319
+ // `--auto` and `--admin` are intentionally omitted — they're rarely
20320
+ // what a user wants from a TUI and require explicit gh auth scopes.
20321
+ // `--delete-branch` is opt-in via a future flag; default leaves the
20322
+ // branch in place so the user can verify before cleanup.
20323
+ return ['pr', 'merge', `--${strategy}`];
20324
+ }
20325
+ function mergePullRequest(strategy, runner = defaultGhRunner) {
20326
+ return runGhAction(runner, buildMergePullRequestArgs(strategy), (output) => ({
20327
+ ok: true,
20328
+ message: output.trim() || `Merged pull request with ${strategy}`,
20329
+ }));
20330
+ }
20331
+ function closePullRequest(runner = defaultGhRunner) {
20332
+ return runGhAction(runner, ['pr', 'close'], (output) => ({
20333
+ ok: true,
20334
+ message: output.trim() || 'Closed pull request',
20335
+ }));
20336
+ }
20337
+ /**
20338
+ * `gh pr review --approve` requires the user's gh auth to have scope
20339
+ * to write reviews — same scope that the in-browser approve button
20340
+ * uses. The runner surfaces auth failures via the standard error path.
20341
+ */
20342
+ function approvePullRequest(runner = defaultGhRunner) {
20343
+ return runGhAction(runner, ['pr', 'review', '--approve'], (output) => ({
20344
+ ok: true,
20345
+ message: output.trim() || 'Approved pull request',
20346
+ }));
20347
+ }
20348
+ /**
20349
+ * Request changes — `gh pr review` requires a body with this verb so
20350
+ * the empty-body case is rejected upstream by the input prompt.
20351
+ */
20352
+ function requestChangesPullRequest(body, runner = defaultGhRunner) {
20353
+ if (!body.trim()) {
20354
+ return Promise.resolve({ ok: false, message: 'Review body required for change-request' });
20355
+ }
20356
+ return runGhAction(runner, ['pr', 'review', '--request-changes', '--body', body], (output) => ({
20357
+ ok: true,
20358
+ message: output.trim() || 'Requested changes',
20359
+ }));
20360
+ }
20361
+ function commentPullRequest(body, runner = defaultGhRunner) {
20362
+ if (!body.trim()) {
20363
+ return Promise.resolve({ ok: false, message: 'Comment body required' });
20364
+ }
20365
+ return runGhAction(runner, ['pr', 'comment', '--body', body], (output) => ({
20366
+ ok: true,
20367
+ message: output.trim() || 'Comment added',
20368
+ }));
20369
+ }
19070
20370
 
19071
20371
  async function runAction$4(action, successMessage) {
19072
20372
  try {
@@ -19691,7 +20991,7 @@ function applyLogTuiAction(state, action) {
19691
20991
  }
19692
20992
  }
19693
20993
 
19694
- function compactOutputLines(output) {
20994
+ function compactOutputLines$1(output) {
19695
20995
  return output
19696
20996
  .split('\n')
19697
20997
  .map((line) => line.trim())
@@ -19706,7 +21006,7 @@ async function runAction(action, successMessage) {
19706
21006
  };
19707
21007
  }
19708
21008
  catch (error) {
19709
- const details = compactOutputLines(error.message);
21009
+ const details = compactOutputLines$1(error.message);
19710
21010
  return {
19711
21011
  ok: false,
19712
21012
  message: details[0] || 'Git operation action failed.',
@@ -21355,35 +22655,303 @@ async function startInteractiveLog(git, rows, streams = {}) {
21355
22655
  applyAndRender(applyLogTuiAction(state, { type: 'clearFilter' }));
21356
22656
  }
21357
22657
  };
21358
- input.on('keypress', onKeypress);
21359
- void Promise.all([
21360
- refreshBranches(),
21361
- refreshPullRequest(),
21362
- refreshTags(),
21363
- refreshWorktree(),
21364
- refreshStashes(),
21365
- refreshWorktreeList(),
21366
- refreshOperationOverview(),
21367
- refreshProviderOverview(),
21368
- ])
21369
- .then(async () => {
21370
- await refreshStatusHunks();
21371
- await render();
21372
- })
21373
- .catch(() => {
21374
- branches = undefined;
21375
- pullRequest = undefined;
21376
- tags = undefined;
21377
- worktree = undefined;
21378
- statusHunks = undefined;
21379
- stashes = undefined;
21380
- worktreeList = undefined;
21381
- operationOverview = undefined;
21382
- providerOverview = undefined;
21383
- });
21384
- void render();
22658
+ input.on('keypress', onKeypress);
22659
+ void Promise.all([
22660
+ refreshBranches(),
22661
+ refreshPullRequest(),
22662
+ refreshTags(),
22663
+ refreshWorktree(),
22664
+ refreshStashes(),
22665
+ refreshWorktreeList(),
22666
+ refreshOperationOverview(),
22667
+ refreshProviderOverview(),
22668
+ ])
22669
+ .then(async () => {
22670
+ await refreshStatusHunks();
22671
+ await render();
22672
+ })
22673
+ .catch(() => {
22674
+ branches = undefined;
22675
+ pullRequest = undefined;
22676
+ tags = undefined;
22677
+ worktree = undefined;
22678
+ statusHunks = undefined;
22679
+ stashes = undefined;
22680
+ worktreeList = undefined;
22681
+ operationOverview = undefined;
22682
+ providerOverview = undefined;
22683
+ });
22684
+ void render();
22685
+ });
22686
+ }
22687
+
22688
+ function compactOutputLines(output) {
22689
+ return output
22690
+ .split('\n')
22691
+ .map((line) => line.trim())
22692
+ .filter(Boolean);
22693
+ }
22694
+ async function safeUnlink(path) {
22695
+ try {
22696
+ await fs.promises.unlink(path);
22697
+ }
22698
+ catch (error) {
22699
+ // ENOENT is fine — the temp file was never created or already
22700
+ // cleaned up. Anything else we silently swallow because the
22701
+ // worst-case impact is a single ~1KB file in $TMPDIR.
22702
+ error.code;
22703
+ }
22704
+ }
22705
+ /**
22706
+ * Write a unified-diff patch to a temp file and feed it to
22707
+ * `git apply` (or `git apply --cached` when target === 'index').
22708
+ *
22709
+ * This is the runner behind the `apply-hunk-worktree` /
22710
+ * `apply-hunk-index` workflow actions — the input handler builds
22711
+ * `patchText` from the cursored hunk via `extractDiffHunk` and the
22712
+ * runtime hands it here.
22713
+ *
22714
+ * `--whitespace=nowarn` keeps `git apply` quiet about trailing
22715
+ * whitespace differences (the most common false positive when the
22716
+ * patch comes from a stash made on a different platform). Real
22717
+ * conflicts still surface via the non-zero exit code.
22718
+ *
22719
+ * The patch is written to a temp file rather than piped on stdin
22720
+ * because some `simple-git` adapters don't expose a clean stdin
22721
+ * channel for `git.raw`; the tempfile path keeps the runner
22722
+ * portable across environments.
22723
+ */
22724
+ async function applyHunkPatch(git, patchText, options) {
22725
+ if (!patchText.trim()) {
22726
+ return {
22727
+ ok: false,
22728
+ message: 'No hunk under cursor to apply.',
22729
+ };
22730
+ }
22731
+ const targetLabel = options.target === 'index' ? 'index' : 'worktree';
22732
+ const tempPath = path.join(os.tmpdir(), `coco-hunk-${crypto$1.randomUUID()}.patch`);
22733
+ try {
22734
+ await fs.promises.writeFile(tempPath, patchText, 'utf8');
22735
+ const args = ['apply'];
22736
+ if (options.target === 'index') {
22737
+ args.push('--cached');
22738
+ }
22739
+ args.push('--whitespace=nowarn');
22740
+ args.push(tempPath);
22741
+ try {
22742
+ await git.raw(args);
22743
+ return {
22744
+ ok: true,
22745
+ message: `Applied hunk to ${targetLabel}`,
22746
+ };
22747
+ }
22748
+ catch (error) {
22749
+ const lines = compactOutputLines(error.message);
22750
+ return {
22751
+ ok: false,
22752
+ message: lines[0] || `Failed to apply hunk to ${targetLabel}`,
22753
+ details: lines.slice(1, 6),
22754
+ };
22755
+ }
22756
+ }
22757
+ catch (error) {
22758
+ return {
22759
+ ok: false,
22760
+ message: `Could not stage hunk for apply: ${error.message}`,
22761
+ };
22762
+ }
22763
+ finally {
22764
+ await safeUnlink(tempPath);
22765
+ }
22766
+ }
22767
+
22768
+ function formatStashHeaderIdentity(ref, stashes) {
22769
+ if (!ref) {
22770
+ return { subtitle: 'no stash', bodyLine: 'Stash:' };
22771
+ }
22772
+ const index = stashes?.findIndex((entry) => entry.ref === ref) ?? -1;
22773
+ const entry = index >= 0 ? stashes[index] : undefined;
22774
+ if (!entry) {
22775
+ return {
22776
+ subtitle: ref,
22777
+ bodyLine: `Stash: ${ref}`,
22778
+ };
22779
+ }
22780
+ const onBranch = entry.branch && entry.branch !== '<unknown>' ? ` on ${entry.branch}` : '';
22781
+ const message = entry.message?.trim() || '(no message)';
22782
+ return {
22783
+ subtitle: `@{${index}} ${message}${onBranch}`,
22784
+ bodyLine: `Stash: ${ref}${onBranch} — ${message}`,
22785
+ };
22786
+ }
22787
+
22788
+ /**
22789
+ * Normalize gh's two parallel signals (`status` for in-flight check
22790
+ * runs, `conclusion` for completed runs and status contexts) into a
22791
+ * single status enum the renderer can map to a glyph + color.
22792
+ */
22793
+ function normalizePullRequestCheckStatus(check) {
22794
+ const status = (check.status || '').toUpperCase();
22795
+ const conclusion = (check.conclusion || '').toUpperCase();
22796
+ // In-flight check runs: gh emits `status: IN_PROGRESS|QUEUED` with
22797
+ // no conclusion yet. `PENDING` covers status-context runs that are
22798
+ // still waiting on a reporter.
22799
+ if (!conclusion && (status === 'IN_PROGRESS' || status === 'QUEUED' || status === 'PENDING')) {
22800
+ return 'pending';
22801
+ }
22802
+ switch (conclusion || status) {
22803
+ case 'SUCCESS':
22804
+ return 'success';
22805
+ case 'FAILURE':
22806
+ case 'ERROR':
22807
+ case 'TIMED_OUT':
22808
+ case 'ACTION_REQUIRED':
22809
+ return 'failure';
22810
+ case 'NEUTRAL':
22811
+ return 'neutral';
22812
+ case 'SKIPPED':
22813
+ case 'CANCELLED':
22814
+ return 'skipped';
22815
+ default:
22816
+ return 'pending';
22817
+ }
22818
+ }
22819
+ /**
22820
+ * Glyph for a normalized check status. ASCII fallbacks keep the panel
22821
+ * usable on legacy terminals where the geometric shapes block isn't
22822
+ * rendered.
22823
+ */
22824
+ function pullRequestCheckGlyph(status, options = {}) {
22825
+ if (options.ascii) {
22826
+ switch (status) {
22827
+ case 'success': return '+';
22828
+ case 'failure': return 'x';
22829
+ case 'pending': return '.';
22830
+ case 'neutral': return '-';
22831
+ case 'skipped': return '/';
22832
+ }
22833
+ }
22834
+ switch (status) {
22835
+ case 'success': return '✓';
22836
+ case 'failure': return '✗';
22837
+ case 'pending': return '◌';
22838
+ case 'neutral': return '○';
22839
+ case 'skipped': return '∼';
22840
+ }
22841
+ }
22842
+ function summarizePullRequestChecks(checks) {
22843
+ const summary = {
22844
+ total: 0, success: 0, failure: 0, pending: 0, neutral: 0, skipped: 0,
22845
+ };
22846
+ if (!checks)
22847
+ return summary;
22848
+ for (const check of checks) {
22849
+ summary.total += 1;
22850
+ summary[normalizePullRequestCheckStatus(check)] += 1;
22851
+ }
22852
+ return summary;
22853
+ }
22854
+ /**
22855
+ * One-line summary like `5 checks · 4 ✓ · 1 ◌` for the panel header.
22856
+ * Hides zero-count categories so the line stays scannable.
22857
+ */
22858
+ function formatPullRequestChecksSummary(summary, options = {}) {
22859
+ if (summary.total === 0) {
22860
+ return 'No status checks reported';
22861
+ }
22862
+ const parts = [`${summary.total} ${summary.total === 1 ? 'check' : 'checks'}`];
22863
+ const push = (count, status) => {
22864
+ if (count > 0)
22865
+ parts.push(`${count} ${pullRequestCheckGlyph(status, options)}`);
22866
+ };
22867
+ push(summary.success, 'success');
22868
+ push(summary.failure, 'failure');
22869
+ push(summary.pending, 'pending');
22870
+ push(summary.neutral, 'neutral');
22871
+ push(summary.skipped, 'skipped');
22872
+ return parts.join(' · ');
22873
+ }
22874
+ function buildPullRequestCheckRows(checks, options = {}) {
22875
+ if (!checks)
22876
+ return [];
22877
+ return checks.map((check) => {
22878
+ const status = normalizePullRequestCheckStatus(check);
22879
+ return {
22880
+ glyph: pullRequestCheckGlyph(status, options),
22881
+ name: check.name,
22882
+ status,
22883
+ detail: (check.conclusion || check.status || '').toLowerCase(),
22884
+ };
21385
22885
  });
21386
22886
  }
22887
+ function summarizePullRequestReviews(reviews, reviewDecision) {
22888
+ const summary = {
22889
+ total: 0, approved: 0, changesRequested: 0, commented: 0, dismissed: 0, pending: 0,
22890
+ decisionLabel: reviewDecision || undefined,
22891
+ };
22892
+ if (!reviews)
22893
+ return summary;
22894
+ for (const review of reviews) {
22895
+ summary.total += 1;
22896
+ switch (review.state.toUpperCase()) {
22897
+ case 'APPROVED':
22898
+ summary.approved += 1;
22899
+ break;
22900
+ case 'CHANGES_REQUESTED':
22901
+ summary.changesRequested += 1;
22902
+ break;
22903
+ case 'COMMENTED':
22904
+ summary.commented += 1;
22905
+ break;
22906
+ case 'DISMISSED':
22907
+ summary.dismissed += 1;
22908
+ break;
22909
+ case 'PENDING':
22910
+ summary.pending += 1;
22911
+ break;
22912
+ }
22913
+ }
22914
+ return summary;
22915
+ }
22916
+ function formatPullRequestReviewsSummary(summary) {
22917
+ const decision = summary.decisionLabel
22918
+ ? summary.decisionLabel.replace(/_/g, ' ').toLowerCase()
22919
+ : undefined;
22920
+ if (summary.total === 0) {
22921
+ return decision ? `No reviews · ${decision}` : 'No reviews submitted';
22922
+ }
22923
+ const parts = [`${summary.total} ${summary.total === 1 ? 'review' : 'reviews'}`];
22924
+ if (summary.approved > 0)
22925
+ parts.push(`${summary.approved} approved`);
22926
+ if (summary.changesRequested > 0)
22927
+ parts.push(`${summary.changesRequested} changes requested`);
22928
+ if (summary.commented > 0)
22929
+ parts.push(`${summary.commented} commented`);
22930
+ if (summary.pending > 0)
22931
+ parts.push(`${summary.pending} pending`);
22932
+ if (summary.dismissed > 0)
22933
+ parts.push(`${summary.dismissed} dismissed`);
22934
+ if (decision)
22935
+ parts.push(`decision: ${decision}`);
22936
+ return parts.join(' · ');
22937
+ }
22938
+ /**
22939
+ * One-line state badge for the header, e.g. `OPEN · draft` or `MERGED`.
22940
+ * Mergeable / merge-state is appended as a secondary chip when the PR
22941
+ * is open so the user sees `MERGEABLE` / `CONFLICTING` at a glance.
22942
+ */
22943
+ function formatPullRequestStateLine(pr) {
22944
+ const parts = [pr.state];
22945
+ if (pr.isDraft)
22946
+ parts.push('draft');
22947
+ if (pr.state === 'OPEN' && pr.mergeable) {
22948
+ parts.push(pr.mergeable.toLowerCase());
22949
+ }
22950
+ if (pr.state === 'OPEN' && pr.mergeStateStatus && pr.mergeStateStatus !== 'CLEAN') {
22951
+ parts.push(pr.mergeStateStatus.toLowerCase());
22952
+ }
22953
+ return parts.join(' · ');
22954
+ }
21387
22955
 
21388
22956
  function sectionLines(title, diff) {
21389
22957
  const lines = diff.split('\n').map((line) => line.trimEnd());
@@ -21600,6 +23168,89 @@ function diffLineProps(line, theme) {
21600
23168
  }
21601
23169
  return {};
21602
23170
  }
23171
+ /**
23172
+ * Minimum terminal width below which the split diff falls back to
23173
+ * unified rendering (#785). Each column needs ~50 columns for code to
23174
+ * read comfortably plus border + padding overhead, so anything narrower
23175
+ * than ~120 columns gets the unified view regardless of the user's
23176
+ * preference. The preference is preserved — switching back to a wide
23177
+ * terminal restores split mode automatically.
23178
+ */
23179
+ const MIN_SPLIT_DIFF_WIDTH = 120;
23180
+ function isSplitDiffViable(state, width) {
23181
+ return state.diffViewMode === 'split' && width >= MIN_SPLIT_DIFF_WIDTH;
23182
+ }
23183
+ /**
23184
+ * Style props for one side of a split-diff row, derived from the row's
23185
+ * `kind` rather than the leading character (because the helper has
23186
+ * already stripped the leading +/-/space). Keeps the colors aligned with
23187
+ * `diffLineProps`.
23188
+ */
23189
+ function splitDiffSideProps(kind, theme) {
23190
+ if (kind === 'header') {
23191
+ if (theme.noColor)
23192
+ return { dimColor: true };
23193
+ return { color: theme.colors.accent };
23194
+ }
23195
+ if (kind === 'empty') {
23196
+ return { dimColor: true };
23197
+ }
23198
+ if (theme.noColor) {
23199
+ return { dimColor: kind === 'context' };
23200
+ }
23201
+ if (kind === 'add')
23202
+ return { color: theme.colors.gitAdded };
23203
+ if (kind === 'remove')
23204
+ return { color: theme.colors.gitDeleted };
23205
+ return {};
23206
+ }
23207
+ /**
23208
+ * Format one column of a split-diff row: an optional 4-digit line
23209
+ * number prefix + the line text, padded/truncated to the column width.
23210
+ * Empty rows render a faint `·` placeholder so the alignment gap is
23211
+ * visible at a glance.
23212
+ */
23213
+ function formatSplitDiffCell(side, columnWidth) {
23214
+ if (side.kind === 'empty') {
23215
+ const placeholder = ' · ';
23216
+ return placeholder.padEnd(columnWidth);
23217
+ }
23218
+ if (side.kind === 'header') {
23219
+ return truncate$1(side.text, columnWidth).padEnd(columnWidth);
23220
+ }
23221
+ const lineNo = side.lineNumber !== undefined ? String(side.lineNumber).padStart(4) : ' ';
23222
+ // Strip the trailing newline that some diffs include. Keeps column
23223
+ // widths predictable.
23224
+ const text = side.text.replace(/\n$/, '');
23225
+ // 4 digits + 1 space gutter = 5 chars; reserve that off the column
23226
+ // before truncating the text.
23227
+ const textRoom = Math.max(1, columnWidth - 5);
23228
+ return `${lineNo} ${truncate$1(text, textRoom)}`.padEnd(columnWidth);
23229
+ }
23230
+ /**
23231
+ * Render the split-diff body as a list of two-column rows. The caller
23232
+ * is responsible for slicing the unified-line array to the visible
23233
+ * window — the helper just transforms that slice into Ink nodes.
23234
+ */
23235
+ function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, theme, keyPrefix) {
23236
+ const { Box, Text } = components;
23237
+ const rows = buildSplitDiffRows(unifiedSlice);
23238
+ // Reserve 3 columns of gutter (1 left padding from the Box + 1 column
23239
+ // separator + 1 right padding) so neither side touches the border.
23240
+ const usable = Math.max(20, width - 4);
23241
+ const gutter = 1;
23242
+ const half = Math.max(10, Math.floor((usable - gutter) / 2));
23243
+ return rows.map((row, index) => {
23244
+ const leftProps = splitDiffSideProps(row.left.kind, theme);
23245
+ const rightProps = splitDiffSideProps(row.right.kind, theme);
23246
+ const leftText = formatSplitDiffCell(row.left, half);
23247
+ const rightText = formatSplitDiffCell(row.right, half);
23248
+ return h(Box, {
23249
+ key: `${keyPrefix}-${startOffset + index}`,
23250
+ flexDirection: 'row',
23251
+ }, 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)));
23252
+ });
23253
+ }
21603
23254
  /**
21604
23255
  * Pick a theme color for a single name-status code (`A`, `M`, `D`,
21605
23256
  * `R100`, etc.) so the inspector and commit-diff file list render with
@@ -21653,68 +23304,6 @@ function sidebarTabLabel(tab) {
21653
23304
  return tab;
21654
23305
  }
21655
23306
  }
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
23307
  async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
21719
23308
  const input = streams.input || process.stdin;
21720
23309
  const output = streams.output || process.stdout;
@@ -22056,6 +23645,13 @@ function LogInkApp(deps) {
22056
23645
  if (saved && saved !== state.userSidebarTab) {
22057
23646
  dispatch({ type: 'restoreSidebarTab', value: saved });
22058
23647
  }
23648
+ // Diff view mode persistence (#785). Same per-repo cache pattern
23649
+ // as the sidebar tab — restore the user's last preference if
23650
+ // they had one. New repos / fresh installs default to unified.
23651
+ const savedDiffMode = getSavedDiffViewMode(repoRoot);
23652
+ if (savedDiffMode && savedDiffMode !== state.diffViewMode) {
23653
+ dispatch({ type: 'setDiffViewMode', value: savedDiffMode });
23654
+ }
22059
23655
  }
22060
23656
  catch {
22061
23657
  // Not in a worktree, or revparse failed; nothing to restore.
@@ -22069,6 +23665,12 @@ function LogInkApp(deps) {
22069
23665
  return;
22070
23666
  saveSidebarTab(repoRoot, state.userSidebarTab);
22071
23667
  }, [state.userSidebarTab]);
23668
+ React.useEffect(() => {
23669
+ const repoRoot = repoRootRef.current;
23670
+ if (!repoRoot)
23671
+ return;
23672
+ saveDiffViewMode(repoRoot, state.diffViewMode);
23673
+ }, [state.diffViewMode]);
22072
23674
  // P-stash-explorer: load `git stash show -p <ref>` once the diff view
22073
23675
  // becomes active with diffSource='stash'. Best-effort — empty stashes
22074
23676
  // or read errors fall through to a "no diff" hint at the render site.
@@ -22361,6 +23963,27 @@ function LogInkApp(deps) {
22361
23963
  // disappears. Called from the y-confirm path for delete-branch / delete-
22362
23964
  // tag / drop-stash / remove-worktree / abort-operation.
22363
23965
  const runWorkflowAction = React.useCallback(async (id, payload) => {
23966
+ // Hunk-apply payload format: `<target>\n<patchText>` — the input
23967
+ // handler synthesizes both pieces (target from the keystroke,
23968
+ // patch text from extractDiffHunk against the live diff lines)
23969
+ // and packs them into the single `payload` field. Splitting on
23970
+ // the first newline keeps the patch body intact.
23971
+ const runApplyHunk = (expectedTarget, raw) => {
23972
+ if (!raw) {
23973
+ return Promise.resolve({ ok: false, message: 'No hunk under cursor to apply.' });
23974
+ }
23975
+ const newlineIndex = raw.indexOf('\n');
23976
+ if (newlineIndex < 0) {
23977
+ return Promise.resolve({ ok: false, message: 'Malformed hunk-apply payload.' });
23978
+ }
23979
+ const target = raw.slice(0, newlineIndex) === 'index' ? 'index' : 'worktree';
23980
+ const patchText = raw.slice(newlineIndex + 1);
23981
+ // The input handler is the source of truth for target — but if a
23982
+ // palette-injected payload mismatches the workflow id, prefer
23983
+ // the workflow id so the user sees the action they asked for.
23984
+ const effectiveTarget = expectedTarget || target;
23985
+ return applyHunkPatch(git, patchText, { target: effectiveTarget });
23986
+ };
22364
23987
  const handlers = {
22365
23988
  'create-branch': async () => {
22366
23989
  const name = payload?.trim();
@@ -22466,6 +24089,44 @@ function LogInkApp(deps) {
22466
24089
  message: commit.message,
22467
24090
  });
22468
24091
  },
24092
+ 'revert-commit': async () => {
24093
+ const commit = getSelectedInkCommit(state);
24094
+ if (!commit)
24095
+ return { ok: false, message: 'No commit selected' };
24096
+ return revertCommit(git, {
24097
+ hash: commit.hash,
24098
+ shortHash: commit.shortHash,
24099
+ message: commit.message,
24100
+ });
24101
+ },
24102
+ 'reset-to-commit': async () => {
24103
+ const commit = getSelectedInkCommit(state);
24104
+ if (!commit)
24105
+ return { ok: false, message: 'No commit selected' };
24106
+ // Mode arrives via the action's `payload` field — the input
24107
+ // handler runs the reset-mode prompt (kind: 'reset-mode') and
24108
+ // routes the typed value here. Default to `mixed` (git's own
24109
+ // default) when the user submitted an empty value.
24110
+ const raw = payload?.trim().toLowerCase() || 'mixed';
24111
+ if (!isResetMode(raw)) {
24112
+ return { ok: false, message: `Unknown reset mode: ${raw}. Use soft, mixed, or hard.` };
24113
+ }
24114
+ return resetToCommit(git, {
24115
+ hash: commit.hash,
24116
+ shortHash: commit.shortHash,
24117
+ message: commit.message,
24118
+ }, raw);
24119
+ },
24120
+ 'interactive-rebase': async () => {
24121
+ const commit = getSelectedInkCommit(state);
24122
+ if (!commit)
24123
+ return { ok: false, message: 'No commit selected' };
24124
+ return startInteractiveRebase(git, {
24125
+ hash: commit.hash,
24126
+ shortHash: commit.shortHash,
24127
+ message: commit.message,
24128
+ });
24129
+ },
22469
24130
  'checkout-file-from-commit': async () => {
22470
24131
  // payload is "<sha> <path>" so we pass both through a single
22471
24132
  // string field on the action.
@@ -22481,6 +24142,8 @@ function LogInkApp(deps) {
22481
24142
  return { ok: false, message: 'No commit file under cursor' };
22482
24143
  return checkoutFileFromCommit(git, sha, path);
22483
24144
  },
24145
+ 'apply-hunk-worktree': async () => runApplyHunk('worktree', payload),
24146
+ 'apply-hunk-index': async () => runApplyHunk('index', payload),
22484
24147
  'remove-worktree': async () => {
22485
24148
  const all = context.worktreeList?.worktrees || [];
22486
24149
  // Resolve the target from the visible (filtered) list so a
@@ -22569,6 +24232,32 @@ function LogInkApp(deps) {
22569
24232
  return { ok: false, message: 'Stash message required' };
22570
24233
  return createStash(git, message);
22571
24234
  },
24235
+ // #783 — full PR action panel handlers. Each wraps the matching
24236
+ // pullRequestActions verb. Strategy / body arrives via `payload`
24237
+ // — input prompts validate before they reach here, but the
24238
+ // strategy guard stays as a defensive belt-and-suspenders since
24239
+ // a future palette path could call us with a raw value.
24240
+ 'merge-pr': async () => {
24241
+ const strategy = (payload || 'merge').toLowerCase();
24242
+ if (!isPullRequestMergeStrategy(strategy)) {
24243
+ return { ok: false, message: `Unknown merge strategy: ${strategy}. Use merge, squash, or rebase.` };
24244
+ }
24245
+ return mergePullRequest(strategy);
24246
+ },
24247
+ 'close-pr': async () => closePullRequest(),
24248
+ 'approve-pr': async () => approvePullRequest(),
24249
+ 'request-changes-pr': async () => {
24250
+ const body = payload?.trim();
24251
+ if (!body)
24252
+ return { ok: false, message: 'Review body required for change-request' };
24253
+ return requestChangesPullRequest(body);
24254
+ },
24255
+ 'comment-pr': async () => {
24256
+ const body = payload?.trim();
24257
+ if (!body)
24258
+ return { ok: false, message: 'Comment body required' };
24259
+ return commentPullRequest(body);
24260
+ },
22572
24261
  };
22573
24262
  const handler = handlers[id];
22574
24263
  if (!handler) {
@@ -22859,6 +24548,51 @@ function LogInkApp(deps) {
22859
24548
  });
22860
24549
  })();
22861
24550
  }, [dispatch, git, logArgv, state.historyFetchArgs]);
24551
+ // Graph mode toggle (`g` key, #791 follow-up). The header label flips
24552
+ // between "compact graph" and "full graph", but unless we re-fetch with
24553
+ // the right `view`, the underlying rows still come from the user's
24554
+ // initial argv (default `--first-parent --no-merges`) and the renderer
24555
+ // has no topology to draw — defeating the per-lane / junction work.
24556
+ // Mirrors the historyFetchArgs effect: skip first run, request-id ref
24557
+ // for stale-completion guard, swap rows in place via replaceRows.
24558
+ const toggleGraphEffectInitialized = React.useRef(false);
24559
+ const toggleGraphRequestRef = React.useRef(0);
24560
+ React.useEffect(() => {
24561
+ if (!logArgv)
24562
+ return;
24563
+ if (!toggleGraphEffectInitialized.current) {
24564
+ toggleGraphEffectInitialized.current = true;
24565
+ return;
24566
+ }
24567
+ const requestId = toggleGraphRequestRef.current + 1;
24568
+ toggleGraphRequestRef.current = requestId;
24569
+ const merged = buildToggleGraphArgs(logArgv, state.fullGraph);
24570
+ dispatch({
24571
+ type: 'setStatus',
24572
+ value: state.fullGraph
24573
+ ? 'Loading full topology…'
24574
+ : 'Loading compact history…',
24575
+ });
24576
+ void (async () => {
24577
+ const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
24578
+ if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
24579
+ return;
24580
+ }
24581
+ if (!nextRows) {
24582
+ dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
24583
+ return;
24584
+ }
24585
+ dispatch({ type: 'replaceRows', rows: nextRows });
24586
+ const matched = getCommitRows(nextRows).length;
24587
+ setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
24588
+ dispatch({
24589
+ type: 'setStatus',
24590
+ value: state.fullGraph
24591
+ ? `Showing ${matched} commits across all branches`
24592
+ : `Showing ${matched} commits (compact)`,
24593
+ });
24594
+ })();
24595
+ }, [dispatch, git, logArgv, state.fullGraph]);
22862
24596
  const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
22863
24597
  .map((line, index) => (line.startsWith('@@') ? index : -1))
22864
24598
  .filter((index) => index >= 0)), [filePreview]);
@@ -22953,6 +24687,17 @@ function LogInkApp(deps) {
22953
24687
  ? selected?.hash
22954
24688
  : undefined,
22955
24689
  worktreeDirty,
24690
+ // H / gH need the actual diff text (not just hunk offsets) to
24691
+ // slice the cursored hunk into a `git apply` patch. Stash uses
24692
+ // the full `git stash show -p` output; commit-diff uses the
24693
+ // per-file `filePreview.hunks` array. Either way, extractDiffHunk
24694
+ // walks `@@` headers and synthesizes a fresh diff --git / --- /
24695
+ // +++ header set using the path the caller already resolved.
24696
+ diffLinesForHunkApply: state.diffSource === 'stash'
24697
+ ? stashDiffLines
24698
+ : state.diffSource === 'commit'
24699
+ ? filePreview?.hunks
24700
+ : undefined,
22956
24701
  }).forEach((event) => {
22957
24702
  if (event.type === 'exit') {
22958
24703
  exit();
@@ -23019,7 +24764,7 @@ function LogInkApp(deps) {
23019
24764
  if (showOnboarding) {
23020
24765
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
23021
24766
  }
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));
24767
+ 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, context, theme, idleTip));
23023
24768
  }
23024
24769
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
23025
24770
  const { Box, Text } = components;
@@ -23119,11 +24864,101 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
23119
24864
  if (tab === 'status') {
23120
24865
  return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
23121
24866
  }
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)));
24867
+ // Branches / tags / stashes / worktrees: render selectable rows so
24868
+ // ↑/↓ navigates within the sidebar list and Enter / per-entity keys
24869
+ // act on the cursored item without needing to drill into the
24870
+ // dedicated view (#791 follow-up — in-sidebar selection).
24871
+ const focused = state.focus === 'sidebar' && state.sidebarTab === tab;
24872
+ if (tab === 'branches') {
24873
+ if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
24874
+ return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
24875
+ }
24876
+ const branches = context.branches;
24877
+ if (!branches) {
24878
+ return [h(Text, { key: 'tab-branches-empty', dimColor: true }, ' Branches unavailable')];
24879
+ }
24880
+ const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
24881
+ const headerRows = [
24882
+ h(Text, { key: 'tab-branches-current', dimColor: true }, truncate$1(` Current: ${branches.currentBranch || '<detached>'}`, width - 4)),
24883
+ h(Text, { key: 'tab-branches-state', dimColor: true }, ` Worktree: ${branches.dirty ? 'dirty' : 'clean'}`),
24884
+ h(Text, { key: 'tab-branches-spacer' }, ''),
24885
+ ];
24886
+ return [
24887
+ ...headerRows,
24888
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches'),
24889
+ ];
24890
+ }
24891
+ if (tab === 'tags') {
24892
+ if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
24893
+ return [h(Text, { key: 'tab-tags-loading', dimColor: true }, ' Loading tags…')];
24894
+ }
24895
+ const tags = sortTags(context.tags?.tags || [], state.tagSort);
24896
+ if (tags.length === 0) {
24897
+ return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
24898
+ }
24899
+ return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags');
24900
+ }
24901
+ if (tab === 'stashes') {
24902
+ if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
24903
+ return [h(Text, { key: 'tab-stashes-loading', dimColor: true }, ' Loading stashes…')];
24904
+ }
24905
+ const stashes = context.stashes?.stashes || [];
24906
+ if (stashes.length === 0) {
24907
+ return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
24908
+ }
24909
+ return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes');
24910
+ }
24911
+ // worktrees
24912
+ if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
24913
+ return [h(Text, { key: 'tab-worktrees-loading', dimColor: true }, ' Loading worktrees…')];
24914
+ }
24915
+ const worktrees = context.worktreeList?.worktrees || [];
24916
+ if (worktrees.length === 0) {
24917
+ return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
24918
+ }
24919
+ return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
24920
+ const marker = worktree.current ? '*' : ' ';
24921
+ const wstate = worktree.dirty ? 'dirty' : 'clean';
24922
+ return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
24923
+ }, 'tab-worktrees');
24924
+ }
24925
+ /**
24926
+ * Render a sliding-window list of selectable sidebar rows. The cursor
24927
+ * highlights the row at `selectedIndex` only when `focused` is true so
24928
+ * an unfocused sidebar doesn't compete visually with the active panel.
24929
+ * Sliding window keeps the cursor in view as the user navigates a long
24930
+ * list; truncation hints surface the count of hidden rows.
24931
+ */
24932
+ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix) {
24933
+ if (items.length === 0)
24934
+ return [];
24935
+ const window = getSidebarVisibleWindow(items.length, selectedIndex);
24936
+ const elements = [];
24937
+ if (window.truncatedAbove > 0) {
24938
+ elements.push(h(Text, {
24939
+ key: `${keyPrefix}-trunc-above`,
24940
+ dimColor: true,
24941
+ }, truncate$1(` … ${window.truncatedAbove} more above`, width - 4)));
24942
+ }
24943
+ for (let offset = 0; offset < window.size; offset += 1) {
24944
+ const index = window.start + offset;
24945
+ if (index >= items.length)
24946
+ break;
24947
+ const isSelected = focused && index === selectedIndex;
24948
+ const text = toRowText(items[index], index);
24949
+ elements.push(h(Text, {
24950
+ key: `${keyPrefix}-row-${index}`,
24951
+ backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
24952
+ inverse: isSelected,
24953
+ }, truncate$1(` ${text}`, width - 4)));
24954
+ }
24955
+ if (window.truncatedBelow > 0) {
24956
+ elements.push(h(Text, {
24957
+ key: `${keyPrefix}-trunc-below`,
24958
+ dimColor: true,
24959
+ }, truncate$1(` … ${window.truncatedBelow} more below`, width - 4)));
24960
+ }
24961
+ return elements;
23127
24962
  }
23128
24963
  function renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme) {
23129
24964
  if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
@@ -23181,6 +25016,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
23181
25016
  if (state.activeView === 'worktrees') {
23182
25017
  return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
23183
25018
  }
25019
+ if (state.activeView === 'pull-request') {
25020
+ return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
25021
+ }
23184
25022
  return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
23185
25023
  }
23186
25024
  function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
@@ -23230,15 +25068,46 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
23230
25068
  }))
23231
25069
  : visible.items.map((item, index) => {
23232
25070
  if (item.type === 'graph') {
25071
+ if (item.laneSegments && !theme.ascii) {
25072
+ return h(Text, { key: `graph-${index}-${item.graph}` }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`));
25073
+ }
23233
25074
  return h(Text, {
23234
25075
  key: `graph-${index}-${item.graph}`,
23235
25076
  color: theme.noColor ? undefined : theme.colors.muted,
23236
25077
  dimColor: theme.noColor,
23237
25078
  }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
23238
25079
  }
23239
- return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index);
25080
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
23240
25081
  }));
23241
25082
  }
25083
+ /**
25084
+ * Render `LaneSegment[]` as a flat list of Text spans, one per lane
25085
+ * (#791 stage 2). Each segment paints in its lane's palette color so
25086
+ * the eye can follow a branch column-by-column; segments without a
25087
+ * lane id (spaces, padding, decorations) fall back to the muted graph
25088
+ * color so they visually recede.
25089
+ *
25090
+ * Final padding is appended as its own span so callers do not need to
25091
+ * pre-pad the graph string before computing lane segments.
25092
+ */
25093
+ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
25094
+ const muted = theme.noColor ? undefined : theme.colors.muted;
25095
+ const elements = [];
25096
+ let totalLen = 0;
25097
+ segments.forEach((seg, idx) => {
25098
+ const laneColor = getLaneColor(seg.laneId, theme);
25099
+ elements.push(h(Text, {
25100
+ key: `${keyPrefix}-${idx}`,
25101
+ color: laneColor ?? muted,
25102
+ dimColor: theme.noColor && seg.laneId === undefined,
25103
+ }, seg.text));
25104
+ totalLen += seg.text.length;
25105
+ });
25106
+ if (padTo > totalLen) {
25107
+ elements.push(h(Text, { key: `${keyPrefix}-pad` }, ' '.repeat(padTo - totalLen)));
25108
+ }
25109
+ return elements;
25110
+ }
23242
25111
  /**
23243
25112
  * Render a single commit row with each segment in its own colored span.
23244
25113
  * Graph chars render in `theme.colors.muted` so the topology visually
@@ -23251,8 +25120,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
23251
25120
  * Truncation is per-segment so the variable-length message field gets
23252
25121
  * the leftover budget after fixed segments are accounted for.
23253
25122
  */
23254
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index) {
23255
- const renderedGraph = substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii });
25123
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
23256
25124
  const refs = formatInkRefLabels(commit.refs);
23257
25125
  const totalWidth = 140;
23258
25126
  const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
@@ -23261,11 +25129,17 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
23261
25129
  const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
23262
25130
  const accent = theme.noColor ? undefined : theme.colors.accent;
23263
25131
  const muted = theme.noColor ? undefined : theme.colors.muted;
25132
+ // Lane-colored graph spans when full graph mode + non-ASCII rendering
25133
+ // is in play; otherwise fall back to the legacy single-muted span so
25134
+ // compact mode and legacy terminals stay visually unchanged.
25135
+ const graphChildren = laneSegments && !theme.ascii
25136
+ ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `c${index}`)
25137
+ : [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
23264
25138
  return h(Text, {
23265
25139
  key: `${commit.hash}-${index}`,
23266
25140
  backgroundColor: selectedBg,
23267
25141
  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);
25142
+ }, ...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
25143
  }
23270
25144
  /**
23271
25145
  * Render the synthetic "(+) new commit" affordance shown above the real
@@ -23498,11 +25372,35 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
23498
25372
  const cursor = isSelected ? '>' : ' ';
23499
25373
  const marker = branchRowMarker(branch, { ascii: theme.ascii });
23500
25374
  const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
25375
+ const lastTouched = formatBranchLastTouched(branch.date, new Date());
25376
+ // Split the row into spans so the timestamp stays dim even on the
25377
+ // currently-selected (bold) row. The leading marker + name keep
25378
+ // their original column widths; the timestamp is right-padded so
25379
+ // the divergence column stays aligned across rows.
25380
+ const namePadded = branch.shortName.padEnd(28);
25381
+ const timestampPadded = lastTouched.padEnd(8);
25382
+ const lineDim = !isSelected && !branch.current;
25383
+ const head = `${cursor} ${marker} ${namePadded} `;
25384
+ const trailingDivergence = divergence ? ` ${divergence}` : '';
25385
+ // Truncate the assembled line cooperatively so we never overflow
25386
+ // the panel; the timestamp is short and the divergence is the
25387
+ // most expendable, but the existing 140 cap is ample.
25388
+ const fullText = `${head}${timestampPadded}${trailingDivergence}`;
25389
+ const truncated = truncate$1(fullText, 140);
25390
+ // If truncation chopped into the timestamp/divergence portion,
25391
+ // fall back to a single Text to keep the visible width honest.
25392
+ if (truncated !== fullText) {
25393
+ return h(Text, {
25394
+ key: `branch-${index}`,
25395
+ bold: isSelected,
25396
+ dimColor: lineDim,
25397
+ }, truncated);
25398
+ }
23501
25399
  return h(Text, {
23502
25400
  key: `branch-${index}`,
23503
25401
  bold: isSelected,
23504
- dimColor: !isSelected && !branch.current,
23505
- }, truncate$1(`${cursor} ${marker} ${branch.shortName.padEnd(28)} ${divergence}`, 140));
25402
+ dimColor: lineDim,
25403
+ }, head, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
23506
25404
  });
23507
25405
  return h(Box, {
23508
25406
  borderColor: focusBorderColor(theme, focused),
@@ -23655,6 +25553,98 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
23655
25553
  width,
23656
25554
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Worktrees', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
23657
25555
  }
25556
+ /**
25557
+ * Pull-request action panel (#783) — renders the current branch's PR
25558
+ * with header, checks table, reviews summary, and a body preview.
25559
+ * Action keys (m / x / a / R / c / O) are wired in inkInput.ts and
25560
+ * surfaced via the footer; this renderer is read-only.
25561
+ *
25562
+ * Three loading / fallback states matter:
25563
+ * - Provider data still loading → "Loading pull request..."
25564
+ * - GitHub remote present but no PR for the current branch → empty
25565
+ * state hint pointing the user at `C` to create one.
25566
+ * - GitHub CLI missing / unauthenticated → unavailable hint.
25567
+ */
25568
+ function renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
25569
+ const { Box, Text } = components;
25570
+ const focused = state.focus === 'commits';
25571
+ const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
25572
+ const pullRequestOverview = context.pullRequest;
25573
+ // Use the dedicated `pullRequest` overview only — the `provider`
25574
+ // shape carries a slimmer ProviderPullRequestStatus that lacks
25575
+ // url / headRefName / body / mergeable / reviews. The dedicated
25576
+ // overview hits `gh pr view --json` with the full enriched field
25577
+ // list (PULL_REQUEST_VIEW_JSON_FIELDS) so the panel has everything.
25578
+ const pr = pullRequestOverview?.currentPullRequest;
25579
+ const muted = theme.noColor ? undefined : theme.colors.muted;
25580
+ const accent = theme.noColor ? undefined : theme.colors.accent;
25581
+ const containerProps = {
25582
+ borderColor: focusBorderColor(theme, focused),
25583
+ borderStyle: theme.borderStyle,
25584
+ flexDirection: 'column',
25585
+ flexShrink: 0,
25586
+ paddingX: 1,
25587
+ width,
25588
+ };
25589
+ if (loading && !pr) {
25590
+ 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' })));
25591
+ }
25592
+ if (!pr) {
25593
+ const hint = pullRequestOverview?.message
25594
+ || 'No pull request detected for this branch. Press `C` (or `:create-pr`) to create one.';
25595
+ 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)));
25596
+ }
25597
+ const checks = summarizePullRequestChecks(pr.statusCheckRollup);
25598
+ const reviews = summarizePullRequestReviews(pr.reviews, pr.reviewDecision);
25599
+ const checkRows = buildPullRequestCheckRows(pr.statusCheckRollup, { ascii: theme.ascii });
25600
+ const checkColor = (s) => {
25601
+ if (theme.noColor)
25602
+ return undefined;
25603
+ if (s === 'success')
25604
+ return theme.colors.success;
25605
+ if (s === 'failure')
25606
+ return theme.colors.danger;
25607
+ if (s === 'pending')
25608
+ return theme.colors.warning;
25609
+ return theme.colors.muted;
25610
+ };
25611
+ // Reserve a few rows for the header/section labels; the rest go to
25612
+ // the checks table. Body preview gets the leftover rows so the
25613
+ // surface stays vertically balanced even on tall terminals.
25614
+ const checkBudget = Math.max(3, Math.min(checkRows.length, Math.floor(bodyRows / 2)));
25615
+ const visibleChecks = checkRows.slice(0, checkBudget);
25616
+ const truncatedChecks = checkRows.length - visibleChecks.length;
25617
+ const bodyPreviewBudget = Math.max(2, bodyRows - 8 - visibleChecks.length);
25618
+ const bodyLines = (pr.body || '').split(/\r?\n/).filter((line) => line.trim().length > 0);
25619
+ const visibleBodyLines = bodyLines.slice(0, bodyPreviewBudget);
25620
+ const truncatedBodyLines = bodyLines.length - visibleBodyLines.length;
25621
+ const headerRight = `#${pr.number} · ${pr.headRefName} → ${pr.baseRefName}`;
25622
+ const stateLine = formatPullRequestStateLine(pr);
25623
+ const author = pr.author ? `by @${pr.author}` : '';
25624
+ 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, ''),
25625
+ // Checks section
25626
+ 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, {
25627
+ key: `pr-check-${index}`,
25628
+ color: checkColor(row.status),
25629
+ }, truncate$1(` ${row.glyph} ${row.name.padEnd(28)} ${row.detail}`, width - 4))), ...(truncatedChecks > 0
25630
+ ? [h(Text, { key: 'pr-checks-trunc', dimColor: true }, truncate$1(` … ${truncatedChecks} more`, width - 4))]
25631
+ : []), h(Text, undefined, ''),
25632
+ // Reviews section
25633
+ h(Text, { bold: true, color: accent }, 'Reviews'), h(Text, { dimColor: true }, truncate$1(` ${formatPullRequestReviewsSummary(reviews)}`, width - 4)), h(Text, undefined, ''),
25634
+ // Body preview
25635
+ ...(visibleBodyLines.length > 0
25636
+ ? [
25637
+ h(Text, { key: 'pr-body-label', bold: true, color: accent }, 'Description'),
25638
+ ...visibleBodyLines.map((line, index) => h(Text, {
25639
+ key: `pr-body-${index}`,
25640
+ color: muted,
25641
+ }, truncate$1(` ${line}`, width - 4))),
25642
+ ...(truncatedBodyLines > 0
25643
+ ? [h(Text, { key: 'pr-body-trunc', dimColor: true }, truncate$1(` … ${truncatedBodyLines} more lines`, width - 4))]
25644
+ : []),
25645
+ ]
25646
+ : []));
25647
+ }
23658
25648
  /**
23659
25649
  * Filter input cursor for the promoted views (branches/tags/stash).
23660
25650
  * History already shows the same `filter: foo_` affordance in its header
@@ -23685,6 +25675,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23685
25675
  // cherry-picks the file at the cursor.
23686
25676
  if (state.diffSource === 'stash') {
23687
25677
  const lines = stashDiffLines || [];
25678
+ const splitActive = isSplitDiffViable(state, width);
25679
+ const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
23688
25680
  const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
23689
25681
  const stashFiles = parseStashDiffFiles(lines);
23690
25682
  const fileCount = stashFiles.length;
@@ -23705,11 +25697,19 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23705
25697
  const currentFileIndex = currentFile
23706
25698
  ? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
23707
25699
  : -1;
23708
- const headerLines = stashDiffLoading
23709
- ? [`Loading diff for ${state.stashDiffRef || 'stash'}...`]
25700
+ // Look up the active stash entry so the panel header can show a
25701
+ // human-identifier instead of the raw `stash@{<iso-date>}` ref.
25702
+ // The git ref is the timestamp form (we fetch with --date=iso for
25703
+ // stable parsing) which reads as noise in the title bar; the
25704
+ // message + branch + index combination is what the user wrote down
25705
+ // when they ran `git stash`. Body still shows the full ref so it
25706
+ // stays unambiguous.
25707
+ const stashIdentity = formatStashHeaderIdentity(state.stashDiffRef, context.stashes?.stashes);
25708
+ const baseHeaderLines = stashDiffLoading
25709
+ ? [`Loading diff for ${stashIdentity.subtitle}...`]
23710
25710
  : lines.length
23711
25711
  ? [
23712
- `Stash: ${state.stashDiffRef || ''}`,
25712
+ stashIdentity.bodyLine,
23713
25713
  fileCount > 0 && currentFile
23714
25714
  ? `File ${currentFileIndex + 1}/${fileCount}: ${currentFile.path}`
23715
25715
  : 'No files in this stash.',
@@ -23717,6 +25717,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23717
25717
  '',
23718
25718
  ]
23719
25719
  : ['No diff to display for this stash.'];
25720
+ const headerLines = splitRequestedButTooNarrow
25721
+ ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
25722
+ : baseHeaderLines;
25723
+ const stashBodyNodes = stashDiffLoading || !lines.length
25724
+ ? []
25725
+ : splitActive
25726
+ ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
25727
+ : visibleLines.map((line, index) => h(Text, {
25728
+ key: `stash-diff-line-${state.diffPreviewOffset + index}`,
25729
+ ...diffLineProps(line, theme),
25730
+ }, truncate$1(line, width - 4)));
23720
25731
  return h(Box, {
23721
25732
  borderColor: focusBorderColor(theme, focused),
23722
25733
  borderStyle: theme.borderStyle,
@@ -23724,15 +25735,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23724
25735
  flexShrink: 0,
23725
25736
  paddingX: 1,
23726
25737
  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, {
25738
+ }, 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
25739
  key: `stash-diff-header-${index}`,
23729
25740
  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)))));
25741
+ }, truncate$1(line, width - 4))), ...stashBodyNodes);
23736
25742
  }
23737
25743
  // diffSource disambiguates: 'commit' was set when the user opened the
23738
25744
  // diff via history → Enter (read-only commit-diff explore), 'worktree'
@@ -23743,6 +25749,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23743
25749
  (state.diffSource === undefined && !worktreeFile && Boolean(selectedDetailFile));
23744
25750
  if (useCommitDiff) {
23745
25751
  const previewHunks = filePreview?.hunks || [];
25752
+ const splitActive = isSplitDiffViable(state, width);
25753
+ const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
23746
25754
  const visiblePreviewHunks = previewHunks.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
23747
25755
  const hunkCount = commitDiffHunkOffsets?.length || 0;
23748
25756
  const currentHunkIndex = hunkCount > 0
@@ -23753,7 +25761,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23753
25761
  const currentHunkLabel = hunkCount > 0
23754
25762
  ? `Hunk ${Math.min(hunkCount - currentHunkIndex, hunkCount)}/${hunkCount}`
23755
25763
  : 'No hunks for this file.';
23756
- const headerLines = filePreviewLoading
25764
+ const baseHeaderLines = filePreviewLoading
23757
25765
  ? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
23758
25766
  : previewHunks.length
23759
25767
  ? [
@@ -23763,6 +25771,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23763
25771
  '',
23764
25772
  ]
23765
25773
  : ['No diff preview available for this file.'];
25774
+ const headerLines = splitRequestedButTooNarrow
25775
+ ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
25776
+ : baseHeaderLines;
25777
+ const commitBodyNodes = filePreviewLoading || !previewHunks.length
25778
+ ? []
25779
+ : splitActive
25780
+ ? renderSplitDiffBody(h, components, visiblePreviewHunks, state.diffPreviewOffset, width, theme, 'commit-diff-split')
25781
+ : visiblePreviewHunks.map((line, index) => h(Text, {
25782
+ key: `diff-surface-line-${state.diffPreviewOffset + index}`,
25783
+ ...diffLineProps(line, theme),
25784
+ }, truncate$1(line, 140)));
23766
25785
  return h(Box, {
23767
25786
  borderColor: focusBorderColor(theme, focused),
23768
25787
  borderStyle: theme.borderStyle,
@@ -23770,15 +25789,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23770
25789
  flexShrink: 0,
23771
25790
  paddingX: 1,
23772
25791
  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, {
25792
+ }, 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
25793
  key: `diff-surface-header-${index}`,
23775
25794
  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)))));
25795
+ }, truncate$1(line, 140))), ...commitBodyNodes);
23782
25796
  }
23783
25797
  const diffLines = worktreeDiff?.lines || [];
23784
25798
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
@@ -24253,16 +26267,34 @@ function renderInputPromptPanel(h, components, state, width, theme, focused) {
24253
26267
  if (!prompt) {
24254
26268
  return h(Box, { width });
24255
26269
  }
26270
+ const accent = theme.noColor ? undefined : theme.colors.accent;
26271
+ // Multi-line prompts (#806) split on newline and render one Text
26272
+ // row per buffer line — the cursor sits at the end of the last
26273
+ // line via the trailing `_`. Single-line prompts collapse to the
26274
+ // original one-row layout for muscle-memory continuity.
26275
+ const promptLines = prompt.multiline ? prompt.value.split('\n') : [prompt.value];
26276
+ if (promptLines.length === 0) {
26277
+ promptLines.push('');
26278
+ }
26279
+ const valueRows = promptLines.map((line, index) => {
26280
+ const isLast = index === promptLines.length - 1;
26281
+ const display = isLast ? `${line}_` : line;
26282
+ return h(Text, {
26283
+ key: `prompt-line-${index}`,
26284
+ bold: true,
26285
+ color: accent,
26286
+ }, truncate$1(display, width - 4));
26287
+ });
26288
+ const hint = prompt.multiline
26289
+ ? 'Enter newline · Ctrl+d submit · Esc cancel · Ctrl+u clear'
26290
+ : 'Enter submit · Esc cancel · Ctrl+u clear';
24256
26291
  return h(Box, {
24257
26292
  borderColor: focusBorderColor(theme, focused),
24258
26293
  borderStyle: theme.borderStyle,
24259
26294
  flexDirection: 'column',
24260
26295
  width,
24261
26296
  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'));
26297
+ }, 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
26298
  }
24267
26299
  function renderConfirmationPanel(h, components, state, width, theme, focused) {
24268
26300
  const { Box, Text } = components;
@@ -24436,16 +26468,31 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
24436
26468
  ? [h(Text, { key: 'palette-recent-hint', dimColor: true }, '· marks recently-used')]
24437
26469
  : []), ...itemLines);
24438
26470
  }
24439
- function renderFooter(h, components, state, theme, idleTip) {
26471
+ function renderFooter(h, components, state, context, theme, idleTip) {
24440
26472
  const { Box, Text } = components;
26473
+ // Sidebar item count drives the per-tab footer hints — when items are
26474
+ // present the footer surfaces in-sidebar ops (checkout / apply / pop /
26475
+ // drop), otherwise it falls back to the generic "enter open" hint.
26476
+ const sidebarItemCount = (() => {
26477
+ switch (state.sidebarTab) {
26478
+ case 'branches': return context.branches?.localBranches.length;
26479
+ case 'tags': return context.tags?.tags.length;
26480
+ case 'stashes': return context.stashes?.stashes.length;
26481
+ case 'worktrees': return context.worktreeList?.worktrees.length;
26482
+ default: return undefined;
26483
+ }
26484
+ })();
24441
26485
  const hints = getLogInkFooterHints({
24442
26486
  activeView: state.activeView,
24443
26487
  diffSource: state.diffSource,
26488
+ diffViewMode: state.diffViewMode,
24444
26489
  filterMode: state.filterMode,
24445
26490
  focus: state.focus,
24446
26491
  pendingKey: state.pendingKey,
24447
26492
  showCommandPalette: state.showCommandPalette,
24448
26493
  showHelp: state.showHelp,
26494
+ sidebarTab: state.sidebarTab,
26495
+ sidebarItemCount,
24449
26496
  });
24450
26497
  // Real status messages always win; idle tips only fill the slot when it
24451
26498
  // would otherwise be empty.