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
@@ -7,7 +7,7 @@ import * as fs from 'fs';
7
7
  import fs__default, { promises, existsSync, readFileSync, readdirSync } from 'fs';
8
8
  import * as ini from 'ini';
9
9
  import * as os from 'os';
10
- import os__default from 'os';
10
+ import os__default, { tmpdir } from 'os';
11
11
  import * as path from 'path';
12
12
  import path__default, { join, isAbsolute } from 'path';
13
13
  import Ajv from 'ajv';
@@ -45,6 +45,7 @@ import * as crypto from 'node:crypto';
45
45
  import * as readline from 'readline';
46
46
  import readline__default from 'readline';
47
47
  import { promisify } from 'util';
48
+ import { randomUUID } from 'crypto';
48
49
  import { pathToFileURL } from 'url';
49
50
 
50
51
  // This file is auto-generated - DO NOT EDIT
@@ -52,7 +53,7 @@ import { pathToFileURL } from 'url';
52
53
  /**
53
54
  * Current build version from package.json
54
55
  */
55
- const BUILD_VERSION = "0.37.0";
56
+ const BUILD_VERSION = "0.38.0";
56
57
 
57
58
  const isInteractive = (config) => {
58
59
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -13514,8 +13515,11 @@ const builder$3 = (yargs) => {
13514
13515
  };
13515
13516
 
13516
13517
  const FIELD_SEPARATOR$2 = '\x1f';
13517
- const LOG_FORMAT = `%x1f%h%x1f%H%x1f%ad%x1f%an%x1f%d%x1f%s`;
13518
- const DETAIL_FORMAT = `%H%x1f%h%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
13518
+ // `%P` (parent hashes, space-separated) lets the TUI distinguish
13519
+ // merge commits (parents.length > 1) from regular commits without a
13520
+ // second round-trip to git. See #791 stage 3 — merge glyph + HEAD ring.
13521
+ const LOG_FORMAT = `%x1f%h%x1f%H%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s`;
13522
+ const DETAIL_FORMAT = `%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
13519
13523
  const LOG_DEFAULT_LIMIT = 30;
13520
13524
  const LOG_INTERACTIVE_DEFAULT_LIMIT = 300;
13521
13525
  function toArray(value) {
@@ -13569,12 +13573,13 @@ function parseLogOutput(output) {
13569
13573
  graph: line,
13570
13574
  };
13571
13575
  }
13572
- const [graph, shortHash, hash, date, author, refs, message] = line.split(FIELD_SEPARATOR$2);
13576
+ const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$2);
13573
13577
  return {
13574
13578
  type: 'commit',
13575
13579
  graph: graph.trimEnd(),
13576
13580
  shortHash,
13577
13581
  hash,
13582
+ parents: parentsStr ? parentsStr.trim().split(' ').filter(Boolean) : [],
13578
13583
  date,
13579
13584
  author,
13580
13585
  refs: cleanRefs(refs),
@@ -13641,13 +13646,14 @@ function parseNameStatus(output, numstat = []) {
13641
13646
  });
13642
13647
  }
13643
13648
  function parseCommitDetail(metadata, files, numstatOutput = '') {
13644
- const [hash, shortHash, date, author, refs, message, body = ''] = metadata
13649
+ const [hash, shortHash, parentsStr, date, author, refs, message, body = ''] = metadata
13645
13650
  .trimEnd()
13646
13651
  .split(FIELD_SEPARATOR$2);
13647
13652
  const numstat = parseNumstat(numstatOutput);
13648
13653
  return {
13649
13654
  shortHash,
13650
13655
  hash,
13656
+ parents: parentsStr ? parentsStr.trim().split(' ').filter(Boolean) : [],
13651
13657
  date,
13652
13658
  author,
13653
13659
  refs: cleanRefs(refs),
@@ -13701,6 +13707,24 @@ function buildLogArgs(argv, options = {}) {
13701
13707
  }
13702
13708
  return args;
13703
13709
  }
13710
+ /**
13711
+ * Build merged `LogArgv` for the interactive TUI's `g` graph toggle.
13712
+ *
13713
+ * The TUI tracks a transient `fullGraph` boolean; toggling it must produce
13714
+ * a fresh fetch with the right `view` so the renderer actually has graph
13715
+ * topology to draw. When switching to full mode we override `view` to
13716
+ * `'full'` (which `buildLogArgs` already maps to `--all`, dropping
13717
+ * `--first-parent`/`--no-merges`). When switching back we honor the user's
13718
+ * original `view` from argv, defaulting to `'compact'`.
13719
+ *
13720
+ * Pure helper so the effect that calls it stays trivially testable.
13721
+ */
13722
+ function buildToggleGraphArgs(argv, fullGraph) {
13723
+ if (fullGraph) {
13724
+ return { ...argv, view: 'full' };
13725
+ }
13726
+ return { ...argv, view: argv.view ?? 'compact' };
13727
+ }
13704
13728
  async function getLogRows(git, argv, options = {}) {
13705
13729
  return parseLogOutput(await git.raw(buildLogArgs(argv, options)));
13706
13730
  }
@@ -13942,7 +13966,7 @@ function splitCommitDraft(draft) {
13942
13966
  body,
13943
13967
  };
13944
13968
  }
13945
- function compactOutputLines$4(output) {
13969
+ function compactOutputLines$5(output) {
13946
13970
  return output
13947
13971
  .split('\n')
13948
13972
  .map((line) => line.trim())
@@ -13950,14 +13974,14 @@ function compactOutputLines$4(output) {
13950
13974
  }
13951
13975
  function formatManualCommitFailure(error) {
13952
13976
  if (error instanceof PreCommitHookError) {
13953
- const details = compactOutputLines$4(error.hookOutput);
13977
+ const details = compactOutputLines$5(error.hookOutput);
13954
13978
  return {
13955
13979
  ok: false,
13956
13980
  message: `Commit blocked by hook: ${details[0] || 'hook failed'}`,
13957
13981
  details: details.slice(1, 6),
13958
13982
  };
13959
13983
  }
13960
- const details = compactOutputLines$4(error.message);
13984
+ const details = compactOutputLines$5(error.message);
13961
13985
  return {
13962
13986
  ok: false,
13963
13987
  message: details[0] || 'Commit failed.',
@@ -14258,7 +14282,7 @@ function formatCommitWorkflowMessage(action, output) {
14258
14282
  }
14259
14283
  return 'Generated commit message.';
14260
14284
  }
14261
- function compactOutputLines$3(output) {
14285
+ function compactOutputLines$4(output) {
14262
14286
  return output
14263
14287
  .split('\n')
14264
14288
  .map((line) => line.trim())
@@ -14266,14 +14290,14 @@ function compactOutputLines$3(output) {
14266
14290
  }
14267
14291
  function formatCommitFailure(error) {
14268
14292
  if (error instanceof PreCommitHookError) {
14269
- const details = compactOutputLines$3(error.hookOutput);
14293
+ const details = compactOutputLines$4(error.hookOutput);
14270
14294
  return {
14271
14295
  ok: false,
14272
14296
  message: `Commit blocked by hook: ${details[0] || 'hook failed'}`,
14273
14297
  details: details.slice(1, 6),
14274
14298
  };
14275
14299
  }
14276
- const details = compactOutputLines$3(error.message);
14300
+ const details = compactOutputLines$4(error.message);
14277
14301
  return {
14278
14302
  ok: false,
14279
14303
  message: details[0] || 'Commit action failed.',
@@ -14306,7 +14330,7 @@ async function runCommitWorkflow({ action, git = getRepo(), noVerify = false, })
14306
14330
  }
14307
14331
  catch (error) {
14308
14332
  if (isCommandExitError(error)) {
14309
- const lines = compactOutputLines$3(output || error.message);
14333
+ const lines = compactOutputLines$4(output || error.message);
14310
14334
  return {
14311
14335
  ok: error.code === 0,
14312
14336
  message: lines[0] || error.message,
@@ -14348,7 +14372,7 @@ async function runCommitDraftWorkflow(input = {}) {
14348
14372
  }
14349
14373
  catch (error) {
14350
14374
  if (isCommandExitError(error)) {
14351
- const lines = compactOutputLines$3(error.message);
14375
+ const lines = compactOutputLines$4(error.message);
14352
14376
  return {
14353
14377
  ok: error.code === 0,
14354
14378
  message: lines[0] || error.message,
@@ -14386,6 +14410,274 @@ function isLogInkContextKeyLoading(status, key) {
14386
14410
  return status[key] === 'loading';
14387
14411
  }
14388
14412
 
14413
+ /**
14414
+ * The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
14415
+ * `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
14416
+ * but the angles read poorly when many branches overlap.
14417
+ *
14418
+ * `substituteGraphChars` walks the row left-to-right with one-char
14419
+ * lookahead so it can recognize git's two-char junction patterns and
14420
+ * emit proper box-drawing junctions (├╮ / ├╯) instead of overlapping
14421
+ * pipes (│╲ / │╱). Anything that isn't part of a recognized pattern
14422
+ * falls back to the legacy 1-to-1 substitution.
14423
+ *
14424
+ * `theme.ascii` (TERM=dumb / vt100) bypasses substitution entirely so
14425
+ * legacy terminals get the raw `git log --graph` output. `theme.noColor`
14426
+ * is orthogonal — Unicode chars still render, just without color.
14427
+ *
14428
+ * Kept ASCII-only intentionally:
14429
+ * - alphanumerics (commit refs / annotations git sometimes injects)
14430
+ * - parens / brackets (HEAD decoration markers, not part of the graph)
14431
+ * - hyphens / colons (likewise)
14432
+ */
14433
+ const ASCII_TO_UNICODE_MAP = {
14434
+ '*': '●',
14435
+ '|': '│',
14436
+ '/': '╱',
14437
+ '\\': '╲',
14438
+ '_': '─',
14439
+ };
14440
+ const DEFAULT_COMMIT_GLYPH = '●';
14441
+ /**
14442
+ * #791 stage 3 — distinct glyphs for merges and HEAD so they stand
14443
+ * out from the run of regular commits. `◆` (filled diamond) flags a
14444
+ * merge commit (`parents.length > 1`); `◉` (fisheye) flags HEAD
14445
+ * regardless of parent count. Both render at the same column width as
14446
+ * `●` so graph alignment stays intact across mixed commit types.
14447
+ */
14448
+ const MERGE_COMMIT_GLYPH = '◆';
14449
+ const HEAD_COMMIT_GLYPH = '◉';
14450
+ /**
14451
+ * Recognized 2-char junction patterns. The key is the bigram git emits
14452
+ * (lane char + spacer char); the value is the box-drawing pair we render.
14453
+ *
14454
+ * - `|\` (fork): trunk lane gains a right-T (├) and the spacer becomes
14455
+ * the upper-right corner (╮) starting the new lane below.
14456
+ * - `|/` (converge): trunk lane gains a right-T (├) and the spacer
14457
+ * becomes the upper-left corner (╯) absorbing the side lane from
14458
+ * above.
14459
+ *
14460
+ * `*\` and `* /` (commit-row variants) are handled the same way, but
14461
+ * the commit glyph itself stays configurable via `commitGlyph` so
14462
+ * stage 3 can swap in `◆` / `◉` for merges and HEAD.
14463
+ */
14464
+ const PIPE_FORK = '├╮';
14465
+ const PIPE_CONVERGE = '├╯';
14466
+ const FORK_SPACER = '╮';
14467
+ const CONVERGE_SPACER = '╯';
14468
+ function substituteGraphChars(graph, options) {
14469
+ if (options.ascii) {
14470
+ return graph;
14471
+ }
14472
+ const commitGlyph = options.commitGlyph ?? DEFAULT_COMMIT_GLYPH;
14473
+ let output = '';
14474
+ let i = 0;
14475
+ while (i < graph.length) {
14476
+ const a = graph[i];
14477
+ const b = i + 1 < graph.length ? graph[i + 1] : '';
14478
+ if (a === '|' && b === '\\') {
14479
+ output += PIPE_FORK;
14480
+ i += 2;
14481
+ continue;
14482
+ }
14483
+ if (a === '|' && b === '/') {
14484
+ output += PIPE_CONVERGE;
14485
+ i += 2;
14486
+ continue;
14487
+ }
14488
+ if (a === '*' && b === '\\') {
14489
+ output += commitGlyph + FORK_SPACER;
14490
+ i += 2;
14491
+ continue;
14492
+ }
14493
+ if (a === '*' && b === '/') {
14494
+ output += commitGlyph + CONVERGE_SPACER;
14495
+ i += 2;
14496
+ continue;
14497
+ }
14498
+ if (a === '*') {
14499
+ output += commitGlyph;
14500
+ }
14501
+ else {
14502
+ output += ASCII_TO_UNICODE_MAP[a] ?? a;
14503
+ }
14504
+ i += 1;
14505
+ }
14506
+ return output;
14507
+ }
14508
+
14509
+ /**
14510
+ * Lane tracking + per-lane coloring for the Ink log TUI graph (#791
14511
+ * stage 2).
14512
+ *
14513
+ * `git log --graph` emits topology in 2-char patterns where every even
14514
+ * position is a lane column (`*`, `|`, ` `) and every odd position is
14515
+ * a spacer that may carry a connector (`\`, `/`, `_`). To color graph
14516
+ * chars by which logical lane they belong to, we walk the rows
14517
+ * left-to-right tracking which lane id occupies each column and apply
14518
+ * git's emission rules:
14519
+ *
14520
+ * - `|\` (fork): the spacer spawns a new lane id at column +1 below.
14521
+ * - `|/` (converge): the spacer absorbs the lane at column +1 into
14522
+ * this column; the absorbed lane disappears in the next row.
14523
+ * - `*` is treated like `|` for lane purposes (a commit lives on a
14524
+ * lane and connects through the row).
14525
+ *
14526
+ * Lane ids are stable across rows for the same column unless one of
14527
+ * the transition patterns above fires. Other shifts (multi-step `_`
14528
+ * crossings, octopus merges) degrade gracefully — uncovered chars
14529
+ * just fall back to `undefined` lane id, so they render in the muted
14530
+ * graph color rather than a wrong lane color.
14531
+ *
14532
+ * The segment builder collapses adjacent characters with the same
14533
+ * lane id into one `LaneSegment` so the renderer emits one Text span
14534
+ * per visually-distinct color region instead of per-char.
14535
+ */
14536
+ function createLaneTrackerState() {
14537
+ return { columnLanes: new Map(), nextLaneId: 0 };
14538
+ }
14539
+ /**
14540
+ * Walk a single graph row left-to-right, mutating the tracker so the
14541
+ * next row sees the updated column → lane id map. Returns lane
14542
+ * segments ready for the renderer. When `options.ascii` is true the
14543
+ * tracker is left untouched and the row is emitted as a single
14544
+ * lane-less segment so legacy terminals get raw ASCII output with no
14545
+ * coloring.
14546
+ */
14547
+ function renderGraphRowSegments(graph, tracker, options) {
14548
+ if (options.ascii) {
14549
+ return [{ text: graph, laneId: undefined }];
14550
+ }
14551
+ const commitGlyph = options.commitGlyph ?? DEFAULT_COMMIT_GLYPH;
14552
+ const segments = [];
14553
+ const push = (text, laneId) => {
14554
+ const last = segments[segments.length - 1];
14555
+ if (last && last.laneId === laneId) {
14556
+ last.text += text;
14557
+ }
14558
+ else {
14559
+ segments.push({ text, laneId });
14560
+ }
14561
+ };
14562
+ let i = 0;
14563
+ while (i < graph.length) {
14564
+ const c = graph[i];
14565
+ const next = i + 1 < graph.length ? graph[i + 1] : '';
14566
+ const col = i >> 1;
14567
+ const isSpacer = (i & 1) === 1;
14568
+ if (c === ' ') {
14569
+ push(' ', undefined);
14570
+ i += 1;
14571
+ continue;
14572
+ }
14573
+ if (!isSpacer && (c === '|' || c === '*')) {
14574
+ if (!tracker.columnLanes.has(col)) {
14575
+ tracker.columnLanes.set(col, tracker.nextLaneId++);
14576
+ }
14577
+ const laneId = tracker.columnLanes.get(col);
14578
+ const glyph = c === '|' ? '│' : commitGlyph;
14579
+ if (next === '\\') {
14580
+ const newLaneId = tracker.nextLaneId++;
14581
+ tracker.columnLanes.set(col + 1, newLaneId);
14582
+ push(c === '|' ? '├' : commitGlyph, laneId);
14583
+ push('╮', newLaneId);
14584
+ i += 2;
14585
+ continue;
14586
+ }
14587
+ if (next === '/') {
14588
+ const absorbedLaneId = tracker.columnLanes.get(col + 1);
14589
+ push(c === '|' ? '├' : commitGlyph, laneId);
14590
+ push('╯', absorbedLaneId);
14591
+ tracker.columnLanes.delete(col + 1);
14592
+ i += 2;
14593
+ continue;
14594
+ }
14595
+ push(glyph, laneId);
14596
+ i += 1;
14597
+ continue;
14598
+ }
14599
+ // Non-lane chars (standalone `\`, `/`, `_`, decorations) — substitute
14600
+ // 1-to-1 and leave the lane id undefined so they render in the muted
14601
+ // fallback color.
14602
+ push(ASCII_TO_UNICODE_MAP[c] ?? c, undefined);
14603
+ i += 1;
14604
+ }
14605
+ return segments;
14606
+ }
14607
+ /**
14608
+ * Run the tracker over `count` rows starting from `state.rows[0]` so
14609
+ * downstream callers can resume tracking from a specific window
14610
+ * without re-scanning. Used by `getVisibleLogInkHistory` to keep lane
14611
+ * ids stable across scrolling — without this, each scroll would
14612
+ * re-color lanes from a fresh tracker.
14613
+ */
14614
+ function advanceTrackerThrough(graphs, tracker, count) {
14615
+ for (let i = 0; i < count && i < graphs.length; i++) {
14616
+ renderGraphRowSegments(graphs[i], tracker, { ascii: false });
14617
+ }
14618
+ }
14619
+ /**
14620
+ * Theme-aware lane palette. Default uses bright ANSI named colors that
14621
+ * render reliably on 16-color terminals; catppuccin / gruvbox lift
14622
+ * accent hues from their respective palettes so the graph stays
14623
+ * coherent with the surrounding chrome.
14624
+ *
14625
+ * Selecting 8 colors gives enough variety to distinguish lanes in
14626
+ * practice (most repos peak at 3-4 simultaneous lanes); the modulo
14627
+ * lookup wraps cleanly for the rare case of more.
14628
+ */
14629
+ const DEFAULT_LANE_PALETTE = [
14630
+ 'cyan', 'magenta', 'yellow', 'green', 'blue', 'red', 'cyanBright', 'magentaBright',
14631
+ ];
14632
+ const CATPPUCCIN_LANE_PALETTE = [
14633
+ '#89b4fa', '#f5c2e7', '#f9e2af', '#a6e3a1', '#cba6f7', '#fab387', '#94e2d5', '#f5e0dc',
14634
+ ];
14635
+ const GRUVBOX_LANE_PALETTE = [
14636
+ '#83a598', '#d3869b', '#fabd2f', '#b8bb26', '#d65d0e', '#fb4934', '#8ec07c', '#fe8019',
14637
+ ];
14638
+ function getLanePalette(theme) {
14639
+ if (theme.noColor) {
14640
+ return [];
14641
+ }
14642
+ const accent = theme.colors.accent;
14643
+ if (accent === '#89b4fa') {
14644
+ return CATPPUCCIN_LANE_PALETTE;
14645
+ }
14646
+ if (accent === '#83a598') {
14647
+ return GRUVBOX_LANE_PALETTE;
14648
+ }
14649
+ return DEFAULT_LANE_PALETTE;
14650
+ }
14651
+ function getLaneColor(laneId, theme) {
14652
+ if (laneId === undefined) {
14653
+ return undefined;
14654
+ }
14655
+ const palette = getLanePalette(theme);
14656
+ if (palette.length === 0) {
14657
+ return undefined;
14658
+ }
14659
+ return palette[laneId % palette.length];
14660
+ }
14661
+
14662
+ /**
14663
+ * Pick the commit glyph based on parent count + HEAD-ness so the
14664
+ * renderer can flag merges and the current head visually. HEAD wins
14665
+ * over merge when both apply (HEAD on a merge commit) — the ◉ ring
14666
+ * is the more salient signal and the user can still see the merge
14667
+ * via the lane topology.
14668
+ */
14669
+ function commitGlyphFor(commit) {
14670
+ if (isHeadCommit$1(commit)) {
14671
+ return HEAD_COMMIT_GLYPH;
14672
+ }
14673
+ if (commit.parents.length > 1) {
14674
+ return MERGE_COMMIT_GLYPH;
14675
+ }
14676
+ return DEFAULT_COMMIT_GLYPH;
14677
+ }
14678
+ function isHeadCommit$1(commit) {
14679
+ return commit.refs.some((ref) => ref === 'HEAD' || ref.startsWith('HEAD ->'));
14680
+ }
14389
14681
  function clampWindowStart(index, count, visibleCount) {
14390
14682
  return Math.max(0, Math.min(index - Math.floor(visibleCount / 2), Math.max(0, count - visibleCount)));
14391
14683
  }
@@ -14401,6 +14693,11 @@ function toCompactItems(state, visibleCount) {
14401
14693
  type: 'commit',
14402
14694
  commit,
14403
14695
  graph: '*',
14696
+ // Compact mode skips lane tracking (no topology to color) but still
14697
+ // wants the merge / HEAD glyph so the user can spot them at a
14698
+ // glance. Lane id stays undefined so the segment renders muted —
14699
+ // matching the legacy compact appearance, just with a richer glyph.
14700
+ laneSegments: [{ text: commitGlyphFor(commit), laneId: undefined }],
14404
14701
  selected: start + offset === state.selectedIndex,
14405
14702
  }));
14406
14703
  }
@@ -14411,17 +14708,28 @@ function toFullGraphItems(state, visibleCount) {
14411
14708
  const selected = state.filteredCommits[state.selectedIndex];
14412
14709
  const selectedRowIndex = state.rows.findIndex((row) => isSelectedCommit(row, selected));
14413
14710
  const start = clampWindowStart(selectedRowIndex >= 0 ? selectedRowIndex : 0, state.rows.length, visibleCount);
14711
+ // Lane tracking is order-dependent — fast-forward the tracker through
14712
+ // every row above the visible window so lane ids stay stable as the
14713
+ // user scrolls. Without this, scrolling would re-color lanes from a
14714
+ // fresh tracker each time.
14715
+ const tracker = createLaneTrackerState();
14716
+ const allGraphs = state.rows.map((row) => (row.type === 'commit' ? row.graph || '*' : row.graph));
14717
+ advanceTrackerThrough(allGraphs, tracker, start);
14414
14718
  return state.rows.slice(start, start + visibleCount).map((row) => {
14415
14719
  if (row.type === 'graph') {
14416
14720
  return {
14417
14721
  type: 'graph',
14418
14722
  graph: row.graph,
14723
+ laneSegments: renderGraphRowSegments(row.graph, tracker, { ascii: false }),
14419
14724
  };
14420
14725
  }
14726
+ const graph = row.graph || '*';
14727
+ const commitGlyph = commitGlyphFor(row);
14421
14728
  return {
14422
14729
  type: 'commit',
14423
14730
  commit: row,
14424
- graph: row.graph || '*',
14731
+ graph,
14732
+ laneSegments: renderGraphRowSegments(graph, tracker, { ascii: false, commitGlyph }),
14425
14733
  selected: isSelectedCommit(row, selected),
14426
14734
  };
14427
14735
  });
@@ -14570,6 +14878,34 @@ function getLogInkWorkflowActions() {
14570
14878
  kind: 'destructive',
14571
14879
  requiresConfirmation: true,
14572
14880
  },
14881
+ {
14882
+ // Per-view-only: scoped to commit-diff and stash-diff explores in
14883
+ // inkInput (key: H). The action is non-destructive in the sense
14884
+ // that `git apply` won't lose any data — `git apply -R` undoes
14885
+ // it cleanly — so it bypasses the y-confirm path. The patch text
14886
+ // travels via the action's `payload` field. Empty key keeps the
14887
+ // workflow palette-discoverable without registering a global
14888
+ // hotkey (the palette path can't synthesize the patch text and
14889
+ // surfaces a hint instead — actual dispatch is from H in diff
14890
+ // view).
14891
+ id: 'apply-hunk-worktree',
14892
+ key: '',
14893
+ label: 'Apply hunk to worktree',
14894
+ description: 'Extract the hunk under the cursor and apply it to the working tree via `git apply`.',
14895
+ kind: 'normal',
14896
+ requiresConfirmation: false,
14897
+ },
14898
+ {
14899
+ // Sibling of `apply-hunk-worktree` — same extraction path, but
14900
+ // `git apply --cached` so the patch lands in the index without
14901
+ // touching the worktree. Bound to the `gH` chord in inkInput.
14902
+ id: 'apply-hunk-index',
14903
+ key: '',
14904
+ label: 'Apply hunk to index',
14905
+ description: 'Extract the hunk under the cursor and apply it to the index via `git apply --cached`.',
14906
+ kind: 'normal',
14907
+ requiresConfirmation: false,
14908
+ },
14573
14909
  {
14574
14910
  id: 'open-pr',
14575
14911
  key: 'O',
@@ -14662,6 +14998,88 @@ function getLogInkWorkflowActions() {
14662
14998
  kind: 'destructive',
14663
14999
  requiresConfirmation: true,
14664
15000
  },
15001
+ // #783 — full PR action panel. All five entries are palette-only
15002
+ // (`key: ''`) — actual dispatch is per-view scoped in inkInput so
15003
+ // the keys stay free outside the pull-request view. Merge / close /
15004
+ // approve / request-changes route through the y-confirm path
15005
+ // because each is irreversible (or near-irreversible) once gh
15006
+ // publishes it; comment is a free-form prompt with no extra
15007
+ // confirmation since the body itself is the affirmative action.
15008
+ {
15009
+ id: 'merge-pr',
15010
+ key: '',
15011
+ label: 'Merge pull request',
15012
+ description: 'Merge the current branch\'s pull request (prompts for merge / squash / rebase, then confirms).',
15013
+ kind: 'destructive',
15014
+ requiresConfirmation: true,
15015
+ },
15016
+ {
15017
+ id: 'close-pr',
15018
+ key: '',
15019
+ label: 'Close pull request',
15020
+ description: 'Close the current pull request without merging.',
15021
+ kind: 'destructive',
15022
+ requiresConfirmation: true,
15023
+ },
15024
+ {
15025
+ id: 'approve-pr',
15026
+ key: '',
15027
+ label: 'Approve pull request',
15028
+ description: 'Submit an approving review on the current pull request.',
15029
+ kind: 'normal',
15030
+ requiresConfirmation: true,
15031
+ },
15032
+ {
15033
+ id: 'request-changes-pr',
15034
+ key: '',
15035
+ label: 'Request changes on pull request',
15036
+ description: 'Submit a change-request review (prompts for the review body, then confirms).',
15037
+ kind: 'normal',
15038
+ requiresConfirmation: true,
15039
+ },
15040
+ {
15041
+ id: 'comment-pr',
15042
+ key: '',
15043
+ label: 'Comment on pull request',
15044
+ description: 'Add a comment to the current pull request (prompts for body).',
15045
+ kind: 'normal',
15046
+ requiresConfirmation: false,
15047
+ },
15048
+ {
15049
+ // Per-view-only: scoped to the history view in inkInput so `R`
15050
+ // doesn't fire elsewhere (it's also `R` for rename in branches
15051
+ // and delete-remote-tag in tags). Empty key keeps it
15052
+ // palette-discoverable without registering a global hotkey.
15053
+ id: 'revert-commit',
15054
+ key: '',
15055
+ label: 'Revert commit',
15056
+ description: 'Revert the cursored commit by adding an inverse commit on top of HEAD.',
15057
+ kind: 'destructive',
15058
+ requiresConfirmation: true,
15059
+ },
15060
+ {
15061
+ // Per-view-only: scoped to the history view in inkInput. Triggers
15062
+ // a mode prompt (soft / mixed / hard) before the reset runs so
15063
+ // `Z` alone never silently rewrites history.
15064
+ id: 'reset-to-commit',
15065
+ key: '',
15066
+ label: 'Reset to commit',
15067
+ description: 'Move the current branch tip to the cursored commit (prompts for soft / mixed / hard).',
15068
+ kind: 'destructive',
15069
+ requiresConfirmation: true,
15070
+ },
15071
+ {
15072
+ // Per-view-only: scoped to the history view in inkInput. `i`
15073
+ // (lowercase) is used instead of `I` so the existing `I`
15074
+ // ai-commit-summary workflow stays reachable on the history
15075
+ // view — `i` matches the `git rebase -i` flag mnemonic anyway.
15076
+ id: 'interactive-rebase',
15077
+ key: '',
15078
+ label: 'Interactive rebase',
15079
+ description: 'Start an interactive rebase from the cursored commit (opens $GIT_EDITOR for the todo list).',
15080
+ kind: 'destructive',
15081
+ requiresConfirmation: true,
15082
+ },
14665
15083
  {
14666
15084
  id: 'ai-commit-summary',
14667
15085
  key: 'I',
@@ -14683,6 +15101,15 @@ function getLogInkWorkflowActions() {
14683
15101
  ];
14684
15102
  }
14685
15103
  function getLogInkWorkflowActionByKey(inputValue) {
15104
+ // Workflow actions with an empty `key` are palette-only — they
15105
+ // exist so the command palette can surface them but should never
15106
+ // match a raw keystroke. Without this guard, any unbound key
15107
+ // (left/right arrow, function keys) that arrives with an empty
15108
+ // inputValue would `find()` the first empty-key entry —
15109
+ // `cherry-pick-commit` — and pop its confirmation dialog.
15110
+ if (!inputValue) {
15111
+ return undefined;
15112
+ }
14686
15113
  return getLogInkWorkflowActions().find((action) => action.key === inputValue);
14687
15114
  }
14688
15115
  function getLogInkWorkflowActionById(id) {
@@ -14812,6 +15239,13 @@ const LOG_INK_KEY_BINDINGS = [
14812
15239
  description: 'Toggle compact and full graph display.',
14813
15240
  contexts: ['normal', 'commits'],
14814
15241
  },
15242
+ {
15243
+ id: 'toggleDiffViewMode',
15244
+ keys: ['d'],
15245
+ label: 'split/unified',
15246
+ description: 'Toggle the diff view between unified and side-by-side split rendering. Falls back to unified on narrow terminals.',
15247
+ contexts: ['commits'],
15248
+ },
14815
15249
  {
14816
15250
  id: 'navigateHome',
14817
15251
  keys: ['gh'],
@@ -14868,6 +15302,13 @@ const LOG_INK_KEY_BINDINGS = [
14868
15302
  description: 'Push the linked worktrees view.',
14869
15303
  contexts: ['normal'],
14870
15304
  },
15305
+ {
15306
+ id: 'navigatePullRequest',
15307
+ keys: ['gp'],
15308
+ label: 'pull request',
15309
+ description: 'Push the dedicated pull-request action panel for the current branch.',
15310
+ contexts: ['normal'],
15311
+ },
14871
15312
  {
14872
15313
  id: 'navigateBack',
14873
15314
  keys: ['<', 'esc'],
@@ -14996,6 +15437,7 @@ const GLOBAL_BINDING_IDS = [
14996
15437
  'navigateTags',
14997
15438
  'navigateStash',
14998
15439
  'navigateWorktrees',
15440
+ 'navigatePullRequest',
14999
15441
  'navigateBack',
15000
15442
  ];
15001
15443
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -15056,8 +15498,37 @@ function getLogInkFooterHints(options) {
15056
15498
  };
15057
15499
  }
15058
15500
  if (options.focus === 'sidebar') {
15501
+ // Per-tab hints when the active tab has selectable items — the user
15502
+ // can act on the cursored entity without leaving the workstation
15503
+ // view. Status tab + empty content tabs fall back to the generic
15504
+ // "enter open" hint that drills into the dedicated view.
15505
+ const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
15506
+ if (itemsPresent && options.sidebarTab === 'branches') {
15507
+ return {
15508
+ contextual: ['↑/↓ branches', '←/→ tab', 'enter checkout', 'D delete', 'R rename', 'u upstream'],
15509
+ global: NORMAL_GLOBAL_HINTS,
15510
+ };
15511
+ }
15512
+ if (itemsPresent && options.sidebarTab === 'stashes') {
15513
+ return {
15514
+ contextual: ['↑/↓ stashes', '←/→ tab', 'enter diff', 'a apply', 'p pop', 'X drop'],
15515
+ global: NORMAL_GLOBAL_HINTS,
15516
+ };
15517
+ }
15518
+ if (itemsPresent && options.sidebarTab === 'tags') {
15519
+ return {
15520
+ contextual: ['↑/↓ tags', '←/→ tab', '+ new', 'P push', 'T delete'],
15521
+ global: NORMAL_GLOBAL_HINTS,
15522
+ };
15523
+ }
15524
+ if (itemsPresent && options.sidebarTab === 'worktrees') {
15525
+ return {
15526
+ contextual: ['↑/↓ worktrees', '←/→ tab', 'W remove'],
15527
+ global: NORMAL_GLOBAL_HINTS,
15528
+ };
15529
+ }
15059
15530
  return {
15060
- contextual: ['[/] tab', '1-5 jump', 'tab focus'],
15531
+ contextual: ['←/→ tab', '1-5 jump', 'enter open', 'tab focus'],
15061
15532
  global: NORMAL_GLOBAL_HINTS,
15062
15533
  };
15063
15534
  }
@@ -15074,17 +15545,23 @@ function getLogInkFooterHints(options) {
15074
15545
  };
15075
15546
  }
15076
15547
  if (options.activeView === 'diff') {
15548
+ // Surface what `d` will switch *to* — labels the next mode rather
15549
+ // than the current one so the hint reads as a verb. The split-mode
15550
+ // hint is only shown for the read-only diff sources (commit/stash);
15551
+ // the worktree diff stays unified-only for now.
15552
+ const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
15077
15553
  if (options.diffSource === 'stash') {
15078
15554
  return {
15079
- contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'o edit', 'y yank', 'esc back'],
15555
+ contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', 'o edit', splitToggleHint, 'y yank', 'esc back'],
15080
15556
  global: NORMAL_GLOBAL_HINTS,
15081
15557
  };
15082
15558
  }
15083
15559
  if (options.diffSource === 'commit') {
15084
15560
  // Commit-diff explore: read-only diff, but `c` cherry-picks the
15085
- // cursored file from the commit into the worktree.
15561
+ // cursored file from the commit into the worktree, and `H`
15562
+ // (or `gH` for index) applies just the cursored hunk.
15086
15563
  return {
15087
- contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'y/Y yank', 'esc back'],
15564
+ contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'y/Y yank', 'esc back'],
15088
15565
  global: NORMAL_GLOBAL_HINTS,
15089
15566
  };
15090
15567
  }
@@ -15123,8 +15600,23 @@ function getLogInkFooterHints(options) {
15123
15600
  global: NORMAL_GLOBAL_HINTS,
15124
15601
  };
15125
15602
  }
15603
+ if (options.activeView === 'pull-request') {
15604
+ return {
15605
+ // #783 — full PR action panel. Five mutating ops scoped to this
15606
+ // view: m / x / a / R / c, plus O for open-in-browser (already
15607
+ // a global). Each routes through y-confirm or an input prompt;
15608
+ // none fire silently.
15609
+ contextual: ['m merge', 'x close', 'a approve', 'R changes', 'c comment', 'O open', 'esc back'],
15610
+ global: NORMAL_GLOBAL_HINTS,
15611
+ };
15612
+ }
15126
15613
  return {
15127
- contextual: ['↑/↓ move', 'enter diff', 'c cherry-pick', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15614
+ // History view default hints. Mutating ops (`c` cherry-pick, `R`
15615
+ // revert, `Z` reset, `i` interactive-rebase) all route through a
15616
+ // y-confirm or mode prompt — none fire silently from the keystroke.
15617
+ // Grouped into a compact `c/R/Z/i mutate` chip so the footer stays
15618
+ // scannable; full descriptions live in `?` help and the palette.
15619
+ contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15128
15620
  global: NORMAL_GLOBAL_HINTS,
15129
15621
  };
15130
15622
  }
@@ -15267,39 +15759,6 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
15267
15759
  .map((entry) => entry.command);
15268
15760
  }
15269
15761
 
15270
- /**
15271
- * The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
15272
- * `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
15273
- * but the angles read poorly when many branches overlap.
15274
- *
15275
- * `substituteGraphChars` swaps them for box-drawing / geometric Unicode
15276
- * equivalents when the terminal can render them; falls back to ASCII
15277
- * under `theme.ascii` (TERM=dumb / vt100) and `theme.noColor` is
15278
- * orthogonal — the Unicode chars are still rendered, just without color.
15279
- *
15280
- * Kept ASCII-only intentionally:
15281
- * - alphanumerics (commit refs / annotations git sometimes injects)
15282
- * - parens / brackets (HEAD decoration markers, not part of the graph)
15283
- * - hyphens / colons (likewise)
15284
- */
15285
- const ASCII_TO_UNICODE = {
15286
- '*': '●',
15287
- '|': '│',
15288
- '/': '╱',
15289
- '\\': '╲',
15290
- '_': '─',
15291
- };
15292
- function substituteGraphChars(graph, options) {
15293
- if (options.ascii) {
15294
- return graph;
15295
- }
15296
- let output = '';
15297
- for (const character of graph) {
15298
- output += ASCII_TO_UNICODE[character] ?? character;
15299
- }
15300
- return output;
15301
- }
15302
-
15303
15762
  /**
15304
15763
  * OSC 8 hyperlink helpers for the Ink TUI (P5.1).
15305
15764
  *
@@ -15371,6 +15830,84 @@ function formatHyperlink(text, url, env = process.env) {
15371
15830
  return `${OSC_PREFIX}${url}${ST}${text}${OSC_PREFIX}${ST}`;
15372
15831
  }
15373
15832
 
15833
+ /**
15834
+ * Extract a single hunk from a unified-patch diff so it can be fed to
15835
+ * `git apply` (or `git apply --cached`) for a hunk-level cherry-pick.
15836
+ *
15837
+ * The TUI's diff explore views render two flavors of patch text:
15838
+ *
15839
+ * - stash-diff: full `git stash show -p` output, which includes
15840
+ * `diff --git`, `---`, `+++`, and one or more `@@ ... @@` hunks
15841
+ * per file.
15842
+ * - commit-diff: the per-file `filePreview.hunks` array, which is
15843
+ * hunks-only (no `diff --git` / `---` / `+++` headers).
15844
+ *
15845
+ * Either way, this helper walks `lines` from `cursorOffset` backwards
15846
+ * to find the most recent `@@` header, walks forward to the end of
15847
+ * that hunk's body, and synthesizes a fresh `diff --git` /
15848
+ * `---` / `+++` set using the caller-provided path. The output is a
15849
+ * complete, self-contained patch suitable for `git apply` without
15850
+ * having to preserve original headers from `lines`.
15851
+ */
15852
+ const HUNK_HEADER_PREFIX = '@@';
15853
+ const DIFF_GIT_PREFIX = 'diff --git ';
15854
+ /**
15855
+ * Find the index of the `@@` hunk header at or before `cursorOffset`.
15856
+ * Returns -1 when the cursor sits before the first hunk in the patch
15857
+ * (i.e. on a `diff --git` / `---` / `+++` header line) — caller treats
15858
+ * that as "no hunk at cursor" and surfaces a status message.
15859
+ */
15860
+ function findHunkHeaderAtOrBefore(lines, cursorOffset) {
15861
+ const start = Math.min(cursorOffset, lines.length - 1);
15862
+ for (let i = start; i >= 0; i -= 1) {
15863
+ if (lines[i]?.startsWith(HUNK_HEADER_PREFIX)) {
15864
+ return i;
15865
+ }
15866
+ }
15867
+ return -1;
15868
+ }
15869
+ /**
15870
+ * Walk forward from a hunk header to either the next `@@` header or
15871
+ * the next `diff --git` line — that's where this hunk's body ends.
15872
+ * The end index is exclusive (the line at `endIndex` is NOT part of
15873
+ * this hunk).
15874
+ */
15875
+ function findHunkBodyEnd(lines, headerIndex) {
15876
+ for (let i = headerIndex + 1; i < lines.length; i += 1) {
15877
+ const line = lines[i];
15878
+ if (line?.startsWith(HUNK_HEADER_PREFIX) || line?.startsWith(DIFF_GIT_PREFIX)) {
15879
+ return i;
15880
+ }
15881
+ }
15882
+ return lines.length;
15883
+ }
15884
+ function extractDiffHunk(input) {
15885
+ const { lines, cursorOffset, path } = input;
15886
+ if (!lines.length || !path) {
15887
+ return null;
15888
+ }
15889
+ const headerIndex = findHunkHeaderAtOrBefore(lines, cursorOffset);
15890
+ if (headerIndex < 0) {
15891
+ return null;
15892
+ }
15893
+ const bodyEnd = findHunkBodyEnd(lines, headerIndex);
15894
+ // Header itself + at least one body line. An empty hunk body would
15895
+ // mean the patch is malformed and `git apply` would reject it; bail
15896
+ // out early so the caller can surface a clear status message.
15897
+ if (bodyEnd <= headerIndex + 1) {
15898
+ return null;
15899
+ }
15900
+ const hunkLines = lines.slice(headerIndex, bodyEnd);
15901
+ const patchText = [
15902
+ `diff --git a/${path} b/${path}`,
15903
+ `--- a/${path}`,
15904
+ `+++ b/${path}`,
15905
+ ...hunkLines,
15906
+ '',
15907
+ ].join('\n');
15908
+ return { patchText };
15909
+ }
15910
+
15374
15911
  /**
15375
15912
  * Sort modes for the promoted views (P4.2).
15376
15913
  *
@@ -15740,6 +16277,7 @@ function createLogInkState(rows, options = {}) {
15740
16277
  sidebarTab: 'status',
15741
16278
  userSidebarTab: 'status',
15742
16279
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
16280
+ diffViewMode: 'unified',
15743
16281
  };
15744
16282
  }
15745
16283
  function getSelectedInkCommit(state) {
@@ -15877,6 +16415,7 @@ function applyLogInkAction(state, action) {
15877
16415
  kind: action.kind,
15878
16416
  label: action.label,
15879
16417
  value: action.initial || '',
16418
+ multiline: action.multiline,
15880
16419
  },
15881
16420
  pendingKey: undefined,
15882
16421
  };
@@ -15909,6 +16448,27 @@ function applyLogInkAction(state, action) {
15909
16448
  }
15910
16449
  case 'setHistoryFetchArgs':
15911
16450
  return { ...state, historyFetchArgs: action.value, pendingKey: undefined };
16451
+ case 'toggleDiffViewMode':
16452
+ // Reset the scroll offsets so the new mode opens at the top — long
16453
+ // lines wrap differently in split mode (the renderer truncates per
16454
+ // column instead of per row), so the saved offset can land on a
16455
+ // different visual line. Snap to the top is simpler than mapping
16456
+ // unified offsets to split offsets.
16457
+ return {
16458
+ ...state,
16459
+ diffViewMode: state.diffViewMode === 'unified' ? 'split' : 'unified',
16460
+ diffPreviewOffset: 0,
16461
+ worktreeDiffOffset: 0,
16462
+ pendingKey: undefined,
16463
+ };
16464
+ case 'setDiffViewMode':
16465
+ return {
16466
+ ...state,
16467
+ diffViewMode: action.value,
16468
+ diffPreviewOffset: 0,
16469
+ worktreeDiffOffset: 0,
16470
+ pendingKey: undefined,
16471
+ };
15912
16472
  case 'moveToBottom':
15913
16473
  return {
15914
16474
  ...state,
@@ -16187,12 +16747,151 @@ function applyLogInkAction(state, action) {
16187
16747
  }
16188
16748
  }
16189
16749
 
16750
+ /**
16751
+ * In-sidebar selection helpers (#791 follow-up — sidebar entity ops).
16752
+ *
16753
+ * The workstation sidebar's branches / tags / stashes / worktrees tabs
16754
+ * used to be read-only previews — to act on an entity the user had to
16755
+ * drill into the dedicated promoted view. With the per-entity ops
16756
+ * gated to also fire on `state.focus === 'sidebar'` plus a matching
16757
+ * `sidebarTab`, j/k navigates the visible list inside the sidebar
16758
+ * itself, Enter performs the primary action (checkout / open diff),
16759
+ * and the existing per-view secondary keys (a/p/X/D/R/u/+P) are now
16760
+ * reachable without leaving the workstation view.
16761
+ *
16762
+ * The sidebar accordion is short — the visible window for an active
16763
+ * tab is capped (defaults below) so a long branch list doesn't
16764
+ * collapse the rest of the chrome. When the cursor scrolls past the
16765
+ * visible window, this module produces a sliding window that keeps it
16766
+ * in view; the dedicated view stays the right home for "show me all
16767
+ * 80 branches at once."
16768
+ */
16769
+ const DEFAULT_SIDEBAR_VISIBLE = 8;
16770
+ /**
16771
+ * Compute the sliding window so that `selected` stays inside it while
16772
+ * the window remains anchored at the top whenever possible (so short
16773
+ * lists don't scroll for no reason). When the cursor moves past the
16774
+ * window, the window slides just enough to keep the cursor in view —
16775
+ * matching the commit history's `clampWindowStart` behaviour for
16776
+ * familiarity.
16777
+ */
16778
+ function getSidebarVisibleWindow(total, selected, visible = DEFAULT_SIDEBAR_VISIBLE) {
16779
+ const size = Math.max(1, Math.min(visible, total));
16780
+ if (total <= visible) {
16781
+ return { start: 0, size, truncatedAbove: 0, truncatedBelow: 0 };
16782
+ }
16783
+ const half = Math.floor(size / 2);
16784
+ const idealStart = selected - half;
16785
+ const maxStart = total - size;
16786
+ const start = Math.max(0, Math.min(idealStart, maxStart));
16787
+ return {
16788
+ start,
16789
+ size,
16790
+ truncatedAbove: start,
16791
+ truncatedBelow: total - (start + size),
16792
+ };
16793
+ }
16794
+ /**
16795
+ * True when an in-sidebar action (j/k move, Enter checkout, etc.)
16796
+ * should fire instead of the generic drill-in / tab-cycle behaviour.
16797
+ *
16798
+ * Status tab is excluded because its preview shows worktree files —
16799
+ * those have their own selection model in the dedicated status view
16800
+ * and the sidebar doesn't surface them as selectable rows.
16801
+ */
16802
+ function sidebarTabHasSelectableItems(sidebarTab, itemCount) {
16803
+ if (!itemCount || itemCount <= 0)
16804
+ return false;
16805
+ return sidebarTab === 'branches' ||
16806
+ sidebarTab === 'tags' ||
16807
+ sidebarTab === 'stashes' ||
16808
+ sidebarTab === 'worktrees';
16809
+ }
16810
+
16190
16811
  function action(actionValue) {
16191
16812
  return {
16192
16813
  type: 'action',
16193
16814
  action: actionValue,
16194
16815
  };
16195
16816
  }
16817
+ /**
16818
+ * Build the events needed to apply the hunk under the diff cursor. The
16819
+ * runtime workflow handler expects payload format `<target>\n<patch>`
16820
+ * — splitting on the first newline keeps the patch body intact for
16821
+ * targets like `worktree` and `index` (no newlines in the prefix).
16822
+ *
16823
+ * Returns [] when the user isn't on a commit-diff / stash-diff explore,
16824
+ * or when no hunk can be extracted at the current cursor offset
16825
+ * (e.g. cursor sits on a `diff --git` header before the first `@@`).
16826
+ * Callers fall back to a contextual status message when this returns [].
16827
+ */
16828
+ function buildApplyHunkEvents(state, context, target) {
16829
+ if (state.activeView !== 'diff')
16830
+ return [];
16831
+ if (state.diffSource !== 'commit' && state.diffSource !== 'stash')
16832
+ return [];
16833
+ const lines = context.diffLinesForHunkApply;
16834
+ if (!lines || lines.length === 0)
16835
+ return [];
16836
+ const path = state.diffSource === 'stash'
16837
+ ? context.stashDiffSelectedPath
16838
+ : context.commitDiffSelectedPath;
16839
+ if (!path)
16840
+ return [];
16841
+ const extracted = extractDiffHunk({
16842
+ lines,
16843
+ cursorOffset: state.diffPreviewOffset,
16844
+ path,
16845
+ });
16846
+ if (!extracted)
16847
+ return [];
16848
+ const id = target === 'index' ? 'apply-hunk-index' : 'apply-hunk-worktree';
16849
+ return [{
16850
+ type: 'runWorkflowAction',
16851
+ id,
16852
+ payload: `${target}\n${extracted.patchText}`,
16853
+ }];
16854
+ }
16855
+ /**
16856
+ * Per-entity action-target predicates. The promoted views (`branches`,
16857
+ * `tags`, `stash`, `worktrees`) each scope a set of ops to their
16858
+ * dedicated surface. The same ops also fire when the user has the
16859
+ * sidebar focused on the matching tab — that's how in-sidebar
16860
+ * selection (#791 follow-up) lets the user checkout / apply / drop
16861
+ * without leaving the workstation view.
16862
+ */
16863
+ function isBranchActionTarget(state) {
16864
+ return (state.activeView === 'branches' && state.focus === 'commits') ||
16865
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
16866
+ }
16867
+ function isTagActionTarget(state) {
16868
+ return (state.activeView === 'tags' && state.focus === 'commits') ||
16869
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
16870
+ }
16871
+ function isStashActionTarget(state) {
16872
+ return (state.activeView === 'stash' && state.focus === 'commits') ||
16873
+ (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
16874
+ }
16875
+ function isWorktreeActionTarget(state) {
16876
+ return (state.activeView === 'worktrees' && state.focus === 'commits') ||
16877
+ (state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
16878
+ }
16879
+ /**
16880
+ * Item count for the active sidebar tab — used by the generic
16881
+ * sidebar-Enter handler to decide whether to defer to the per-entity
16882
+ * Enter (when items are present and the user is cursoring through
16883
+ * them) or to drill into the dedicated view (when the tab is empty
16884
+ * or has no per-entity Enter handler defined).
16885
+ */
16886
+ function getSidebarItemCount(sidebarTab, context) {
16887
+ switch (sidebarTab) {
16888
+ case 'branches': return context.branchCount;
16889
+ case 'tags': return context.tagCount;
16890
+ case 'stashes': return context.stashCount;
16891
+ case 'worktrees': return context.worktreeListCount;
16892
+ default: return undefined;
16893
+ }
16894
+ }
16196
16895
  /**
16197
16896
  * Translate a palette command into the same events its keystroke would have
16198
16897
  * produced. Phase 6 makes `:` a real launcher: this is the single mapping
@@ -16272,6 +16971,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
16272
16971
  return [action({ type: 'pushView', value: 'stash' })];
16273
16972
  case 'navigateWorktrees':
16274
16973
  return [action({ type: 'pushView', value: 'worktrees' })];
16974
+ case 'navigatePullRequest':
16975
+ return [action({ type: 'pushView', value: 'pull-request' })];
16275
16976
  case 'navigateBack':
16276
16977
  return [action({ type: 'popView' })];
16277
16978
  case 'openSelected': {
@@ -16329,10 +17030,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
16329
17030
  case 'clearSearch':
16330
17031
  return [action({ type: 'clearFilter' })];
16331
17032
  case 'cycleSort':
16332
- if (state.activeView === 'branches') {
17033
+ if (isBranchActionTarget(state)) {
16333
17034
  return [action({ type: 'cycleBranchSort' })];
16334
17035
  }
16335
- if (state.activeView === 'tags') {
17036
+ if (isTagActionTarget(state)) {
16336
17037
  return [action({ type: 'cycleTagSort' })];
16337
17038
  }
16338
17039
  return [action({
@@ -16369,6 +17070,77 @@ function hasUnsavedComposeDraft(state) {
16369
17070
  }
16370
17071
  return Boolean(compose.summary.trim() || compose.body.trim());
16371
17072
  }
17073
+ /**
17074
+ * Submit the active input prompt — used by Enter on single-line
17075
+ * prompts and by Ctrl+D on multi-line prompts (#806). Most prompt
17076
+ * kinds dispatch a workflow whose id matches the kind
17077
+ * (`create-branch`, `rename-branch`, etc.). A few are exceptions:
17078
+ * - `reset-mode` (#777) collects soft/mixed/hard and forwards the
17079
+ * mode as the payload to `reset-to-commit`.
17080
+ * - `pr-merge-strategy` (#783) validates the strategy and routes to
17081
+ * `merge-pr` via the y-confirm path.
17082
+ * - `pr-comment` dispatches `comment-pr` directly — the body itself
17083
+ * is the affirmative action.
17084
+ * - `pr-request-changes` routes to `request-changes-pr` via
17085
+ * y-confirm because the review is publicly visible.
17086
+ * Each exception validates here so a typo doesn't surface as a
17087
+ * "workflow not yet wired" status downstream.
17088
+ *
17089
+ * Empty values yield a hint instead of a no-op so the user knows what
17090
+ * to do — the same UX whether they pressed Enter (single-line) or
17091
+ * Ctrl+D (multi-line).
17092
+ */
17093
+ function submitInputPrompt(state) {
17094
+ if (!state.inputPrompt)
17095
+ return [];
17096
+ const value = state.inputPrompt.value.trim();
17097
+ if (!value) {
17098
+ return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
17099
+ }
17100
+ if (state.inputPrompt.kind === 'reset-mode') {
17101
+ const mode = value.toLowerCase();
17102
+ if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
17103
+ return [action({
17104
+ type: 'setStatus',
17105
+ value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
17106
+ })];
17107
+ }
17108
+ return [
17109
+ { type: 'runWorkflowAction', id: 'reset-to-commit', payload: mode },
17110
+ action({ type: 'closeInputPrompt' }),
17111
+ ];
17112
+ }
17113
+ if (state.inputPrompt.kind === 'pr-merge-strategy') {
17114
+ const strategy = value.toLowerCase();
17115
+ if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
17116
+ return [action({
17117
+ type: 'setStatus',
17118
+ value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
17119
+ })];
17120
+ }
17121
+ return [
17122
+ action({ type: 'setPendingConfirmation', value: 'merge-pr', payload: strategy }),
17123
+ action({ type: 'closeInputPrompt' }),
17124
+ ];
17125
+ }
17126
+ if (state.inputPrompt.kind === 'pr-comment') {
17127
+ return [
17128
+ { type: 'runWorkflowAction', id: 'comment-pr', payload: value },
17129
+ action({ type: 'closeInputPrompt' }),
17130
+ ];
17131
+ }
17132
+ if (state.inputPrompt.kind === 'pr-request-changes') {
17133
+ return [
17134
+ action({ type: 'setPendingConfirmation', value: 'request-changes-pr', payload: value }),
17135
+ action({ type: 'closeInputPrompt' }),
17136
+ ];
17137
+ }
17138
+ const id = state.inputPrompt.kind;
17139
+ return [
17140
+ { type: 'runWorkflowAction', id, payload: value },
17141
+ action({ type: 'closeInputPrompt' }),
17142
+ ];
17143
+ }
16372
17144
  function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16373
17145
  if (key.ctrl && inputValue === 'c') {
16374
17146
  if (hasUnsavedComposeDraft(state) && !state.pendingMutationConfirmation) {
@@ -16381,22 +17153,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16381
17153
  // filter/confirmation/compose handlers so a prompt opened from inside
16382
17154
  // any of those still captures focus cleanly.
16383
17155
  if (state.inputPrompt) {
17156
+ const isMultiline = Boolean(state.inputPrompt.multiline);
16384
17157
  if (key.escape) {
16385
17158
  return [
16386
17159
  action({ type: 'closeInputPrompt' }),
16387
17160
  action({ type: 'setStatus', value: 'cancelled' }),
16388
17161
  ];
16389
17162
  }
17163
+ // Multi-line prompts (#806): Ctrl+D submits (Unix EOF convention,
17164
+ // mirrors `git commit -m -` and HEREDOC patterns). Plain Enter
17165
+ // inserts a newline so the user can compose review bodies / PR
17166
+ // comments naturally without opening $EDITOR.
17167
+ if (isMultiline && key.ctrl && inputValue === 'd') {
17168
+ return submitInputPrompt(state);
17169
+ }
17170
+ if (isMultiline && key.return) {
17171
+ return [action({ type: 'appendInputPrompt', value: '\n' })];
17172
+ }
16390
17173
  if (key.return) {
16391
- const value = state.inputPrompt.value.trim();
16392
- if (!value) {
16393
- return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
16394
- }
16395
- const id = state.inputPrompt.kind;
16396
- return [
16397
- { type: 'runWorkflowAction', id, payload: value },
16398
- action({ type: 'closeInputPrompt' }),
16399
- ];
17174
+ return submitInputPrompt(state);
16400
17175
  }
16401
17176
  if (key.backspace || key.delete) {
16402
17177
  return [action({ type: 'backspaceInputPrompt' })];
@@ -16648,14 +17423,40 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16648
17423
  }
16649
17424
  if (state.pendingKey === 'g' && inputValue === 'z') {
16650
17425
  return [
16651
- action({ type: 'pushView', value: 'stash' }),
16652
- action({ type: 'setStatus', value: 'jumped to stash' }),
17426
+ action({ type: 'pushView', value: 'stash' }),
17427
+ action({ type: 'setStatus', value: 'jumped to stash' }),
17428
+ ];
17429
+ }
17430
+ if (state.pendingKey === 'g' && inputValue === 'w') {
17431
+ return [
17432
+ action({ type: 'pushView', value: 'worktrees' }),
17433
+ action({ type: 'setStatus', value: 'jumped to worktrees' }),
17434
+ ];
17435
+ }
17436
+ // `gp` jumps to the dedicated pull-request action panel (#783).
17437
+ // Lowercase `p` matches the pattern of other navigation chords
17438
+ // (gh / gs / gd / gc / gb / gt / gz / gw). The panel renders the
17439
+ // current branch's PR via `gh pr view --json` enriched fields and
17440
+ // exposes m / x / a / R / c action keys scoped to the view.
17441
+ if (state.pendingKey === 'g' && inputValue === 'p') {
17442
+ return [
17443
+ action({ type: 'pushView', value: 'pull-request' }),
17444
+ action({ type: 'setStatus', value: 'jumped to pull request' }),
16653
17445
  ];
16654
17446
  }
16655
- if (state.pendingKey === 'g' && inputValue === 'w') {
17447
+ // `gH` chord: apply the cursored hunk to the index (`git apply
17448
+ // --cached`). Sibling of bare `H` which targets the worktree.
17449
+ // Discoverable via the footer hint on diff views and the help
17450
+ // overlay; the explicit chord keeps `H` (single keystroke) for
17451
+ // the more common worktree case.
17452
+ if (state.pendingKey === 'g' && inputValue === 'H') {
17453
+ const events = buildApplyHunkEvents(state, context, 'index');
17454
+ if (events.length) {
17455
+ return [action({ type: 'setPendingKey', value: undefined }), ...events];
17456
+ }
16656
17457
  return [
16657
- action({ type: 'pushView', value: 'worktrees' }),
16658
- action({ type: 'setStatus', value: 'jumped to worktrees' }),
17458
+ action({ type: 'setPendingKey', value: undefined }),
17459
+ action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
16659
17460
  ];
16660
17461
  }
16661
17462
  if (inputValue === 'g') {
@@ -16667,6 +17468,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16667
17468
  }
16668
17469
  return [action({ type: 'setPendingKey', value: 'g' })];
16669
17470
  }
17471
+ // `d` on the diff view toggles between unified and side-by-side split
17472
+ // rendering (#785). Scoped to the diff view so the letter stays free
17473
+ // for other surfaces. The chord branch above already claimed `gd`,
17474
+ // so by the time we get here `pendingKey` is not `g`.
17475
+ if (inputValue === 'd' && state.activeView === 'diff') {
17476
+ const next = state.diffViewMode === 'unified' ? 'split' : 'unified';
17477
+ return [
17478
+ action({ type: 'toggleDiffViewMode' }),
17479
+ action({
17480
+ type: 'setStatus',
17481
+ value: next === 'split'
17482
+ ? 'Switched to side-by-side diff'
17483
+ : 'Switched to unified diff',
17484
+ }),
17485
+ ];
17486
+ }
16670
17487
  if (inputValue === '\\') {
16671
17488
  return [action({ type: 'toggleGraph' })];
16672
17489
  }
@@ -16689,10 +17506,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16689
17506
  return [{ type: 'refreshContext' }];
16690
17507
  }
16691
17508
  if (inputValue === 's') {
16692
- if (state.activeView === 'branches') {
17509
+ if (isBranchActionTarget(state)) {
16693
17510
  return [action({ type: 'cycleBranchSort' })];
16694
17511
  }
16695
- if (state.activeView === 'tags') {
17512
+ if (isTagActionTarget(state)) {
16696
17513
  return [action({ type: 'cycleTagSort' })];
16697
17514
  }
16698
17515
  // Falls through so other views (history/status/diff/compose/stash) still
@@ -16763,6 +17580,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16763
17580
  if (key.tab) {
16764
17581
  return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
16765
17582
  }
17583
+ // ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
17584
+ // Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
17585
+ // vertical axis (↑/↓ below) is "within the active tab's items".
17586
+ // [/] still works as a keyboard alternative for users who prefer
17587
+ // non-arrow keys.
17588
+ if (key.leftArrow && state.focus === 'sidebar') {
17589
+ return [action({ type: 'previousSidebarTab' })];
17590
+ }
17591
+ if (key.rightArrow && state.focus === 'sidebar') {
17592
+ return [action({ type: 'nextSidebarTab' })];
17593
+ }
16766
17594
  if (key.upArrow || inputValue === 'k') {
16767
17595
  if (state.focus === 'detail' && context.detailFileCount) {
16768
17596
  return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
@@ -16792,16 +17620,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16792
17620
  previewLineCount: context.previewLineCount,
16793
17621
  })];
16794
17622
  }
16795
- if (state.activeView === 'branches' && context.branchCount) {
17623
+ if (isBranchActionTarget(state) && context.branchCount) {
16796
17624
  return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
16797
17625
  }
16798
- if (state.activeView === 'tags' && context.tagCount) {
17626
+ if (isTagActionTarget(state) && context.tagCount) {
16799
17627
  return [action({ type: 'moveTag', delta: -1, count: context.tagCount })];
16800
17628
  }
16801
- if (state.activeView === 'stash' && context.stashCount) {
17629
+ if (isStashActionTarget(state) && context.stashCount) {
16802
17630
  return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
16803
17631
  }
16804
- if (state.activeView === 'worktrees' && context.worktreeListCount) {
17632
+ if (isWorktreeActionTarget(state) && context.worktreeListCount) {
16805
17633
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
16806
17634
  }
16807
17635
  if (state.activeView === 'history' &&
@@ -16811,6 +17639,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16811
17639
  context.worktreeDirty) {
16812
17640
  return [action({ type: 'focusPendingCommit' })];
16813
17641
  }
17642
+ // Sidebar fallback: when no entity claim above succeeds (status
17643
+ // tab or empty content tab), ↑ falls through to cycling sidebar
17644
+ // tabs so the user always has a way to navigate. With ←/→ above
17645
+ // already handling tab switching, this is mostly a vim-style
17646
+ // safety net for `k`.
16814
17647
  return [
16815
17648
  action(state.focus === 'sidebar'
16816
17649
  ? { type: 'previousSidebarTab' }
@@ -16845,16 +17678,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16845
17678
  previewLineCount: context.previewLineCount,
16846
17679
  })];
16847
17680
  }
16848
- if (state.activeView === 'branches' && context.branchCount) {
17681
+ if (isBranchActionTarget(state) && context.branchCount) {
16849
17682
  return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
16850
17683
  }
16851
- if (state.activeView === 'tags' && context.tagCount) {
17684
+ if (isTagActionTarget(state) && context.tagCount) {
16852
17685
  return [action({ type: 'moveTag', delta: 1, count: context.tagCount })];
16853
17686
  }
16854
- if (state.activeView === 'stash' && context.stashCount) {
17687
+ if (isStashActionTarget(state) && context.stashCount) {
16855
17688
  return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
16856
17689
  }
16857
- if (state.activeView === 'worktrees' && context.worktreeListCount) {
17690
+ if (isWorktreeActionTarget(state) && context.worktreeListCount) {
16858
17691
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
16859
17692
  }
16860
17693
  return [
@@ -16958,30 +17791,42 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16958
17791
  }
16959
17792
  }
16960
17793
  // Enter on a sidebar tab drills into the corresponding promoted view
16961
- // (status / branches / tags / stash). Sits above the per-view Enter
16962
- // handlers so a sidebar-focused Enter never fires checkout-branch /
16963
- // navigateOpenDiffForCommit / etc. against the (hidden) selection in
16964
- // the active tab.
17794
+ // (status / branches / tags / stash) but only when the sidebar tab
17795
+ // either has no per-entity Enter handler defined (status, tags,
17796
+ // worktrees) or has zero items (so the dedicated view's empty-state
17797
+ // tells the user what to do next).
17798
+ //
17799
+ // When the sidebar IS focused on a content tab WITH items, this
17800
+ // handler defers to the per-entity Enter below (checkout-branch for
17801
+ // branches, navigateOpenDiffForStash for stashes) so the user can
17802
+ // act on the cursored item without leaving the workstation view —
17803
+ // the in-sidebar selection win from #791 follow-up.
16965
17804
  //
16966
- // The Enter also moves focus out of the sidebar into the newly opened
16967
- // list — otherwise ↑/↓ keep cycling sidebar tabs instead of navigating
16968
- // inside the just-opened view, which made the drill-in feel half-done.
17805
+ // The drill-in moves focus out of the sidebar into the newly opened
17806
+ // list — otherwise ↑/↓ keep navigating the sidebar instead of the
17807
+ // just-opened view, which made the drill-in feel half-done.
16969
17808
  if (key.return && state.focus === 'sidebar') {
16970
- const tabToView = {
16971
- status: 'status',
16972
- branches: 'branches',
16973
- tags: 'tags',
16974
- stashes: 'stash',
16975
- worktrees: 'worktrees',
16976
- };
16977
- const target = tabToView[state.sidebarTab];
16978
- if (target) {
16979
- return [
16980
- action({ type: 'pushView', value: target }),
16981
- action({ type: 'setFocus', value: 'commits' }),
16982
- ];
17809
+ const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
17810
+ const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
17811
+ sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
17812
+ if (!hasInSidebarPrimaryAction) {
17813
+ const tabToView = {
17814
+ status: 'status',
17815
+ branches: 'branches',
17816
+ tags: 'tags',
17817
+ stashes: 'stash',
17818
+ worktrees: 'worktrees',
17819
+ };
17820
+ const target = tabToView[state.sidebarTab];
17821
+ if (target) {
17822
+ return [
17823
+ action({ type: 'pushView', value: target }),
17824
+ action({ type: 'setFocus', value: 'commits' }),
17825
+ ];
17826
+ }
17827
+ return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
16983
17828
  }
16984
- return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
17829
+ // Fall through per-entity Enter handler below claims the keystroke.
16985
17830
  }
16986
17831
  if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
16987
17832
  return [action({
@@ -16990,8 +17835,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16990
17835
  })];
16991
17836
  }
16992
17837
  // Enter on a branch row checks the branch out. Non-destructive workflow
16993
- // action — no confirmation prompt.
16994
- if (key.return && state.activeView === 'branches' && state.focus === 'commits' && context.branchCount) {
17838
+ // action — no confirmation prompt. Fires from either the dedicated
17839
+ // branches view or from the sidebar when the branches tab is focused
17840
+ // with items.
17841
+ if (key.return && isBranchActionTarget(state) && context.branchCount) {
16995
17842
  return [{ type: 'runWorkflowAction', id: 'checkout-branch' }];
16996
17843
  }
16997
17844
  // `+` opens a create-branch / create-tag prompt depending on context.
@@ -17018,32 +17865,33 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17018
17865
  }
17019
17866
  // Per-view stash actions: `a` apply (keep the stash), `p` pop (apply
17020
17867
  // then drop). Drop is the existing destructive `X` workflow which
17021
- // routes through the y-confirm path. Scoped to the stash view so the
17022
- // letters stay free elsewhere.
17023
- if (inputValue === 'a' && state.activeView === 'stash' && context.stashCount) {
17868
+ // routes through the y-confirm path. Scoped to the stash target so
17869
+ // the letters stay free elsewhere — the target predicate also fires
17870
+ // when the sidebar's stashes tab is focused with items.
17871
+ if (inputValue === 'a' && isStashActionTarget(state) && context.stashCount) {
17024
17872
  return [{ type: 'runWorkflowAction', id: 'apply-stash' }];
17025
17873
  }
17026
- if (inputValue === 'p' && state.activeView === 'stash' && context.stashCount) {
17874
+ if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
17027
17875
  return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
17028
17876
  }
17029
17877
  // Per-view tag action: `P` pushes the selected tag to origin. Letter
17030
- // is scoped to the tags surface so it doesn't collide with `p` for
17878
+ // is scoped to the tags target so it doesn't collide with `p` for
17031
17879
  // pop-stash. Note: this also takes precedence over the global
17032
17880
  // push-current-branch workflow's `P` key.
17033
- if (inputValue === 'P' && state.activeView === 'tags' && context.tagCount) {
17881
+ if (inputValue === 'P' && isTagActionTarget(state) && context.tagCount) {
17034
17882
  return [{ type: 'runWorkflowAction', id: 'push-tag' }];
17035
17883
  }
17036
17884
  // Per-view branches actions: `R` renames the selected branch, `u`
17037
17885
  // sets its upstream. Both open the input prompt so the user can type
17038
17886
  // the new value. Pre-fills are handled by the prompt's `initial`.
17039
- if (inputValue === 'R' && state.activeView === 'branches' && context.branchCount) {
17887
+ if (inputValue === 'R' && isBranchActionTarget(state) && context.branchCount) {
17040
17888
  return [action({
17041
17889
  type: 'openInputPrompt',
17042
17890
  kind: 'rename-branch',
17043
17891
  label: 'Rename branch to',
17044
17892
  })];
17045
17893
  }
17046
- if (inputValue === 'u' && state.activeView === 'branches' && context.branchCount) {
17894
+ if (inputValue === 'u' && isBranchActionTarget(state) && context.branchCount) {
17047
17895
  return [action({
17048
17896
  type: 'openInputPrompt',
17049
17897
  kind: 'set-upstream',
@@ -17051,11 +17899,48 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17051
17899
  })];
17052
17900
  }
17053
17901
  // Per-view tag action: `R` deletes the tag from the remote (after
17054
- // confirmation). Scoped per-view so this letter is free elsewhere
17055
- // (especially the `R` rename binding on the branches view).
17056
- if (inputValue === 'R' && state.activeView === 'tags' && context.tagCount) {
17902
+ // confirmation). Scoped per-target so this letter is free elsewhere
17903
+ // (especially the `R` rename binding on the branches target).
17904
+ if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
17057
17905
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
17058
17906
  }
17907
+ // #783 — full PR action panel keys, scoped to the pull-request view.
17908
+ // All five wrap a `gh pr <verb>` invocation; merge / request-changes /
17909
+ // comment open prompts first, the rest route through the y-confirm
17910
+ // path because they're irreversible (or near-irreversible).
17911
+ if (inputValue === 'm' && state.activeView === 'pull-request') {
17912
+ return [action({
17913
+ type: 'openInputPrompt',
17914
+ kind: 'pr-merge-strategy',
17915
+ label: 'Merge strategy (merge / squash / rebase)',
17916
+ })];
17917
+ }
17918
+ if (inputValue === 'x' && state.activeView === 'pull-request') {
17919
+ return [action({ type: 'setPendingConfirmation', value: 'close-pr' })];
17920
+ }
17921
+ if (inputValue === 'a' && state.activeView === 'pull-request') {
17922
+ return [action({ type: 'setPendingConfirmation', value: 'approve-pr' })];
17923
+ }
17924
+ if (inputValue === 'R' && state.activeView === 'pull-request') {
17925
+ // Free-form review body — multi-line so the reviewer can structure
17926
+ // their feedback naturally without opening $EDITOR (#806).
17927
+ return [action({
17928
+ type: 'openInputPrompt',
17929
+ kind: 'pr-request-changes',
17930
+ label: 'Request changes — review body (Enter newline · Ctrl+D submit)',
17931
+ multiline: true,
17932
+ })];
17933
+ }
17934
+ if (inputValue === 'c' && state.activeView === 'pull-request') {
17935
+ // Free-form comment body — multi-line for the same reason as
17936
+ // pr-request-changes.
17937
+ return [action({
17938
+ type: 'openInputPrompt',
17939
+ kind: 'pr-comment',
17940
+ label: 'Comment body (Enter newline · Ctrl+D submit)',
17941
+ multiline: true,
17942
+ })];
17943
+ }
17059
17944
  // Global stash hotkey: `S` opens a stash-message prompt and
17060
17945
  // `createStash` runs once submitted. Available everywhere there's
17061
17946
  // not a more modal handler in front of it.
@@ -17113,6 +17998,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17113
17998
  payload: `${context.commitDiffSelectedSha} ${context.commitDiffSelectedPath}`,
17114
17999
  })];
17115
18000
  }
18001
+ // `H` on a commit-diff or stash-diff explore extracts the hunk under
18002
+ // the cursor and applies it to the working tree (`git apply`). The
18003
+ // sibling `gH` chord targets the index (`git apply --cached`). Both
18004
+ // bypass the y-confirm path because `git apply` is non-destructive
18005
+ // (it'll fail loudly on conflict and `git apply -R` undoes a clean
18006
+ // apply).
18007
+ if (inputValue === 'H') {
18008
+ const events = buildApplyHunkEvents(state, context, 'worktree');
18009
+ if (events.length) {
18010
+ return events;
18011
+ }
18012
+ if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
18013
+ return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
18014
+ }
18015
+ }
17116
18016
  // `c` on the history view cherry-picks the full selected commit on
17117
18017
  // top of the current branch. Routed through the y-confirm flow since
17118
18018
  // it can produce conflicts and is a real working-tree mutation.
@@ -17123,6 +18023,46 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17123
18023
  !state.pendingCommitFocused) {
17124
18024
  return [action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' })];
17125
18025
  }
18026
+ // `R` reverts the cursored commit by adding an inverse commit on top
18027
+ // of HEAD. Same y-confirm gate as cherry-pick — non-rewriting but
18028
+ // still a real mutation.
18029
+ if (inputValue === 'R' &&
18030
+ state.activeView === 'history' &&
18031
+ state.focus === 'commits' &&
18032
+ state.filteredCommits.length > 0 &&
18033
+ !state.pendingCommitFocused) {
18034
+ return [action({ type: 'setPendingConfirmation', value: 'revert-commit' })];
18035
+ }
18036
+ // `Z` resets the current branch tip to the cursored commit. Opens a
18037
+ // mode prompt (soft / mixed / hard) instead of jumping straight to
18038
+ // confirmation because the choice changes the destructiveness
18039
+ // dramatically — `--hard` discards working-tree changes. The prompt
18040
+ // submission special-cases `kind === 'reset-mode'` to forward the
18041
+ // mode through `reset-to-commit` (see prompt-submit handler above).
18042
+ // No `initial` value: existing prompts append to initial rather than
18043
+ // replacing it, which would surprise the user typing the mode.
18044
+ if (inputValue === 'Z' &&
18045
+ state.activeView === 'history' &&
18046
+ state.focus === 'commits' &&
18047
+ state.filteredCommits.length > 0 &&
18048
+ !state.pendingCommitFocused) {
18049
+ return [action({
18050
+ type: 'openInputPrompt',
18051
+ kind: 'reset-mode',
18052
+ label: 'Reset mode (soft / mixed / hard)',
18053
+ })];
18054
+ }
18055
+ // `i` (lowercase) starts an interactive rebase from the cursored
18056
+ // commit's parent. Lowercase keeps the existing global `I`
18057
+ // ai-commit-summary workflow reachable on the history view; `i`
18058
+ // also matches the `git rebase -i` flag mnemonic.
18059
+ if (inputValue === 'i' &&
18060
+ state.activeView === 'history' &&
18061
+ state.focus === 'commits' &&
18062
+ state.filteredCommits.length > 0 &&
18063
+ !state.pendingCommitFocused) {
18064
+ return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
18065
+ }
17126
18066
  // `y` / `Y` yank the contextually relevant identifier from the active
17127
18067
  // view to the system clipboard:
17128
18068
  // history → cursored commit hash (Y for short hash)
@@ -17138,13 +18078,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17138
18078
  if (state.activeView === 'history' && state.filteredCommits.length > 0) {
17139
18079
  return [{ type: 'yankFromActiveView', short }];
17140
18080
  }
17141
- if (state.activeView === 'branches' && context.branchCount) {
18081
+ if (isBranchActionTarget(state) && context.branchCount) {
17142
18082
  return [{ type: 'yankFromActiveView' }];
17143
18083
  }
17144
- if (state.activeView === 'tags' && context.tagCount) {
18084
+ if (isTagActionTarget(state) && context.tagCount) {
17145
18085
  return [{ type: 'yankFromActiveView' }];
17146
18086
  }
17147
- if (state.activeView === 'stash' && context.stashCount && context.stashSelectedRef) {
18087
+ if (isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
17148
18088
  return [{ type: 'yankFromActiveView' }];
17149
18089
  }
17150
18090
  if (state.activeView === 'status' && context.worktreeSelectedPath) {
@@ -17162,8 +18102,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17162
18102
  // Enter on a stash row pushes the diff view scoped to that stash.
17163
18103
  // The runtime loads `git stash show -p <ref>` once the view is
17164
18104
  // active. The stash ref is passed via the action so we don't need a
17165
- // context lookup here.
17166
- if (key.return && state.activeView === 'stash' && state.focus === 'commits' && context.stashCount && context.stashSelectedRef) {
18105
+ // context lookup here. Fires from either the dedicated stash view or
18106
+ // from the sidebar when the stashes tab is focused with items.
18107
+ if (key.return && isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
17167
18108
  return [action({
17168
18109
  type: 'navigateOpenDiffForStash',
17169
18110
  ref: context.stashSelectedRef,
@@ -17224,7 +18165,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17224
18165
  * fall back to "already seen" so we never block startup.
17225
18166
  */
17226
18167
  const MARKER_BASENAME = 'onboarding.seen';
17227
- function resolveCacheDir$1() {
18168
+ function resolveCacheDir$2() {
17228
18169
  const xdg = process.env.XDG_CACHE_HOME;
17229
18170
  if (xdg && xdg.trim().length > 0) {
17230
18171
  return path$1.join(xdg, 'coco');
@@ -17232,7 +18173,7 @@ function resolveCacheDir$1() {
17232
18173
  return path$1.join(os$1.homedir(), '.cache', 'coco');
17233
18174
  }
17234
18175
  function getOnboardingMarkerPath() {
17235
- return path$1.join(resolveCacheDir$1(), MARKER_BASENAME);
18176
+ return path$1.join(resolveCacheDir$2(), MARKER_BASENAME);
17236
18177
  }
17237
18178
  function hasSeenOnboarding() {
17238
18179
  try {
@@ -17256,6 +18197,52 @@ function markOnboardingSeen() {
17256
18197
  }
17257
18198
  }
17258
18199
 
18200
+ /**
18201
+ * Persist the user's preferred diff view mode (unified vs side-by-side
18202
+ * split — #785) per repo. Mirrors `inkSidebarPersistence.ts` so the
18203
+ * cache layout, error model, and key derivation stay consistent across
18204
+ * settings: best-effort, XDG-friendly, no PII in the cache filename.
18205
+ */
18206
+ const VALID_MODES = ['unified', 'split'];
18207
+ function resolveCacheDir$1() {
18208
+ const xdg = process.env.XDG_CACHE_HOME;
18209
+ if (xdg && xdg.trim().length > 0) {
18210
+ return path$1.join(xdg, 'coco');
18211
+ }
18212
+ return path$1.join(os$1.homedir(), '.cache', 'coco');
18213
+ }
18214
+ function repoKey$1(repoPath) {
18215
+ // sha1 is used here as a non-security cache-key derivation — we just
18216
+ // need a deterministic short identifier for the marker filename. No
18217
+ // PII or auth context is hashed.
18218
+ // DevSkim: ignore DS126858
18219
+ return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
18220
+ }
18221
+ function getDiffViewModeMarkerPath(repoPath) {
18222
+ return path$1.join(resolveCacheDir$1(), `diff-view-mode.${repoKey$1(repoPath)}`);
18223
+ }
18224
+ function getSavedDiffViewMode(repoPath) {
18225
+ try {
18226
+ const raw = fs$1.readFileSync(getDiffViewModeMarkerPath(repoPath), 'utf8').trim();
18227
+ return VALID_MODES.includes(raw)
18228
+ ? raw
18229
+ : undefined;
18230
+ }
18231
+ catch {
18232
+ return undefined;
18233
+ }
18234
+ }
18235
+ function saveDiffViewMode(repoPath, mode) {
18236
+ const marker = getDiffViewModeMarkerPath(repoPath);
18237
+ try {
18238
+ fs$1.mkdirSync(path$1.dirname(marker), { recursive: true });
18239
+ fs$1.writeFileSync(marker, mode);
18240
+ }
18241
+ catch {
18242
+ // Best-effort persistence; swallow.
18243
+ }
18244
+ }
18245
+
17259
18246
  /**
17260
18247
  * Persist which sidebar tab the user last had active, keyed per repo so
17261
18248
  * switching projects doesn't reset every other repo's preference. The
@@ -17315,6 +18302,144 @@ function saveSidebarTab(repoPath, tab) {
17315
18302
  }
17316
18303
  }
17317
18304
 
18305
+ /**
18306
+ * Pair-alignment helper for the side-by-side diff view (#785).
18307
+ *
18308
+ * Takes the unified-diff line array that the renderer already paints (one
18309
+ * line per element, the leading character drives `+`/`-`/context coloring)
18310
+ * and re-shapes it into two-column rows the split renderer can lay out
18311
+ * without further parsing. Pure / synchronous so it can be exercised from
18312
+ * tests without spinning up Ink.
18313
+ *
18314
+ * Algorithm:
18315
+ * 1. Walk lines in order. `@@` headers seed a new hunk and reset the
18316
+ * `oldLineNo` / `newLineNo` cursors from the header range.
18317
+ * 2. Inside a hunk, group the consecutive runs of `-` and `+` lines that
18318
+ * follow each other. Each run of removals + the immediately-following
18319
+ * run of additions forms a "change block" that pairs up element-wise:
18320
+ * row[i] = { left: removals[i], right: additions[i] }. When one side
18321
+ * is shorter, pad with `kind: 'empty'` rows so the columns stay
18322
+ * aligned.
18323
+ * 3. Context lines emit as a paired row with the same text on both
18324
+ * sides and the synthesized line numbers from each cursor.
18325
+ * 4. Diff metadata (`diff `, `index `, `--- `, `+++ `, etc.) emit as
18326
+ * `kind: 'header'` rows so the split view still has a section break.
18327
+ * 5. A context line that interrupts a change block forces the in-flight
18328
+ * block to flush before the context row is emitted — pairs are never
18329
+ * drawn across context boundaries (matches lazygit / fugitive
18330
+ * behavior, and is what the issue specifies).
18331
+ *
18332
+ * Long lines are not wrapped here — the renderer truncates per column at
18333
+ * paint time so this helper stays pure and trivially testable.
18334
+ */
18335
+ const EMPTY_LEFT = { text: '', kind: 'empty' };
18336
+ const EMPTY_RIGHT = { text: '', kind: 'empty' };
18337
+ /**
18338
+ * Parse the start line numbers out of an `@@ -A,B +C,D @@` header. Returns
18339
+ * `[oldStart, newStart]`; either falls back to 1 when the header is
18340
+ * malformed (which only happens with synthetic / hand-crafted patches).
18341
+ */
18342
+ function parseHunkHeader(line) {
18343
+ const match = /@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(line);
18344
+ if (!match) {
18345
+ return [1, 1];
18346
+ }
18347
+ return [Number(match[1]) || 1, Number(match[2]) || 1];
18348
+ }
18349
+ function isDiffHeader(line) {
18350
+ return (line.startsWith('diff ') ||
18351
+ line.startsWith('index ') ||
18352
+ line.startsWith('--- ') ||
18353
+ line.startsWith('+++ ') ||
18354
+ line.startsWith('similarity ') ||
18355
+ line.startsWith('rename ') ||
18356
+ line.startsWith('copy ') ||
18357
+ line.startsWith('new file ') ||
18358
+ line.startsWith('deleted file ') ||
18359
+ line.startsWith('old mode ') ||
18360
+ line.startsWith('new mode ') ||
18361
+ line.startsWith('Binary files '));
18362
+ }
18363
+ /**
18364
+ * Flush a pending change block (removals + additions accumulated from a
18365
+ * contiguous `-`/`+` run) into paired rows. Pads the shorter side with
18366
+ * empty placeholders so columns stay aligned.
18367
+ */
18368
+ function flushChangeBlock(removals, additions, rows) {
18369
+ const max = Math.max(removals.length, additions.length);
18370
+ for (let i = 0; i < max; i++) {
18371
+ const left = removals[i] || EMPTY_LEFT;
18372
+ const right = additions[i] || EMPTY_RIGHT;
18373
+ rows.push({ left, right });
18374
+ }
18375
+ removals.length = 0;
18376
+ additions.length = 0;
18377
+ }
18378
+ function buildSplitDiffRows(unifiedLines) {
18379
+ const rows = [];
18380
+ let oldLineNo = 0;
18381
+ let newLineNo = 0;
18382
+ let inHunk = false;
18383
+ const removals = [];
18384
+ const additions = [];
18385
+ const flushHeader = (text) => {
18386
+ flushChangeBlock(removals, additions, rows);
18387
+ rows.push({
18388
+ left: { text, kind: 'header' },
18389
+ right: { text, kind: 'header' },
18390
+ });
18391
+ };
18392
+ for (const raw of unifiedLines) {
18393
+ if (raw.startsWith('@@')) {
18394
+ flushChangeBlock(removals, additions, rows);
18395
+ const [oldStart, newStart] = parseHunkHeader(raw);
18396
+ oldLineNo = oldStart;
18397
+ newLineNo = newStart;
18398
+ inHunk = true;
18399
+ rows.push({
18400
+ left: { text: raw, kind: 'header' },
18401
+ right: { text: raw, kind: 'header' },
18402
+ });
18403
+ continue;
18404
+ }
18405
+ if (!inHunk || isDiffHeader(raw)) {
18406
+ flushHeader(raw);
18407
+ continue;
18408
+ }
18409
+ if (raw.startsWith('-')) {
18410
+ removals.push({
18411
+ text: raw.slice(1),
18412
+ lineNumber: oldLineNo,
18413
+ kind: 'remove',
18414
+ });
18415
+ oldLineNo += 1;
18416
+ continue;
18417
+ }
18418
+ if (raw.startsWith('+')) {
18419
+ additions.push({
18420
+ text: raw.slice(1),
18421
+ lineNumber: newLineNo,
18422
+ kind: 'add',
18423
+ });
18424
+ newLineNo += 1;
18425
+ continue;
18426
+ }
18427
+ // Context line (or `` marker, which we
18428
+ // treat like a context row so it lands on both sides — readers
18429
+ // expect to see it in either column).
18430
+ flushChangeBlock(removals, additions, rows);
18431
+ const text = raw.startsWith(' ') ? raw.slice(1) : raw;
18432
+ rows.push({
18433
+ left: { text, lineNumber: oldLineNo, kind: 'context' },
18434
+ right: { text, lineNumber: newLineNo, kind: 'context' },
18435
+ });
18436
+ oldLineNo += 1;
18437
+ newLineNo += 1;
18438
+ }
18439
+ flushChangeBlock(removals, additions, rows);
18440
+ return rows;
18441
+ }
18442
+
17318
18443
  /**
17319
18444
  * Promoted-view selection rectification on filter changes (P4.5).
17320
18445
  *
@@ -17736,7 +18861,8 @@ function createLogInkTheme(options = {}) {
17736
18861
  /**
17737
18862
  * Format a branch's relationship to its upstream.
17738
18863
  * - no upstream → "no upstream"
17739
- * - even → "even with <upstream>"
18864
+ * - even → "" (the boring default — keep the row tight; the row
18865
+ * marker already encodes "synced")
17740
18866
  * - divergent → "↑<ahead> ↓<behind> <upstream>" (only the non-zero side
17741
18867
  * is rendered so the line stays tight). ASCII mode falls back to the
17742
18868
  * legacy `+N/-N` form.
@@ -17746,7 +18872,7 @@ function formatBranchDivergence(branch, options = {}) {
17746
18872
  return 'no upstream';
17747
18873
  }
17748
18874
  if (branch.ahead === 0 && branch.behind === 0) {
17749
- return `even with ${branch.upstream}`;
18875
+ return '';
17750
18876
  }
17751
18877
  if (options.ascii) {
17752
18878
  return `+${branch.ahead}/-${branch.behind} ${branch.upstream}`;
@@ -17760,14 +18886,76 @@ function formatBranchDivergence(branch, options = {}) {
17760
18886
  }
17761
18887
  /**
17762
18888
  * Single-cell marker shown to the left of a branch name in lists.
17763
- * `*` = current, `◌` = no upstream (detached from a remote), space otherwise.
18889
+ *
18890
+ * - `*` — current branch (regardless of remote state)
18891
+ * - `◌` — no upstream
18892
+ * - `≡` — has upstream + synced (ahead === 0 && behind === 0)
18893
+ * - `↕` — has upstream + diverged (any non-zero ahead/behind)
18894
+ * - ` ` — fallback / no info
18895
+ *
18896
+ * ASCII fallbacks (legible without box-drawing/arrow glyphs):
18897
+ * - `?` for "no upstream", `=` for synced, `~` for diverged.
17764
18898
  */
17765
18899
  function branchRowMarker(branch, options = {}) {
17766
18900
  if (branch.current)
17767
18901
  return '*';
17768
18902
  if (!branch.upstream)
17769
18903
  return options.ascii ? '?' : '◌';
17770
- return ' ';
18904
+ const ahead = branch.ahead ?? 0;
18905
+ const behind = branch.behind ?? 0;
18906
+ if (ahead === 0 && behind === 0) {
18907
+ return options.ascii ? '=' : '≡';
18908
+ }
18909
+ return options.ascii ? '~' : '↕';
18910
+ }
18911
+ /**
18912
+ * Compact, human-friendly relative timestamp for the branch row.
18913
+ * Inputs:
18914
+ * - `iso` — committer-date in `YYYY-MM-DD` form (as produced by
18915
+ * `for-each-ref` with `committerdate:short`).
18916
+ * - `now` — reference instant; pass it explicitly so callers can pin it
18917
+ * for deterministic tests.
18918
+ *
18919
+ * Outputs (rounded toward the nearest unit):
18920
+ * - `today`, `1d ago`, `2d ago` … up to 13d
18921
+ * - `2w ago` … up to 8w
18922
+ * - `2mo ago` … up to 12mo
18923
+ * - `2y ago` for older
18924
+ * - `''` for malformed inputs (caller renders nothing).
18925
+ *
18926
+ * "in the future" inputs (clock skew, bad data) collapse to `today`.
18927
+ */
18928
+ function formatBranchLastTouched(iso, now) {
18929
+ if (!iso)
18930
+ return '';
18931
+ // Tolerate either `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS…` ISO strings.
18932
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
18933
+ if (!match)
18934
+ return '';
18935
+ const year = Number.parseInt(match[1], 10);
18936
+ const month = Number.parseInt(match[2], 10);
18937
+ const day = Number.parseInt(match[3], 10);
18938
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
18939
+ return '';
18940
+ // Compare at day granularity in UTC so a branch touched "yesterday"
18941
+ // never reads "today" depending on the operator's timezone.
18942
+ const branchUtc = Date.UTC(year, month - 1, day);
18943
+ const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
18944
+ const diffMs = nowUtc - branchUtc;
18945
+ const oneDay = 24 * 60 * 60 * 1000;
18946
+ const days = Math.floor(diffMs / oneDay);
18947
+ if (days <= 0)
18948
+ return 'today';
18949
+ if (days < 14)
18950
+ return `${days}d ago`;
18951
+ const weeks = Math.floor(days / 7);
18952
+ if (weeks < 9)
18953
+ return `${weeks}w ago`;
18954
+ const months = Math.floor(days / 30);
18955
+ if (months < 12)
18956
+ return `${months}mo ago`;
18957
+ const years = Math.floor(days / 365);
18958
+ return `${years}y ago`;
17771
18959
  }
17772
18960
  /**
17773
18961
  * Pick the glyph + color for a PR state badge.
@@ -18197,7 +19385,7 @@ function createChangelogArgv(input) {
18197
19385
  ...input,
18198
19386
  };
18199
19387
  }
18200
- function compactOutputLines$2(output) {
19388
+ function compactOutputLines$3(output) {
18201
19389
  return output
18202
19390
  .split('\n')
18203
19391
  .map((line) => line.trim())
@@ -18221,7 +19409,7 @@ async function captureStdout(action) {
18221
19409
  }
18222
19410
  }
18223
19411
  function formatCapturedAiOutput(output) {
18224
- const lines = compactOutputLines$2(output);
19412
+ const lines = compactOutputLines$3(output);
18225
19413
  const telemetry = lines.filter((line) => line.includes('[llm:summary]'));
18226
19414
  const content = lines.filter((line) => !line.includes('[llm]') && !line.includes('[llm:summary]'));
18227
19415
  const editable = content.join('\n');
@@ -18518,8 +19706,65 @@ function parsePullRequestInfo(output) {
18518
19706
  if (!trimmed) {
18519
19707
  return undefined;
18520
19708
  }
18521
- return JSON.parse(trimmed);
19709
+ const raw = JSON.parse(trimmed);
19710
+ const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
19711
+ ? String(raw.author.login)
19712
+ : undefined;
19713
+ return {
19714
+ number: raw.number,
19715
+ title: raw.title,
19716
+ url: raw.url,
19717
+ state: raw.state,
19718
+ isDraft: raw.isDraft,
19719
+ headRefName: raw.headRefName,
19720
+ baseRefName: raw.baseRefName,
19721
+ body: typeof raw.body === 'string' ? raw.body : undefined,
19722
+ author,
19723
+ reviewDecision: typeof raw.reviewDecision === 'string' ? raw.reviewDecision : undefined,
19724
+ mergeable: typeof raw.mergeable === 'string' ? raw.mergeable : undefined,
19725
+ mergeStateStatus: typeof raw.mergeStateStatus === 'string' ? raw.mergeStateStatus : undefined,
19726
+ statusCheckRollup: Array.isArray(raw.statusCheckRollup)
19727
+ ? raw.statusCheckRollup.map((entry) => ({
19728
+ name: String(entry.name || entry.context || 'check'),
19729
+ status: typeof entry.status === 'string' ? entry.status : undefined,
19730
+ conclusion: typeof entry.conclusion === 'string' ? entry.conclusion : undefined,
19731
+ }))
19732
+ : undefined,
19733
+ reviews: Array.isArray(raw.reviews)
19734
+ ? raw.reviews.map((entry) => {
19735
+ const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
19736
+ ? String(entry.author.login)
19737
+ : '';
19738
+ return {
19739
+ author,
19740
+ state: typeof entry.state === 'string' ? entry.state : '',
19741
+ };
19742
+ }).filter((review) => review.author)
19743
+ : undefined,
19744
+ };
18522
19745
  }
19746
+ /**
19747
+ * `gh pr view --json` field list. Centralized so the data fetcher and
19748
+ * any future re-fetch (e.g., refresh after a merge action) request the
19749
+ * same shape — the parser depends on every field being present, even
19750
+ * if optional, so they're safe to deserialize.
19751
+ */
19752
+ const PULL_REQUEST_VIEW_JSON_FIELDS = [
19753
+ 'number',
19754
+ 'title',
19755
+ 'url',
19756
+ 'state',
19757
+ 'isDraft',
19758
+ 'headRefName',
19759
+ 'baseRefName',
19760
+ 'body',
19761
+ 'author',
19762
+ 'reviewDecision',
19763
+ 'mergeable',
19764
+ 'mergeStateStatus',
19765
+ 'statusCheckRollup',
19766
+ 'reviews',
19767
+ ].join(',');
18523
19768
  async function getPullRequestOverview(git, runner = defaultGhRunner) {
18524
19769
  const [repository, currentBranchOutput] = await Promise.all([
18525
19770
  getGitHubRepository(git),
@@ -18551,7 +19796,7 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
18551
19796
  'pr',
18552
19797
  'view',
18553
19798
  '--json',
18554
- 'number,title,url,state,isDraft,headRefName,baseRefName',
19799
+ PULL_REQUEST_VIEW_JSON_FIELDS,
18555
19800
  ]);
18556
19801
  return {
18557
19802
  available: true,
@@ -18718,7 +19963,7 @@ function providerBranchName(branch) {
18718
19963
  return branch.shortName;
18719
19964
  }
18720
19965
 
18721
- function compactOutputLines$1(output) {
19966
+ function compactOutputLines$2(output) {
18722
19967
  return output
18723
19968
  .split('\n')
18724
19969
  .map((line) => line.trim())
@@ -18733,7 +19978,7 @@ async function runAction$5(action, successMessage) {
18733
19978
  };
18734
19979
  }
18735
19980
  catch (error) {
18736
- const lines = compactOutputLines$1(error.message);
19981
+ const lines = compactOutputLines$2(error.message);
18737
19982
  return {
18738
19983
  ok: false,
18739
19984
  message: lines[0] || 'History action failed.',
@@ -18875,7 +20120,7 @@ async function compareCommits(git, from, to) {
18875
20120
  }
18876
20121
  try {
18877
20122
  const output = await git.raw(['diff', '--stat', '--color=never', `${from.hash}..${to.hash}`]);
18878
- const lines = compactOutputLines$1(output);
20123
+ const lines = compactOutputLines$2(output);
18879
20124
  return {
18880
20125
  ok: true,
18881
20126
  message: `Compared ${from.shortHash}..${to.shortHash}`,
@@ -19042,6 +20287,61 @@ function createPullRequest(input, runner = defaultGhRunner) {
19042
20287
  };
19043
20288
  });
19044
20289
  }
20290
+ function isPullRequestMergeStrategy(value) {
20291
+ return value === 'merge' || value === 'squash' || value === 'rebase';
20292
+ }
20293
+ function buildMergePullRequestArgs(strategy) {
20294
+ // `--auto` and `--admin` are intentionally omitted — they're rarely
20295
+ // what a user wants from a TUI and require explicit gh auth scopes.
20296
+ // `--delete-branch` is opt-in via a future flag; default leaves the
20297
+ // branch in place so the user can verify before cleanup.
20298
+ return ['pr', 'merge', `--${strategy}`];
20299
+ }
20300
+ function mergePullRequest(strategy, runner = defaultGhRunner) {
20301
+ return runGhAction(runner, buildMergePullRequestArgs(strategy), (output) => ({
20302
+ ok: true,
20303
+ message: output.trim() || `Merged pull request with ${strategy}`,
20304
+ }));
20305
+ }
20306
+ function closePullRequest(runner = defaultGhRunner) {
20307
+ return runGhAction(runner, ['pr', 'close'], (output) => ({
20308
+ ok: true,
20309
+ message: output.trim() || 'Closed pull request',
20310
+ }));
20311
+ }
20312
+ /**
20313
+ * `gh pr review --approve` requires the user's gh auth to have scope
20314
+ * to write reviews — same scope that the in-browser approve button
20315
+ * uses. The runner surfaces auth failures via the standard error path.
20316
+ */
20317
+ function approvePullRequest(runner = defaultGhRunner) {
20318
+ return runGhAction(runner, ['pr', 'review', '--approve'], (output) => ({
20319
+ ok: true,
20320
+ message: output.trim() || 'Approved pull request',
20321
+ }));
20322
+ }
20323
+ /**
20324
+ * Request changes — `gh pr review` requires a body with this verb so
20325
+ * the empty-body case is rejected upstream by the input prompt.
20326
+ */
20327
+ function requestChangesPullRequest(body, runner = defaultGhRunner) {
20328
+ if (!body.trim()) {
20329
+ return Promise.resolve({ ok: false, message: 'Review body required for change-request' });
20330
+ }
20331
+ return runGhAction(runner, ['pr', 'review', '--request-changes', '--body', body], (output) => ({
20332
+ ok: true,
20333
+ message: output.trim() || 'Requested changes',
20334
+ }));
20335
+ }
20336
+ function commentPullRequest(body, runner = defaultGhRunner) {
20337
+ if (!body.trim()) {
20338
+ return Promise.resolve({ ok: false, message: 'Comment body required' });
20339
+ }
20340
+ return runGhAction(runner, ['pr', 'comment', '--body', body], (output) => ({
20341
+ ok: true,
20342
+ message: output.trim() || 'Comment added',
20343
+ }));
20344
+ }
19045
20345
 
19046
20346
  async function runAction$4(action, successMessage) {
19047
20347
  try {
@@ -19666,7 +20966,7 @@ function applyLogTuiAction(state, action) {
19666
20966
  }
19667
20967
  }
19668
20968
 
19669
- function compactOutputLines(output) {
20969
+ function compactOutputLines$1(output) {
19670
20970
  return output
19671
20971
  .split('\n')
19672
20972
  .map((line) => line.trim())
@@ -19681,7 +20981,7 @@ async function runAction(action, successMessage) {
19681
20981
  };
19682
20982
  }
19683
20983
  catch (error) {
19684
- const details = compactOutputLines(error.message);
20984
+ const details = compactOutputLines$1(error.message);
19685
20985
  return {
19686
20986
  ok: false,
19687
20987
  message: details[0] || 'Git operation action failed.',
@@ -21330,35 +22630,303 @@ async function startInteractiveLog(git, rows, streams = {}) {
21330
22630
  applyAndRender(applyLogTuiAction(state, { type: 'clearFilter' }));
21331
22631
  }
21332
22632
  };
21333
- input.on('keypress', onKeypress);
21334
- void Promise.all([
21335
- refreshBranches(),
21336
- refreshPullRequest(),
21337
- refreshTags(),
21338
- refreshWorktree(),
21339
- refreshStashes(),
21340
- refreshWorktreeList(),
21341
- refreshOperationOverview(),
21342
- refreshProviderOverview(),
21343
- ])
21344
- .then(async () => {
21345
- await refreshStatusHunks();
21346
- await render();
21347
- })
21348
- .catch(() => {
21349
- branches = undefined;
21350
- pullRequest = undefined;
21351
- tags = undefined;
21352
- worktree = undefined;
21353
- statusHunks = undefined;
21354
- stashes = undefined;
21355
- worktreeList = undefined;
21356
- operationOverview = undefined;
21357
- providerOverview = undefined;
21358
- });
21359
- void render();
22633
+ input.on('keypress', onKeypress);
22634
+ void Promise.all([
22635
+ refreshBranches(),
22636
+ refreshPullRequest(),
22637
+ refreshTags(),
22638
+ refreshWorktree(),
22639
+ refreshStashes(),
22640
+ refreshWorktreeList(),
22641
+ refreshOperationOverview(),
22642
+ refreshProviderOverview(),
22643
+ ])
22644
+ .then(async () => {
22645
+ await refreshStatusHunks();
22646
+ await render();
22647
+ })
22648
+ .catch(() => {
22649
+ branches = undefined;
22650
+ pullRequest = undefined;
22651
+ tags = undefined;
22652
+ worktree = undefined;
22653
+ statusHunks = undefined;
22654
+ stashes = undefined;
22655
+ worktreeList = undefined;
22656
+ operationOverview = undefined;
22657
+ providerOverview = undefined;
22658
+ });
22659
+ void render();
22660
+ });
22661
+ }
22662
+
22663
+ function compactOutputLines(output) {
22664
+ return output
22665
+ .split('\n')
22666
+ .map((line) => line.trim())
22667
+ .filter(Boolean);
22668
+ }
22669
+ async function safeUnlink(path) {
22670
+ try {
22671
+ await promises.unlink(path);
22672
+ }
22673
+ catch (error) {
22674
+ // ENOENT is fine — the temp file was never created or already
22675
+ // cleaned up. Anything else we silently swallow because the
22676
+ // worst-case impact is a single ~1KB file in $TMPDIR.
22677
+ error.code;
22678
+ }
22679
+ }
22680
+ /**
22681
+ * Write a unified-diff patch to a temp file and feed it to
22682
+ * `git apply` (or `git apply --cached` when target === 'index').
22683
+ *
22684
+ * This is the runner behind the `apply-hunk-worktree` /
22685
+ * `apply-hunk-index` workflow actions — the input handler builds
22686
+ * `patchText` from the cursored hunk via `extractDiffHunk` and the
22687
+ * runtime hands it here.
22688
+ *
22689
+ * `--whitespace=nowarn` keeps `git apply` quiet about trailing
22690
+ * whitespace differences (the most common false positive when the
22691
+ * patch comes from a stash made on a different platform). Real
22692
+ * conflicts still surface via the non-zero exit code.
22693
+ *
22694
+ * The patch is written to a temp file rather than piped on stdin
22695
+ * because some `simple-git` adapters don't expose a clean stdin
22696
+ * channel for `git.raw`; the tempfile path keeps the runner
22697
+ * portable across environments.
22698
+ */
22699
+ async function applyHunkPatch(git, patchText, options) {
22700
+ if (!patchText.trim()) {
22701
+ return {
22702
+ ok: false,
22703
+ message: 'No hunk under cursor to apply.',
22704
+ };
22705
+ }
22706
+ const targetLabel = options.target === 'index' ? 'index' : 'worktree';
22707
+ const tempPath = join(tmpdir(), `coco-hunk-${randomUUID()}.patch`);
22708
+ try {
22709
+ await promises.writeFile(tempPath, patchText, 'utf8');
22710
+ const args = ['apply'];
22711
+ if (options.target === 'index') {
22712
+ args.push('--cached');
22713
+ }
22714
+ args.push('--whitespace=nowarn');
22715
+ args.push(tempPath);
22716
+ try {
22717
+ await git.raw(args);
22718
+ return {
22719
+ ok: true,
22720
+ message: `Applied hunk to ${targetLabel}`,
22721
+ };
22722
+ }
22723
+ catch (error) {
22724
+ const lines = compactOutputLines(error.message);
22725
+ return {
22726
+ ok: false,
22727
+ message: lines[0] || `Failed to apply hunk to ${targetLabel}`,
22728
+ details: lines.slice(1, 6),
22729
+ };
22730
+ }
22731
+ }
22732
+ catch (error) {
22733
+ return {
22734
+ ok: false,
22735
+ message: `Could not stage hunk for apply: ${error.message}`,
22736
+ };
22737
+ }
22738
+ finally {
22739
+ await safeUnlink(tempPath);
22740
+ }
22741
+ }
22742
+
22743
+ function formatStashHeaderIdentity(ref, stashes) {
22744
+ if (!ref) {
22745
+ return { subtitle: 'no stash', bodyLine: 'Stash:' };
22746
+ }
22747
+ const index = stashes?.findIndex((entry) => entry.ref === ref) ?? -1;
22748
+ const entry = index >= 0 ? stashes[index] : undefined;
22749
+ if (!entry) {
22750
+ return {
22751
+ subtitle: ref,
22752
+ bodyLine: `Stash: ${ref}`,
22753
+ };
22754
+ }
22755
+ const onBranch = entry.branch && entry.branch !== '<unknown>' ? ` on ${entry.branch}` : '';
22756
+ const message = entry.message?.trim() || '(no message)';
22757
+ return {
22758
+ subtitle: `@{${index}} ${message}${onBranch}`,
22759
+ bodyLine: `Stash: ${ref}${onBranch} — ${message}`,
22760
+ };
22761
+ }
22762
+
22763
+ /**
22764
+ * Normalize gh's two parallel signals (`status` for in-flight check
22765
+ * runs, `conclusion` for completed runs and status contexts) into a
22766
+ * single status enum the renderer can map to a glyph + color.
22767
+ */
22768
+ function normalizePullRequestCheckStatus(check) {
22769
+ const status = (check.status || '').toUpperCase();
22770
+ const conclusion = (check.conclusion || '').toUpperCase();
22771
+ // In-flight check runs: gh emits `status: IN_PROGRESS|QUEUED` with
22772
+ // no conclusion yet. `PENDING` covers status-context runs that are
22773
+ // still waiting on a reporter.
22774
+ if (!conclusion && (status === 'IN_PROGRESS' || status === 'QUEUED' || status === 'PENDING')) {
22775
+ return 'pending';
22776
+ }
22777
+ switch (conclusion || status) {
22778
+ case 'SUCCESS':
22779
+ return 'success';
22780
+ case 'FAILURE':
22781
+ case 'ERROR':
22782
+ case 'TIMED_OUT':
22783
+ case 'ACTION_REQUIRED':
22784
+ return 'failure';
22785
+ case 'NEUTRAL':
22786
+ return 'neutral';
22787
+ case 'SKIPPED':
22788
+ case 'CANCELLED':
22789
+ return 'skipped';
22790
+ default:
22791
+ return 'pending';
22792
+ }
22793
+ }
22794
+ /**
22795
+ * Glyph for a normalized check status. ASCII fallbacks keep the panel
22796
+ * usable on legacy terminals where the geometric shapes block isn't
22797
+ * rendered.
22798
+ */
22799
+ function pullRequestCheckGlyph(status, options = {}) {
22800
+ if (options.ascii) {
22801
+ switch (status) {
22802
+ case 'success': return '+';
22803
+ case 'failure': return 'x';
22804
+ case 'pending': return '.';
22805
+ case 'neutral': return '-';
22806
+ case 'skipped': return '/';
22807
+ }
22808
+ }
22809
+ switch (status) {
22810
+ case 'success': return '✓';
22811
+ case 'failure': return '✗';
22812
+ case 'pending': return '◌';
22813
+ case 'neutral': return '○';
22814
+ case 'skipped': return '∼';
22815
+ }
22816
+ }
22817
+ function summarizePullRequestChecks(checks) {
22818
+ const summary = {
22819
+ total: 0, success: 0, failure: 0, pending: 0, neutral: 0, skipped: 0,
22820
+ };
22821
+ if (!checks)
22822
+ return summary;
22823
+ for (const check of checks) {
22824
+ summary.total += 1;
22825
+ summary[normalizePullRequestCheckStatus(check)] += 1;
22826
+ }
22827
+ return summary;
22828
+ }
22829
+ /**
22830
+ * One-line summary like `5 checks · 4 ✓ · 1 ◌` for the panel header.
22831
+ * Hides zero-count categories so the line stays scannable.
22832
+ */
22833
+ function formatPullRequestChecksSummary(summary, options = {}) {
22834
+ if (summary.total === 0) {
22835
+ return 'No status checks reported';
22836
+ }
22837
+ const parts = [`${summary.total} ${summary.total === 1 ? 'check' : 'checks'}`];
22838
+ const push = (count, status) => {
22839
+ if (count > 0)
22840
+ parts.push(`${count} ${pullRequestCheckGlyph(status, options)}`);
22841
+ };
22842
+ push(summary.success, 'success');
22843
+ push(summary.failure, 'failure');
22844
+ push(summary.pending, 'pending');
22845
+ push(summary.neutral, 'neutral');
22846
+ push(summary.skipped, 'skipped');
22847
+ return parts.join(' · ');
22848
+ }
22849
+ function buildPullRequestCheckRows(checks, options = {}) {
22850
+ if (!checks)
22851
+ return [];
22852
+ return checks.map((check) => {
22853
+ const status = normalizePullRequestCheckStatus(check);
22854
+ return {
22855
+ glyph: pullRequestCheckGlyph(status, options),
22856
+ name: check.name,
22857
+ status,
22858
+ detail: (check.conclusion || check.status || '').toLowerCase(),
22859
+ };
21360
22860
  });
21361
22861
  }
22862
+ function summarizePullRequestReviews(reviews, reviewDecision) {
22863
+ const summary = {
22864
+ total: 0, approved: 0, changesRequested: 0, commented: 0, dismissed: 0, pending: 0,
22865
+ decisionLabel: reviewDecision || undefined,
22866
+ };
22867
+ if (!reviews)
22868
+ return summary;
22869
+ for (const review of reviews) {
22870
+ summary.total += 1;
22871
+ switch (review.state.toUpperCase()) {
22872
+ case 'APPROVED':
22873
+ summary.approved += 1;
22874
+ break;
22875
+ case 'CHANGES_REQUESTED':
22876
+ summary.changesRequested += 1;
22877
+ break;
22878
+ case 'COMMENTED':
22879
+ summary.commented += 1;
22880
+ break;
22881
+ case 'DISMISSED':
22882
+ summary.dismissed += 1;
22883
+ break;
22884
+ case 'PENDING':
22885
+ summary.pending += 1;
22886
+ break;
22887
+ }
22888
+ }
22889
+ return summary;
22890
+ }
22891
+ function formatPullRequestReviewsSummary(summary) {
22892
+ const decision = summary.decisionLabel
22893
+ ? summary.decisionLabel.replace(/_/g, ' ').toLowerCase()
22894
+ : undefined;
22895
+ if (summary.total === 0) {
22896
+ return decision ? `No reviews · ${decision}` : 'No reviews submitted';
22897
+ }
22898
+ const parts = [`${summary.total} ${summary.total === 1 ? 'review' : 'reviews'}`];
22899
+ if (summary.approved > 0)
22900
+ parts.push(`${summary.approved} approved`);
22901
+ if (summary.changesRequested > 0)
22902
+ parts.push(`${summary.changesRequested} changes requested`);
22903
+ if (summary.commented > 0)
22904
+ parts.push(`${summary.commented} commented`);
22905
+ if (summary.pending > 0)
22906
+ parts.push(`${summary.pending} pending`);
22907
+ if (summary.dismissed > 0)
22908
+ parts.push(`${summary.dismissed} dismissed`);
22909
+ if (decision)
22910
+ parts.push(`decision: ${decision}`);
22911
+ return parts.join(' · ');
22912
+ }
22913
+ /**
22914
+ * One-line state badge for the header, e.g. `OPEN · draft` or `MERGED`.
22915
+ * Mergeable / merge-state is appended as a secondary chip when the PR
22916
+ * is open so the user sees `MERGEABLE` / `CONFLICTING` at a glance.
22917
+ */
22918
+ function formatPullRequestStateLine(pr) {
22919
+ const parts = [pr.state];
22920
+ if (pr.isDraft)
22921
+ parts.push('draft');
22922
+ if (pr.state === 'OPEN' && pr.mergeable) {
22923
+ parts.push(pr.mergeable.toLowerCase());
22924
+ }
22925
+ if (pr.state === 'OPEN' && pr.mergeStateStatus && pr.mergeStateStatus !== 'CLEAN') {
22926
+ parts.push(pr.mergeStateStatus.toLowerCase());
22927
+ }
22928
+ return parts.join(' · ');
22929
+ }
21362
22930
 
21363
22931
  function sectionLines(title, diff) {
21364
22932
  const lines = diff.split('\n').map((line) => line.trimEnd());
@@ -21575,6 +23143,89 @@ function diffLineProps(line, theme) {
21575
23143
  }
21576
23144
  return {};
21577
23145
  }
23146
+ /**
23147
+ * Minimum terminal width below which the split diff falls back to
23148
+ * unified rendering (#785). Each column needs ~50 columns for code to
23149
+ * read comfortably plus border + padding overhead, so anything narrower
23150
+ * than ~120 columns gets the unified view regardless of the user's
23151
+ * preference. The preference is preserved — switching back to a wide
23152
+ * terminal restores split mode automatically.
23153
+ */
23154
+ const MIN_SPLIT_DIFF_WIDTH = 120;
23155
+ function isSplitDiffViable(state, width) {
23156
+ return state.diffViewMode === 'split' && width >= MIN_SPLIT_DIFF_WIDTH;
23157
+ }
23158
+ /**
23159
+ * Style props for one side of a split-diff row, derived from the row's
23160
+ * `kind` rather than the leading character (because the helper has
23161
+ * already stripped the leading +/-/space). Keeps the colors aligned with
23162
+ * `diffLineProps`.
23163
+ */
23164
+ function splitDiffSideProps(kind, theme) {
23165
+ if (kind === 'header') {
23166
+ if (theme.noColor)
23167
+ return { dimColor: true };
23168
+ return { color: theme.colors.accent };
23169
+ }
23170
+ if (kind === 'empty') {
23171
+ return { dimColor: true };
23172
+ }
23173
+ if (theme.noColor) {
23174
+ return { dimColor: kind === 'context' };
23175
+ }
23176
+ if (kind === 'add')
23177
+ return { color: theme.colors.gitAdded };
23178
+ if (kind === 'remove')
23179
+ return { color: theme.colors.gitDeleted };
23180
+ return {};
23181
+ }
23182
+ /**
23183
+ * Format one column of a split-diff row: an optional 4-digit line
23184
+ * number prefix + the line text, padded/truncated to the column width.
23185
+ * Empty rows render a faint `·` placeholder so the alignment gap is
23186
+ * visible at a glance.
23187
+ */
23188
+ function formatSplitDiffCell(side, columnWidth) {
23189
+ if (side.kind === 'empty') {
23190
+ const placeholder = ' · ';
23191
+ return placeholder.padEnd(columnWidth);
23192
+ }
23193
+ if (side.kind === 'header') {
23194
+ return truncate$1(side.text, columnWidth).padEnd(columnWidth);
23195
+ }
23196
+ const lineNo = side.lineNumber !== undefined ? String(side.lineNumber).padStart(4) : ' ';
23197
+ // Strip the trailing newline that some diffs include. Keeps column
23198
+ // widths predictable.
23199
+ const text = side.text.replace(/\n$/, '');
23200
+ // 4 digits + 1 space gutter = 5 chars; reserve that off the column
23201
+ // before truncating the text.
23202
+ const textRoom = Math.max(1, columnWidth - 5);
23203
+ return `${lineNo} ${truncate$1(text, textRoom)}`.padEnd(columnWidth);
23204
+ }
23205
+ /**
23206
+ * Render the split-diff body as a list of two-column rows. The caller
23207
+ * is responsible for slicing the unified-line array to the visible
23208
+ * window — the helper just transforms that slice into Ink nodes.
23209
+ */
23210
+ function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, theme, keyPrefix) {
23211
+ const { Box, Text } = components;
23212
+ const rows = buildSplitDiffRows(unifiedSlice);
23213
+ // Reserve 3 columns of gutter (1 left padding from the Box + 1 column
23214
+ // separator + 1 right padding) so neither side touches the border.
23215
+ const usable = Math.max(20, width - 4);
23216
+ const gutter = 1;
23217
+ const half = Math.max(10, Math.floor((usable - gutter) / 2));
23218
+ return rows.map((row, index) => {
23219
+ const leftProps = splitDiffSideProps(row.left.kind, theme);
23220
+ const rightProps = splitDiffSideProps(row.right.kind, theme);
23221
+ const leftText = formatSplitDiffCell(row.left, half);
23222
+ const rightText = formatSplitDiffCell(row.right, half);
23223
+ return h(Box, {
23224
+ key: `${keyPrefix}-${startOffset + index}`,
23225
+ flexDirection: 'row',
23226
+ }, 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)));
23227
+ });
23228
+ }
21578
23229
  /**
21579
23230
  * Pick a theme color for a single name-status code (`A`, `M`, `D`,
21580
23231
  * `R100`, etc.) so the inspector and commit-diff file list render with
@@ -21628,68 +23279,6 @@ function sidebarTabLabel(tab) {
21628
23279
  return tab;
21629
23280
  }
21630
23281
  }
21631
- function sidebarLines(context, contextStatus, tab, width, state, theme) {
21632
- if (tab === 'status') {
21633
- const worktree = context.worktree;
21634
- if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
21635
- return ['Loading status...'];
21636
- }
21637
- if (!worktree) {
21638
- return ['Status unavailable'];
21639
- }
21640
- return [
21641
- `${worktree.stagedCount} staged`,
21642
- `${worktree.unstagedCount} unstaged`,
21643
- `${worktree.untrackedCount} untracked`,
21644
- '',
21645
- ...worktree.files.slice(0, 12).map((file) => `${file.indexStatus}${file.worktreeStatus} ${truncate$1(file.path, width - 3)}`),
21646
- ];
21647
- }
21648
- if (tab === 'branches') {
21649
- const branches = context.branches;
21650
- if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
21651
- return ['Loading branches...'];
21652
- }
21653
- if (!branches) {
21654
- return ['Branches unavailable'];
21655
- }
21656
- const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
21657
- return [
21658
- `Current: ${branches.currentBranch || '<detached>'}`,
21659
- branches.dirty ? 'Worktree: dirty' : 'Worktree: clean',
21660
- '',
21661
- ...sortedBranches.slice(0, 8).map((branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${truncate$1(branch.shortName, width - 4)}`),
21662
- ...sortedBranches.slice(0, 4).map((branch) => ` ${truncate$1(formatBranchDivergence(branch, { ascii: theme.ascii }), width - 2)}`),
21663
- ];
21664
- }
21665
- if (tab === 'tags') {
21666
- if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
21667
- return ['Loading tags...'];
21668
- }
21669
- const sortedTags = sortTags(context.tags?.tags || [], state.tagSort);
21670
- return sortedTags.length
21671
- ? sortedTags.slice(0, 12).map((tag) => `${truncate$1(tag.name, 16)} ${truncate$1(tag.subject, Math.max(8, width - 18))}`)
21672
- : ['No tags found'];
21673
- }
21674
- if (tab === 'stashes') {
21675
- if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
21676
- return ['Loading stashes...'];
21677
- }
21678
- return context.stashes?.stashes.length
21679
- ? context.stashes.stashes.slice(0, 12).map((stash) => `${stash.ref} ${truncate$1(stash.message, Math.max(8, width - stash.ref.length - 1))}`)
21680
- : ['No stashes found'];
21681
- }
21682
- if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
21683
- return ['Loading worktrees...'];
21684
- }
21685
- return context.worktreeList?.worktrees.length
21686
- ? context.worktreeList.worktrees.slice(0, 12).map((worktree) => {
21687
- const marker = worktree.current ? '*' : ' ';
21688
- const state = worktree.dirty ? 'dirty' : 'clean';
21689
- return `${marker} ${truncate$1(worktree.branch || worktree.path, Math.max(8, width - 8))} ${state}`;
21690
- })
21691
- : ['No linked worktrees'];
21692
- }
21693
23282
  async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
21694
23283
  const input = streams.input || process.stdin;
21695
23284
  const output = streams.output || process.stdout;
@@ -22031,6 +23620,13 @@ function LogInkApp(deps) {
22031
23620
  if (saved && saved !== state.userSidebarTab) {
22032
23621
  dispatch({ type: 'restoreSidebarTab', value: saved });
22033
23622
  }
23623
+ // Diff view mode persistence (#785). Same per-repo cache pattern
23624
+ // as the sidebar tab — restore the user's last preference if
23625
+ // they had one. New repos / fresh installs default to unified.
23626
+ const savedDiffMode = getSavedDiffViewMode(repoRoot);
23627
+ if (savedDiffMode && savedDiffMode !== state.diffViewMode) {
23628
+ dispatch({ type: 'setDiffViewMode', value: savedDiffMode });
23629
+ }
22034
23630
  }
22035
23631
  catch {
22036
23632
  // Not in a worktree, or revparse failed; nothing to restore.
@@ -22044,6 +23640,12 @@ function LogInkApp(deps) {
22044
23640
  return;
22045
23641
  saveSidebarTab(repoRoot, state.userSidebarTab);
22046
23642
  }, [state.userSidebarTab]);
23643
+ React.useEffect(() => {
23644
+ const repoRoot = repoRootRef.current;
23645
+ if (!repoRoot)
23646
+ return;
23647
+ saveDiffViewMode(repoRoot, state.diffViewMode);
23648
+ }, [state.diffViewMode]);
22047
23649
  // P-stash-explorer: load `git stash show -p <ref>` once the diff view
22048
23650
  // becomes active with diffSource='stash'. Best-effort — empty stashes
22049
23651
  // or read errors fall through to a "no diff" hint at the render site.
@@ -22336,6 +23938,27 @@ function LogInkApp(deps) {
22336
23938
  // disappears. Called from the y-confirm path for delete-branch / delete-
22337
23939
  // tag / drop-stash / remove-worktree / abort-operation.
22338
23940
  const runWorkflowAction = React.useCallback(async (id, payload) => {
23941
+ // Hunk-apply payload format: `<target>\n<patchText>` — the input
23942
+ // handler synthesizes both pieces (target from the keystroke,
23943
+ // patch text from extractDiffHunk against the live diff lines)
23944
+ // and packs them into the single `payload` field. Splitting on
23945
+ // the first newline keeps the patch body intact.
23946
+ const runApplyHunk = (expectedTarget, raw) => {
23947
+ if (!raw) {
23948
+ return Promise.resolve({ ok: false, message: 'No hunk under cursor to apply.' });
23949
+ }
23950
+ const newlineIndex = raw.indexOf('\n');
23951
+ if (newlineIndex < 0) {
23952
+ return Promise.resolve({ ok: false, message: 'Malformed hunk-apply payload.' });
23953
+ }
23954
+ const target = raw.slice(0, newlineIndex) === 'index' ? 'index' : 'worktree';
23955
+ const patchText = raw.slice(newlineIndex + 1);
23956
+ // The input handler is the source of truth for target — but if a
23957
+ // palette-injected payload mismatches the workflow id, prefer
23958
+ // the workflow id so the user sees the action they asked for.
23959
+ const effectiveTarget = expectedTarget || target;
23960
+ return applyHunkPatch(git, patchText, { target: effectiveTarget });
23961
+ };
22339
23962
  const handlers = {
22340
23963
  'create-branch': async () => {
22341
23964
  const name = payload?.trim();
@@ -22441,6 +24064,44 @@ function LogInkApp(deps) {
22441
24064
  message: commit.message,
22442
24065
  });
22443
24066
  },
24067
+ 'revert-commit': async () => {
24068
+ const commit = getSelectedInkCommit(state);
24069
+ if (!commit)
24070
+ return { ok: false, message: 'No commit selected' };
24071
+ return revertCommit(git, {
24072
+ hash: commit.hash,
24073
+ shortHash: commit.shortHash,
24074
+ message: commit.message,
24075
+ });
24076
+ },
24077
+ 'reset-to-commit': async () => {
24078
+ const commit = getSelectedInkCommit(state);
24079
+ if (!commit)
24080
+ return { ok: false, message: 'No commit selected' };
24081
+ // Mode arrives via the action's `payload` field — the input
24082
+ // handler runs the reset-mode prompt (kind: 'reset-mode') and
24083
+ // routes the typed value here. Default to `mixed` (git's own
24084
+ // default) when the user submitted an empty value.
24085
+ const raw = payload?.trim().toLowerCase() || 'mixed';
24086
+ if (!isResetMode(raw)) {
24087
+ return { ok: false, message: `Unknown reset mode: ${raw}. Use soft, mixed, or hard.` };
24088
+ }
24089
+ return resetToCommit(git, {
24090
+ hash: commit.hash,
24091
+ shortHash: commit.shortHash,
24092
+ message: commit.message,
24093
+ }, raw);
24094
+ },
24095
+ 'interactive-rebase': async () => {
24096
+ const commit = getSelectedInkCommit(state);
24097
+ if (!commit)
24098
+ return { ok: false, message: 'No commit selected' };
24099
+ return startInteractiveRebase(git, {
24100
+ hash: commit.hash,
24101
+ shortHash: commit.shortHash,
24102
+ message: commit.message,
24103
+ });
24104
+ },
22444
24105
  'checkout-file-from-commit': async () => {
22445
24106
  // payload is "<sha> <path>" so we pass both through a single
22446
24107
  // string field on the action.
@@ -22456,6 +24117,8 @@ function LogInkApp(deps) {
22456
24117
  return { ok: false, message: 'No commit file under cursor' };
22457
24118
  return checkoutFileFromCommit(git, sha, path);
22458
24119
  },
24120
+ 'apply-hunk-worktree': async () => runApplyHunk('worktree', payload),
24121
+ 'apply-hunk-index': async () => runApplyHunk('index', payload),
22459
24122
  'remove-worktree': async () => {
22460
24123
  const all = context.worktreeList?.worktrees || [];
22461
24124
  // Resolve the target from the visible (filtered) list so a
@@ -22544,6 +24207,32 @@ function LogInkApp(deps) {
22544
24207
  return { ok: false, message: 'Stash message required' };
22545
24208
  return createStash(git, message);
22546
24209
  },
24210
+ // #783 — full PR action panel handlers. Each wraps the matching
24211
+ // pullRequestActions verb. Strategy / body arrives via `payload`
24212
+ // — input prompts validate before they reach here, but the
24213
+ // strategy guard stays as a defensive belt-and-suspenders since
24214
+ // a future palette path could call us with a raw value.
24215
+ 'merge-pr': async () => {
24216
+ const strategy = (payload || 'merge').toLowerCase();
24217
+ if (!isPullRequestMergeStrategy(strategy)) {
24218
+ return { ok: false, message: `Unknown merge strategy: ${strategy}. Use merge, squash, or rebase.` };
24219
+ }
24220
+ return mergePullRequest(strategy);
24221
+ },
24222
+ 'close-pr': async () => closePullRequest(),
24223
+ 'approve-pr': async () => approvePullRequest(),
24224
+ 'request-changes-pr': async () => {
24225
+ const body = payload?.trim();
24226
+ if (!body)
24227
+ return { ok: false, message: 'Review body required for change-request' };
24228
+ return requestChangesPullRequest(body);
24229
+ },
24230
+ 'comment-pr': async () => {
24231
+ const body = payload?.trim();
24232
+ if (!body)
24233
+ return { ok: false, message: 'Comment body required' };
24234
+ return commentPullRequest(body);
24235
+ },
22547
24236
  };
22548
24237
  const handler = handlers[id];
22549
24238
  if (!handler) {
@@ -22834,6 +24523,51 @@ function LogInkApp(deps) {
22834
24523
  });
22835
24524
  })();
22836
24525
  }, [dispatch, git, logArgv, state.historyFetchArgs]);
24526
+ // Graph mode toggle (`g` key, #791 follow-up). The header label flips
24527
+ // between "compact graph" and "full graph", but unless we re-fetch with
24528
+ // the right `view`, the underlying rows still come from the user's
24529
+ // initial argv (default `--first-parent --no-merges`) and the renderer
24530
+ // has no topology to draw — defeating the per-lane / junction work.
24531
+ // Mirrors the historyFetchArgs effect: skip first run, request-id ref
24532
+ // for stale-completion guard, swap rows in place via replaceRows.
24533
+ const toggleGraphEffectInitialized = React.useRef(false);
24534
+ const toggleGraphRequestRef = React.useRef(0);
24535
+ React.useEffect(() => {
24536
+ if (!logArgv)
24537
+ return;
24538
+ if (!toggleGraphEffectInitialized.current) {
24539
+ toggleGraphEffectInitialized.current = true;
24540
+ return;
24541
+ }
24542
+ const requestId = toggleGraphRequestRef.current + 1;
24543
+ toggleGraphRequestRef.current = requestId;
24544
+ const merged = buildToggleGraphArgs(logArgv, state.fullGraph);
24545
+ dispatch({
24546
+ type: 'setStatus',
24547
+ value: state.fullGraph
24548
+ ? 'Loading full topology…'
24549
+ : 'Loading compact history…',
24550
+ });
24551
+ void (async () => {
24552
+ const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
24553
+ if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
24554
+ return;
24555
+ }
24556
+ if (!nextRows) {
24557
+ dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
24558
+ return;
24559
+ }
24560
+ dispatch({ type: 'replaceRows', rows: nextRows });
24561
+ const matched = getCommitRows(nextRows).length;
24562
+ setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
24563
+ dispatch({
24564
+ type: 'setStatus',
24565
+ value: state.fullGraph
24566
+ ? `Showing ${matched} commits across all branches`
24567
+ : `Showing ${matched} commits (compact)`,
24568
+ });
24569
+ })();
24570
+ }, [dispatch, git, logArgv, state.fullGraph]);
22837
24571
  const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
22838
24572
  .map((line, index) => (line.startsWith('@@') ? index : -1))
22839
24573
  .filter((index) => index >= 0)), [filePreview]);
@@ -22928,6 +24662,17 @@ function LogInkApp(deps) {
22928
24662
  ? selected?.hash
22929
24663
  : undefined,
22930
24664
  worktreeDirty,
24665
+ // H / gH need the actual diff text (not just hunk offsets) to
24666
+ // slice the cursored hunk into a `git apply` patch. Stash uses
24667
+ // the full `git stash show -p` output; commit-diff uses the
24668
+ // per-file `filePreview.hunks` array. Either way, extractDiffHunk
24669
+ // walks `@@` headers and synthesizes a fresh diff --git / --- /
24670
+ // +++ header set using the path the caller already resolved.
24671
+ diffLinesForHunkApply: state.diffSource === 'stash'
24672
+ ? stashDiffLines
24673
+ : state.diffSource === 'commit'
24674
+ ? filePreview?.hunks
24675
+ : undefined,
22931
24676
  }).forEach((event) => {
22932
24677
  if (event.type === 'exit') {
22933
24678
  exit();
@@ -22994,7 +24739,7 @@ function LogInkApp(deps) {
22994
24739
  if (showOnboarding) {
22995
24740
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
22996
24741
  }
22997
- 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));
24742
+ 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));
22998
24743
  }
22999
24744
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
23000
24745
  const { Box, Text } = components;
@@ -23094,11 +24839,101 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
23094
24839
  if (tab === 'status') {
23095
24840
  return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
23096
24841
  }
23097
- const lines = sidebarLines(context, contextStatus, tab, width - 6, state, theme);
23098
- return lines.map((line, index) => h(Text, {
23099
- key: `tab-content-${tab}-${index}`,
23100
- dimColor: !line.trim(),
23101
- }, truncate$1(` ${line}`, width - 4)));
24842
+ // Branches / tags / stashes / worktrees: render selectable rows so
24843
+ // ↑/↓ navigates within the sidebar list and Enter / per-entity keys
24844
+ // act on the cursored item without needing to drill into the
24845
+ // dedicated view (#791 follow-up — in-sidebar selection).
24846
+ const focused = state.focus === 'sidebar' && state.sidebarTab === tab;
24847
+ if (tab === 'branches') {
24848
+ if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
24849
+ return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
24850
+ }
24851
+ const branches = context.branches;
24852
+ if (!branches) {
24853
+ return [h(Text, { key: 'tab-branches-empty', dimColor: true }, ' Branches unavailable')];
24854
+ }
24855
+ const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
24856
+ const headerRows = [
24857
+ h(Text, { key: 'tab-branches-current', dimColor: true }, truncate$1(` Current: ${branches.currentBranch || '<detached>'}`, width - 4)),
24858
+ h(Text, { key: 'tab-branches-state', dimColor: true }, ` Worktree: ${branches.dirty ? 'dirty' : 'clean'}`),
24859
+ h(Text, { key: 'tab-branches-spacer' }, ''),
24860
+ ];
24861
+ return [
24862
+ ...headerRows,
24863
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches'),
24864
+ ];
24865
+ }
24866
+ if (tab === 'tags') {
24867
+ if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
24868
+ return [h(Text, { key: 'tab-tags-loading', dimColor: true }, ' Loading tags…')];
24869
+ }
24870
+ const tags = sortTags(context.tags?.tags || [], state.tagSort);
24871
+ if (tags.length === 0) {
24872
+ return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
24873
+ }
24874
+ return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags');
24875
+ }
24876
+ if (tab === 'stashes') {
24877
+ if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
24878
+ return [h(Text, { key: 'tab-stashes-loading', dimColor: true }, ' Loading stashes…')];
24879
+ }
24880
+ const stashes = context.stashes?.stashes || [];
24881
+ if (stashes.length === 0) {
24882
+ return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
24883
+ }
24884
+ return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes');
24885
+ }
24886
+ // worktrees
24887
+ if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
24888
+ return [h(Text, { key: 'tab-worktrees-loading', dimColor: true }, ' Loading worktrees…')];
24889
+ }
24890
+ const worktrees = context.worktreeList?.worktrees || [];
24891
+ if (worktrees.length === 0) {
24892
+ return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
24893
+ }
24894
+ return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
24895
+ const marker = worktree.current ? '*' : ' ';
24896
+ const wstate = worktree.dirty ? 'dirty' : 'clean';
24897
+ return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
24898
+ }, 'tab-worktrees');
24899
+ }
24900
+ /**
24901
+ * Render a sliding-window list of selectable sidebar rows. The cursor
24902
+ * highlights the row at `selectedIndex` only when `focused` is true so
24903
+ * an unfocused sidebar doesn't compete visually with the active panel.
24904
+ * Sliding window keeps the cursor in view as the user navigates a long
24905
+ * list; truncation hints surface the count of hidden rows.
24906
+ */
24907
+ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix) {
24908
+ if (items.length === 0)
24909
+ return [];
24910
+ const window = getSidebarVisibleWindow(items.length, selectedIndex);
24911
+ const elements = [];
24912
+ if (window.truncatedAbove > 0) {
24913
+ elements.push(h(Text, {
24914
+ key: `${keyPrefix}-trunc-above`,
24915
+ dimColor: true,
24916
+ }, truncate$1(` … ${window.truncatedAbove} more above`, width - 4)));
24917
+ }
24918
+ for (let offset = 0; offset < window.size; offset += 1) {
24919
+ const index = window.start + offset;
24920
+ if (index >= items.length)
24921
+ break;
24922
+ const isSelected = focused && index === selectedIndex;
24923
+ const text = toRowText(items[index], index);
24924
+ elements.push(h(Text, {
24925
+ key: `${keyPrefix}-row-${index}`,
24926
+ backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
24927
+ inverse: isSelected,
24928
+ }, truncate$1(` ${text}`, width - 4)));
24929
+ }
24930
+ if (window.truncatedBelow > 0) {
24931
+ elements.push(h(Text, {
24932
+ key: `${keyPrefix}-trunc-below`,
24933
+ dimColor: true,
24934
+ }, truncate$1(` … ${window.truncatedBelow} more below`, width - 4)));
24935
+ }
24936
+ return elements;
23102
24937
  }
23103
24938
  function renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme) {
23104
24939
  if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
@@ -23156,6 +24991,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
23156
24991
  if (state.activeView === 'worktrees') {
23157
24992
  return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
23158
24993
  }
24994
+ if (state.activeView === 'pull-request') {
24995
+ return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
24996
+ }
23159
24997
  return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
23160
24998
  }
23161
24999
  function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
@@ -23205,15 +25043,46 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
23205
25043
  }))
23206
25044
  : visible.items.map((item, index) => {
23207
25045
  if (item.type === 'graph') {
25046
+ if (item.laneSegments && !theme.ascii) {
25047
+ return h(Text, { key: `graph-${index}-${item.graph}` }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`));
25048
+ }
23208
25049
  return h(Text, {
23209
25050
  key: `graph-${index}-${item.graph}`,
23210
25051
  color: theme.noColor ? undefined : theme.colors.muted,
23211
25052
  dimColor: theme.noColor,
23212
25053
  }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
23213
25054
  }
23214
- return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index);
25055
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
23215
25056
  }));
23216
25057
  }
25058
+ /**
25059
+ * Render `LaneSegment[]` as a flat list of Text spans, one per lane
25060
+ * (#791 stage 2). Each segment paints in its lane's palette color so
25061
+ * the eye can follow a branch column-by-column; segments without a
25062
+ * lane id (spaces, padding, decorations) fall back to the muted graph
25063
+ * color so they visually recede.
25064
+ *
25065
+ * Final padding is appended as its own span so callers do not need to
25066
+ * pre-pad the graph string before computing lane segments.
25067
+ */
25068
+ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
25069
+ const muted = theme.noColor ? undefined : theme.colors.muted;
25070
+ const elements = [];
25071
+ let totalLen = 0;
25072
+ segments.forEach((seg, idx) => {
25073
+ const laneColor = getLaneColor(seg.laneId, theme);
25074
+ elements.push(h(Text, {
25075
+ key: `${keyPrefix}-${idx}`,
25076
+ color: laneColor ?? muted,
25077
+ dimColor: theme.noColor && seg.laneId === undefined,
25078
+ }, seg.text));
25079
+ totalLen += seg.text.length;
25080
+ });
25081
+ if (padTo > totalLen) {
25082
+ elements.push(h(Text, { key: `${keyPrefix}-pad` }, ' '.repeat(padTo - totalLen)));
25083
+ }
25084
+ return elements;
25085
+ }
23217
25086
  /**
23218
25087
  * Render a single commit row with each segment in its own colored span.
23219
25088
  * Graph chars render in `theme.colors.muted` so the topology visually
@@ -23226,8 +25095,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
23226
25095
  * Truncation is per-segment so the variable-length message field gets
23227
25096
  * the leftover budget after fixed segments are accounted for.
23228
25097
  */
23229
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index) {
23230
- const renderedGraph = substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii });
25098
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
23231
25099
  const refs = formatInkRefLabels(commit.refs);
23232
25100
  const totalWidth = 140;
23233
25101
  const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
@@ -23236,11 +25104,17 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
23236
25104
  const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
23237
25105
  const accent = theme.noColor ? undefined : theme.colors.accent;
23238
25106
  const muted = theme.noColor ? undefined : theme.colors.muted;
25107
+ // Lane-colored graph spans when full graph mode + non-ASCII rendering
25108
+ // is in play; otherwise fall back to the legacy single-muted span so
25109
+ // compact mode and legacy terminals stay visually unchanged.
25110
+ const graphChildren = laneSegments && !theme.ascii
25111
+ ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `c${index}`)
25112
+ : [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
23239
25113
  return h(Text, {
23240
25114
  key: `${commit.hash}-${index}`,
23241
25115
  backgroundColor: selectedBg,
23242
25116
  inverse: selected,
23243
- }, 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);
25117
+ }, ...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);
23244
25118
  }
23245
25119
  /**
23246
25120
  * Render the synthetic "(+) new commit" affordance shown above the real
@@ -23473,11 +25347,35 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
23473
25347
  const cursor = isSelected ? '>' : ' ';
23474
25348
  const marker = branchRowMarker(branch, { ascii: theme.ascii });
23475
25349
  const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
25350
+ const lastTouched = formatBranchLastTouched(branch.date, new Date());
25351
+ // Split the row into spans so the timestamp stays dim even on the
25352
+ // currently-selected (bold) row. The leading marker + name keep
25353
+ // their original column widths; the timestamp is right-padded so
25354
+ // the divergence column stays aligned across rows.
25355
+ const namePadded = branch.shortName.padEnd(28);
25356
+ const timestampPadded = lastTouched.padEnd(8);
25357
+ const lineDim = !isSelected && !branch.current;
25358
+ const head = `${cursor} ${marker} ${namePadded} `;
25359
+ const trailingDivergence = divergence ? ` ${divergence}` : '';
25360
+ // Truncate the assembled line cooperatively so we never overflow
25361
+ // the panel; the timestamp is short and the divergence is the
25362
+ // most expendable, but the existing 140 cap is ample.
25363
+ const fullText = `${head}${timestampPadded}${trailingDivergence}`;
25364
+ const truncated = truncate$1(fullText, 140);
25365
+ // If truncation chopped into the timestamp/divergence portion,
25366
+ // fall back to a single Text to keep the visible width honest.
25367
+ if (truncated !== fullText) {
25368
+ return h(Text, {
25369
+ key: `branch-${index}`,
25370
+ bold: isSelected,
25371
+ dimColor: lineDim,
25372
+ }, truncated);
25373
+ }
23476
25374
  return h(Text, {
23477
25375
  key: `branch-${index}`,
23478
25376
  bold: isSelected,
23479
- dimColor: !isSelected && !branch.current,
23480
- }, truncate$1(`${cursor} ${marker} ${branch.shortName.padEnd(28)} ${divergence}`, 140));
25377
+ dimColor: lineDim,
25378
+ }, head, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
23481
25379
  });
23482
25380
  return h(Box, {
23483
25381
  borderColor: focusBorderColor(theme, focused),
@@ -23630,6 +25528,98 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
23630
25528
  width,
23631
25529
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Worktrees', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
23632
25530
  }
25531
+ /**
25532
+ * Pull-request action panel (#783) — renders the current branch's PR
25533
+ * with header, checks table, reviews summary, and a body preview.
25534
+ * Action keys (m / x / a / R / c / O) are wired in inkInput.ts and
25535
+ * surfaced via the footer; this renderer is read-only.
25536
+ *
25537
+ * Three loading / fallback states matter:
25538
+ * - Provider data still loading → "Loading pull request..."
25539
+ * - GitHub remote present but no PR for the current branch → empty
25540
+ * state hint pointing the user at `C` to create one.
25541
+ * - GitHub CLI missing / unauthenticated → unavailable hint.
25542
+ */
25543
+ function renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
25544
+ const { Box, Text } = components;
25545
+ const focused = state.focus === 'commits';
25546
+ const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
25547
+ const pullRequestOverview = context.pullRequest;
25548
+ // Use the dedicated `pullRequest` overview only — the `provider`
25549
+ // shape carries a slimmer ProviderPullRequestStatus that lacks
25550
+ // url / headRefName / body / mergeable / reviews. The dedicated
25551
+ // overview hits `gh pr view --json` with the full enriched field
25552
+ // list (PULL_REQUEST_VIEW_JSON_FIELDS) so the panel has everything.
25553
+ const pr = pullRequestOverview?.currentPullRequest;
25554
+ const muted = theme.noColor ? undefined : theme.colors.muted;
25555
+ const accent = theme.noColor ? undefined : theme.colors.accent;
25556
+ const containerProps = {
25557
+ borderColor: focusBorderColor(theme, focused),
25558
+ borderStyle: theme.borderStyle,
25559
+ flexDirection: 'column',
25560
+ flexShrink: 0,
25561
+ paddingX: 1,
25562
+ width,
25563
+ };
25564
+ if (loading && !pr) {
25565
+ 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' })));
25566
+ }
25567
+ if (!pr) {
25568
+ const hint = pullRequestOverview?.message
25569
+ || 'No pull request detected for this branch. Press `C` (or `:create-pr`) to create one.';
25570
+ 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)));
25571
+ }
25572
+ const checks = summarizePullRequestChecks(pr.statusCheckRollup);
25573
+ const reviews = summarizePullRequestReviews(pr.reviews, pr.reviewDecision);
25574
+ const checkRows = buildPullRequestCheckRows(pr.statusCheckRollup, { ascii: theme.ascii });
25575
+ const checkColor = (s) => {
25576
+ if (theme.noColor)
25577
+ return undefined;
25578
+ if (s === 'success')
25579
+ return theme.colors.success;
25580
+ if (s === 'failure')
25581
+ return theme.colors.danger;
25582
+ if (s === 'pending')
25583
+ return theme.colors.warning;
25584
+ return theme.colors.muted;
25585
+ };
25586
+ // Reserve a few rows for the header/section labels; the rest go to
25587
+ // the checks table. Body preview gets the leftover rows so the
25588
+ // surface stays vertically balanced even on tall terminals.
25589
+ const checkBudget = Math.max(3, Math.min(checkRows.length, Math.floor(bodyRows / 2)));
25590
+ const visibleChecks = checkRows.slice(0, checkBudget);
25591
+ const truncatedChecks = checkRows.length - visibleChecks.length;
25592
+ const bodyPreviewBudget = Math.max(2, bodyRows - 8 - visibleChecks.length);
25593
+ const bodyLines = (pr.body || '').split(/\r?\n/).filter((line) => line.trim().length > 0);
25594
+ const visibleBodyLines = bodyLines.slice(0, bodyPreviewBudget);
25595
+ const truncatedBodyLines = bodyLines.length - visibleBodyLines.length;
25596
+ const headerRight = `#${pr.number} · ${pr.headRefName} → ${pr.baseRefName}`;
25597
+ const stateLine = formatPullRequestStateLine(pr);
25598
+ const author = pr.author ? `by @${pr.author}` : '';
25599
+ 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, ''),
25600
+ // Checks section
25601
+ 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, {
25602
+ key: `pr-check-${index}`,
25603
+ color: checkColor(row.status),
25604
+ }, truncate$1(` ${row.glyph} ${row.name.padEnd(28)} ${row.detail}`, width - 4))), ...(truncatedChecks > 0
25605
+ ? [h(Text, { key: 'pr-checks-trunc', dimColor: true }, truncate$1(` … ${truncatedChecks} more`, width - 4))]
25606
+ : []), h(Text, undefined, ''),
25607
+ // Reviews section
25608
+ h(Text, { bold: true, color: accent }, 'Reviews'), h(Text, { dimColor: true }, truncate$1(` ${formatPullRequestReviewsSummary(reviews)}`, width - 4)), h(Text, undefined, ''),
25609
+ // Body preview
25610
+ ...(visibleBodyLines.length > 0
25611
+ ? [
25612
+ h(Text, { key: 'pr-body-label', bold: true, color: accent }, 'Description'),
25613
+ ...visibleBodyLines.map((line, index) => h(Text, {
25614
+ key: `pr-body-${index}`,
25615
+ color: muted,
25616
+ }, truncate$1(` ${line}`, width - 4))),
25617
+ ...(truncatedBodyLines > 0
25618
+ ? [h(Text, { key: 'pr-body-trunc', dimColor: true }, truncate$1(` … ${truncatedBodyLines} more lines`, width - 4))]
25619
+ : []),
25620
+ ]
25621
+ : []));
25622
+ }
23633
25623
  /**
23634
25624
  * Filter input cursor for the promoted views (branches/tags/stash).
23635
25625
  * History already shows the same `filter: foo_` affordance in its header
@@ -23660,6 +25650,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23660
25650
  // cherry-picks the file at the cursor.
23661
25651
  if (state.diffSource === 'stash') {
23662
25652
  const lines = stashDiffLines || [];
25653
+ const splitActive = isSplitDiffViable(state, width);
25654
+ const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
23663
25655
  const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
23664
25656
  const stashFiles = parseStashDiffFiles(lines);
23665
25657
  const fileCount = stashFiles.length;
@@ -23680,11 +25672,19 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23680
25672
  const currentFileIndex = currentFile
23681
25673
  ? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
23682
25674
  : -1;
23683
- const headerLines = stashDiffLoading
23684
- ? [`Loading diff for ${state.stashDiffRef || 'stash'}...`]
25675
+ // Look up the active stash entry so the panel header can show a
25676
+ // human-identifier instead of the raw `stash@{<iso-date>}` ref.
25677
+ // The git ref is the timestamp form (we fetch with --date=iso for
25678
+ // stable parsing) which reads as noise in the title bar; the
25679
+ // message + branch + index combination is what the user wrote down
25680
+ // when they ran `git stash`. Body still shows the full ref so it
25681
+ // stays unambiguous.
25682
+ const stashIdentity = formatStashHeaderIdentity(state.stashDiffRef, context.stashes?.stashes);
25683
+ const baseHeaderLines = stashDiffLoading
25684
+ ? [`Loading diff for ${stashIdentity.subtitle}...`]
23685
25685
  : lines.length
23686
25686
  ? [
23687
- `Stash: ${state.stashDiffRef || ''}`,
25687
+ stashIdentity.bodyLine,
23688
25688
  fileCount > 0 && currentFile
23689
25689
  ? `File ${currentFileIndex + 1}/${fileCount}: ${currentFile.path}`
23690
25690
  : 'No files in this stash.',
@@ -23692,6 +25692,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23692
25692
  '',
23693
25693
  ]
23694
25694
  : ['No diff to display for this stash.'];
25695
+ const headerLines = splitRequestedButTooNarrow
25696
+ ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
25697
+ : baseHeaderLines;
25698
+ const stashBodyNodes = stashDiffLoading || !lines.length
25699
+ ? []
25700
+ : splitActive
25701
+ ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
25702
+ : visibleLines.map((line, index) => h(Text, {
25703
+ key: `stash-diff-line-${state.diffPreviewOffset + index}`,
25704
+ ...diffLineProps(line, theme),
25705
+ }, truncate$1(line, width - 4)));
23695
25706
  return h(Box, {
23696
25707
  borderColor: focusBorderColor(theme, focused),
23697
25708
  borderStyle: theme.borderStyle,
@@ -23699,15 +25710,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23699
25710
  flexShrink: 0,
23700
25711
  paddingX: 1,
23701
25712
  width,
23702
- }, 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, {
25713
+ }, 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, {
23703
25714
  key: `stash-diff-header-${index}`,
23704
25715
  dimColor: index > 0,
23705
- }, truncate$1(line, width - 4))), ...(stashDiffLoading || !lines.length
23706
- ? []
23707
- : visibleLines.map((line, index) => h(Text, {
23708
- key: `stash-diff-line-${state.diffPreviewOffset + index}`,
23709
- ...diffLineProps(line, theme),
23710
- }, truncate$1(line, width - 4)))));
25716
+ }, truncate$1(line, width - 4))), ...stashBodyNodes);
23711
25717
  }
23712
25718
  // diffSource disambiguates: 'commit' was set when the user opened the
23713
25719
  // diff via history → Enter (read-only commit-diff explore), 'worktree'
@@ -23718,6 +25724,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23718
25724
  (state.diffSource === undefined && !worktreeFile && Boolean(selectedDetailFile));
23719
25725
  if (useCommitDiff) {
23720
25726
  const previewHunks = filePreview?.hunks || [];
25727
+ const splitActive = isSplitDiffViable(state, width);
25728
+ const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
23721
25729
  const visiblePreviewHunks = previewHunks.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
23722
25730
  const hunkCount = commitDiffHunkOffsets?.length || 0;
23723
25731
  const currentHunkIndex = hunkCount > 0
@@ -23728,7 +25736,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23728
25736
  const currentHunkLabel = hunkCount > 0
23729
25737
  ? `Hunk ${Math.min(hunkCount - currentHunkIndex, hunkCount)}/${hunkCount}`
23730
25738
  : 'No hunks for this file.';
23731
- const headerLines = filePreviewLoading
25739
+ const baseHeaderLines = filePreviewLoading
23732
25740
  ? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
23733
25741
  : previewHunks.length
23734
25742
  ? [
@@ -23738,6 +25746,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23738
25746
  '',
23739
25747
  ]
23740
25748
  : ['No diff preview available for this file.'];
25749
+ const headerLines = splitRequestedButTooNarrow
25750
+ ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
25751
+ : baseHeaderLines;
25752
+ const commitBodyNodes = filePreviewLoading || !previewHunks.length
25753
+ ? []
25754
+ : splitActive
25755
+ ? renderSplitDiffBody(h, components, visiblePreviewHunks, state.diffPreviewOffset, width, theme, 'commit-diff-split')
25756
+ : visiblePreviewHunks.map((line, index) => h(Text, {
25757
+ key: `diff-surface-line-${state.diffPreviewOffset + index}`,
25758
+ ...diffLineProps(line, theme),
25759
+ }, truncate$1(line, 140)));
23741
25760
  return h(Box, {
23742
25761
  borderColor: focusBorderColor(theme, focused),
23743
25762
  borderStyle: theme.borderStyle,
@@ -23745,15 +25764,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23745
25764
  flexShrink: 0,
23746
25765
  paddingX: 1,
23747
25766
  width,
23748
- }, 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, {
25767
+ }, 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, {
23749
25768
  key: `diff-surface-header-${index}`,
23750
25769
  dimColor: index > 0,
23751
- }, truncate$1(line, 140))), ...(filePreviewLoading || !previewHunks.length
23752
- ? []
23753
- : visiblePreviewHunks.map((line, index) => h(Text, {
23754
- key: `diff-surface-line-${state.diffPreviewOffset + index}`,
23755
- ...diffLineProps(line, theme),
23756
- }, truncate$1(line, 140)))));
25770
+ }, truncate$1(line, 140))), ...commitBodyNodes);
23757
25771
  }
23758
25772
  const diffLines = worktreeDiff?.lines || [];
23759
25773
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
@@ -24228,16 +26242,34 @@ function renderInputPromptPanel(h, components, state, width, theme, focused) {
24228
26242
  if (!prompt) {
24229
26243
  return h(Box, { width });
24230
26244
  }
26245
+ const accent = theme.noColor ? undefined : theme.colors.accent;
26246
+ // Multi-line prompts (#806) split on newline and render one Text
26247
+ // row per buffer line — the cursor sits at the end of the last
26248
+ // line via the trailing `_`. Single-line prompts collapse to the
26249
+ // original one-row layout for muscle-memory continuity.
26250
+ const promptLines = prompt.multiline ? prompt.value.split('\n') : [prompt.value];
26251
+ if (promptLines.length === 0) {
26252
+ promptLines.push('');
26253
+ }
26254
+ const valueRows = promptLines.map((line, index) => {
26255
+ const isLast = index === promptLines.length - 1;
26256
+ const display = isLast ? `${line}_` : line;
26257
+ return h(Text, {
26258
+ key: `prompt-line-${index}`,
26259
+ bold: true,
26260
+ color: accent,
26261
+ }, truncate$1(display, width - 4));
26262
+ });
26263
+ const hint = prompt.multiline
26264
+ ? 'Enter newline · Ctrl+d submit · Esc cancel · Ctrl+u clear'
26265
+ : 'Enter submit · Esc cancel · Ctrl+u clear';
24231
26266
  return h(Box, {
24232
26267
  borderColor: focusBorderColor(theme, focused),
24233
26268
  borderStyle: theme.borderStyle,
24234
26269
  flexDirection: 'column',
24235
26270
  width,
24236
26271
  paddingX: 1,
24237
- }, h(Text, { bold: true }, panelTitle('Prompt', focused)), h(Text, { dimColor: true }, truncate$1(prompt.label, width - 4)), h(Text, undefined, ''), h(Text, {
24238
- bold: true,
24239
- color: theme.noColor ? undefined : theme.colors.accent,
24240
- }, truncate$1(`${prompt.value}_`, width - 4)), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Enter to submit · Esc to cancel · Ctrl+u to clear'));
26272
+ }, 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));
24241
26273
  }
24242
26274
  function renderConfirmationPanel(h, components, state, width, theme, focused) {
24243
26275
  const { Box, Text } = components;
@@ -24411,16 +26443,31 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
24411
26443
  ? [h(Text, { key: 'palette-recent-hint', dimColor: true }, '· marks recently-used')]
24412
26444
  : []), ...itemLines);
24413
26445
  }
24414
- function renderFooter(h, components, state, theme, idleTip) {
26446
+ function renderFooter(h, components, state, context, theme, idleTip) {
24415
26447
  const { Box, Text } = components;
26448
+ // Sidebar item count drives the per-tab footer hints — when items are
26449
+ // present the footer surfaces in-sidebar ops (checkout / apply / pop /
26450
+ // drop), otherwise it falls back to the generic "enter open" hint.
26451
+ const sidebarItemCount = (() => {
26452
+ switch (state.sidebarTab) {
26453
+ case 'branches': return context.branches?.localBranches.length;
26454
+ case 'tags': return context.tags?.tags.length;
26455
+ case 'stashes': return context.stashes?.stashes.length;
26456
+ case 'worktrees': return context.worktreeList?.worktrees.length;
26457
+ default: return undefined;
26458
+ }
26459
+ })();
24416
26460
  const hints = getLogInkFooterHints({
24417
26461
  activeView: state.activeView,
24418
26462
  diffSource: state.diffSource,
26463
+ diffViewMode: state.diffViewMode,
24419
26464
  filterMode: state.filterMode,
24420
26465
  focus: state.focus,
24421
26466
  pendingKey: state.pendingKey,
24422
26467
  showCommandPalette: state.showCommandPalette,
24423
26468
  showHelp: state.showHelp,
26469
+ sidebarTab: state.sidebarTab,
26470
+ sidebarItemCount,
24424
26471
  });
24425
26472
  // Real status messages always win; idle tips only fill the slot when it
24426
26473
  // would otherwise be empty.