git-coco 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.esm.mjs +2848 -354
  2. package/dist/index.js +2847 -353
  3. package/package.json +1 -1
@@ -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.39.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
  });
@@ -14439,84 +14747,6 @@ function formatInkRefLabels(refs) {
14439
14747
  return refs.length ? ` ${refs.map((ref) => `[${ref}]`).join(' ')}` : '';
14440
14748
  }
14441
14749
 
14442
- function countLabel(count, singular, plural = `${singular}s`) {
14443
- return `${count} ${count === 1 ? singular : plural}`;
14444
- }
14445
- function getLogInkWorkflowSections(context) {
14446
- const currentBranch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
14447
- const dirty = context.branches?.dirty ? 'dirty worktree' : 'clean worktree';
14448
- const loading = context.contextLoading;
14449
- const currentPullRequest = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
14450
- const repository = context.provider?.repository;
14451
- const repoName = repository?.owner && repository.name
14452
- ? `${repository.owner}/${repository.name}`
14453
- : repository?.message || 'local repository';
14454
- const operation = context.operation;
14455
- const worktree = context.worktree;
14456
- return [
14457
- {
14458
- title: 'Branch',
14459
- lines: [
14460
- `Current: ${currentBranch}`,
14461
- `State: ${dirty}`,
14462
- loading && !context.branches
14463
- ? 'Branch data loading'
14464
- : context.branches
14465
- ? `${countLabel(context.branches.localBranches.length, 'local branch', 'local branches')} | ${countLabel(context.branches.remoteBranches.length, 'remote branch', 'remote branches')}`
14466
- : 'Branch data unavailable',
14467
- ],
14468
- },
14469
- {
14470
- title: 'Provider / PR',
14471
- lines: [
14472
- `Repository: ${repoName}`,
14473
- loading && !context.provider && !context.pullRequest
14474
- ? 'Provider and pull request data loading'
14475
- : currentPullRequest
14476
- ? `PR #${currentPullRequest.number} ${currentPullRequest.state}${currentPullRequest.isDraft ? ' draft' : ''}`
14477
- : 'No pull request detected for current branch',
14478
- context.provider?.authenticated === false ? 'Provider auth: offline' : 'Provider auth: available',
14479
- ],
14480
- },
14481
- {
14482
- title: 'Status',
14483
- lines: loading && !worktree
14484
- ? ['Status data loading']
14485
- : worktree
14486
- ? [
14487
- `${countLabel(worktree.stagedCount, 'staged file')}`,
14488
- `${countLabel(worktree.unstagedCount, 'unstaged file')}`,
14489
- `${countLabel(worktree.untrackedCount, 'untracked file')}`,
14490
- ]
14491
- : ['Status data unavailable'],
14492
- },
14493
- {
14494
- title: 'Tags / Stashes / Worktrees',
14495
- lines: [
14496
- loading && !context.tags ? 'Tags loading' : context.tags ? countLabel(context.tags.tags.length, 'tag') : 'Tags unavailable',
14497
- loading && !context.stashes ? 'Stashes loading' : context.stashes ? countLabel(context.stashes.stashes.length, 'stash', 'stashes') : 'Stashes unavailable',
14498
- context.worktreeList
14499
- ? countLabel(context.worktreeList.worktrees.length, 'worktree')
14500
- : loading
14501
- ? 'Worktrees loading'
14502
- : 'Worktrees unavailable',
14503
- ],
14504
- },
14505
- {
14506
- title: 'Operation / AI',
14507
- lines: [
14508
- loading && !operation
14509
- ? 'Operation data loading'
14510
- : operation?.operation
14511
- ? `${operation.operation} in progress with ${countLabel(operation.conflictedFiles.length, 'conflict')}`
14512
- : 'No merge, rebase, cherry-pick, or revert in progress',
14513
- context.selectedCommit
14514
- ? `AI actions target ${context.selectedCommit.shortHash}; estimates shown before execution`
14515
- : 'AI actions require a selected commit',
14516
- ],
14517
- },
14518
- ];
14519
- }
14520
14750
  function getLogInkWorkflowActions() {
14521
14751
  return [
14522
14752
  {
@@ -14570,6 +14800,34 @@ function getLogInkWorkflowActions() {
14570
14800
  kind: 'destructive',
14571
14801
  requiresConfirmation: true,
14572
14802
  },
14803
+ {
14804
+ // Per-view-only: scoped to commit-diff and stash-diff explores in
14805
+ // inkInput (key: H). The action is non-destructive in the sense
14806
+ // that `git apply` won't lose any data — `git apply -R` undoes
14807
+ // it cleanly — so it bypasses the y-confirm path. The patch text
14808
+ // travels via the action's `payload` field. Empty key keeps the
14809
+ // workflow palette-discoverable without registering a global
14810
+ // hotkey (the palette path can't synthesize the patch text and
14811
+ // surfaces a hint instead — actual dispatch is from H in diff
14812
+ // view).
14813
+ id: 'apply-hunk-worktree',
14814
+ key: '',
14815
+ label: 'Apply hunk to worktree',
14816
+ description: 'Extract the hunk under the cursor and apply it to the working tree via `git apply`.',
14817
+ kind: 'normal',
14818
+ requiresConfirmation: false,
14819
+ },
14820
+ {
14821
+ // Sibling of `apply-hunk-worktree` — same extraction path, but
14822
+ // `git apply --cached` so the patch lands in the index without
14823
+ // touching the worktree. Bound to the `gH` chord in inkInput.
14824
+ id: 'apply-hunk-index',
14825
+ key: '',
14826
+ label: 'Apply hunk to index',
14827
+ description: 'Extract the hunk under the cursor and apply it to the index via `git apply --cached`.',
14828
+ kind: 'normal',
14829
+ requiresConfirmation: false,
14830
+ },
14573
14831
  {
14574
14832
  id: 'open-pr',
14575
14833
  key: 'O',
@@ -14662,6 +14920,152 @@ function getLogInkWorkflowActions() {
14662
14920
  kind: 'destructive',
14663
14921
  requiresConfirmation: true,
14664
14922
  },
14923
+ // #783 — full PR action panel. All five entries are palette-only
14924
+ // (`key: ''`) — actual dispatch is per-view scoped in inkInput so
14925
+ // the keys stay free outside the pull-request view. Merge / close /
14926
+ // approve / request-changes route through the y-confirm path
14927
+ // because each is irreversible (or near-irreversible) once gh
14928
+ // publishes it; comment is a free-form prompt with no extra
14929
+ // confirmation since the body itself is the affirmative action.
14930
+ {
14931
+ id: 'merge-pr',
14932
+ key: '',
14933
+ label: 'Merge pull request',
14934
+ description: 'Merge the current branch\'s pull request (prompts for merge / squash / rebase, then confirms).',
14935
+ kind: 'destructive',
14936
+ requiresConfirmation: true,
14937
+ },
14938
+ {
14939
+ id: 'close-pr',
14940
+ key: '',
14941
+ label: 'Close pull request',
14942
+ description: 'Close the current pull request without merging.',
14943
+ kind: 'destructive',
14944
+ requiresConfirmation: true,
14945
+ },
14946
+ {
14947
+ id: 'approve-pr',
14948
+ key: '',
14949
+ label: 'Approve pull request',
14950
+ description: 'Submit an approving review on the current pull request.',
14951
+ kind: 'normal',
14952
+ requiresConfirmation: true,
14953
+ },
14954
+ {
14955
+ id: 'request-changes-pr',
14956
+ key: '',
14957
+ label: 'Request changes on pull request',
14958
+ description: 'Submit a change-request review (prompts for the review body, then confirms).',
14959
+ kind: 'normal',
14960
+ requiresConfirmation: true,
14961
+ },
14962
+ {
14963
+ id: 'comment-pr',
14964
+ key: '',
14965
+ label: 'Comment on pull request',
14966
+ description: 'Add a comment to the current pull request (prompts for body).',
14967
+ kind: 'normal',
14968
+ requiresConfirmation: false,
14969
+ },
14970
+ {
14971
+ // Per-view-only: scoped to the history view in inkInput so `R`
14972
+ // doesn't fire elsewhere (it's also `R` for rename in branches
14973
+ // and delete-remote-tag in tags). Empty key keeps it
14974
+ // palette-discoverable without registering a global hotkey.
14975
+ id: 'revert-commit',
14976
+ key: '',
14977
+ label: 'Revert commit',
14978
+ description: 'Revert the cursored commit by adding an inverse commit on top of HEAD.',
14979
+ kind: 'destructive',
14980
+ requiresConfirmation: true,
14981
+ },
14982
+ {
14983
+ // Per-view-only: scoped to the history view in inkInput. Triggers
14984
+ // a mode prompt (soft / mixed / hard) before the reset runs so
14985
+ // `Z` alone never silently rewrites history.
14986
+ id: 'reset-to-commit',
14987
+ key: '',
14988
+ label: 'Reset to commit',
14989
+ description: 'Move the current branch tip to the cursored commit (prompts for soft / mixed / hard).',
14990
+ kind: 'destructive',
14991
+ requiresConfirmation: true,
14992
+ },
14993
+ {
14994
+ // Per-view-only: scoped to the history view in inkInput (key `B`).
14995
+ // The prompt itself is the affirmative gate — the user has to
14996
+ // type a branch name before anything happens — so this skips the
14997
+ // y-confirm path. Empty key keeps it palette-discoverable; the
14998
+ // palette path can't synthesize a branch name and surfaces a
14999
+ // hint instead.
15000
+ //
15001
+ // Distinct from `create-branch` (palette / `+` on branches view),
15002
+ // which uses `git switch -c` and switches onto the new branch.
15003
+ // This workflow uses `git branch <name> <sha>` and stays put —
15004
+ // GitKraken's "create branch here" semantic.
15005
+ id: 'create-branch-here',
15006
+ key: '',
15007
+ label: 'Create branch from commit',
15008
+ description: 'Create a branch pointed at the cursored commit (does not switch).',
15009
+ kind: 'normal',
15010
+ requiresConfirmation: false,
15011
+ },
15012
+ {
15013
+ // Per-view-only: scoped to the history view in inkInput via the
15014
+ // `gT` chord (bare `T` is taken by delete-tag on the tags view).
15015
+ // Same prompt-as-confirmation pattern as create-branch-here.
15016
+ // Lightweight tag — annotated tags remain available through the
15017
+ // existing `+` flow on the tags view.
15018
+ id: 'create-tag-here',
15019
+ key: '',
15020
+ label: 'Create tag at commit',
15021
+ description: 'Create a lightweight tag at the cursored commit.',
15022
+ kind: 'normal',
15023
+ requiresConfirmation: false,
15024
+ },
15025
+ {
15026
+ // Per-view-only: scoped to the history view in inkInput. `i`
15027
+ // (lowercase) is used instead of `I` so the existing `I`
15028
+ // ai-commit-summary workflow stays reachable on the history
15029
+ // view — `i` matches the `git rebase -i` flag mnemonic anyway.
15030
+ id: 'interactive-rebase',
15031
+ key: '',
15032
+ label: 'Interactive rebase',
15033
+ description: 'Start an interactive rebase from the cursored commit (opens $GIT_EDITOR for the todo list).',
15034
+ kind: 'destructive',
15035
+ requiresConfirmation: true,
15036
+ },
15037
+ {
15038
+ // Per-view-only: scoped to the history view in inkInput (key `B`).
15039
+ // The prompt itself is the affirmative gate — the user has to
15040
+ // type a branch name before anything happens — so this skips the
15041
+ // y-confirm path. Empty key keeps it palette-discoverable; the
15042
+ // palette path can't synthesize a branch name and surfaces a
15043
+ // hint instead.
15044
+ //
15045
+ // Distinct from `create-branch` (palette / `+` on branches view),
15046
+ // which uses `git switch -c` and switches onto the new branch.
15047
+ // This workflow uses `git branch <name> <sha>` and stays put —
15048
+ // GitKraken's "create branch here" semantic.
15049
+ id: 'create-branch-here',
15050
+ key: '',
15051
+ label: 'Create branch from commit',
15052
+ description: 'Create a branch pointed at the cursored commit (does not switch).',
15053
+ kind: 'normal',
15054
+ requiresConfirmation: false,
15055
+ },
15056
+ {
15057
+ // Per-view-only: scoped to the history view in inkInput via the
15058
+ // `gT` chord (bare `T` is taken by delete-tag on the tags view).
15059
+ // Same prompt-as-confirmation pattern as create-branch-here.
15060
+ // Lightweight tag — annotated tags remain available through the
15061
+ // existing `+` flow on the tags view.
15062
+ id: 'create-tag-here',
15063
+ key: '',
15064
+ label: 'Create tag at commit',
15065
+ description: 'Create a lightweight tag at the cursored commit.',
15066
+ kind: 'normal',
15067
+ requiresConfirmation: false,
15068
+ },
14665
15069
  {
14666
15070
  id: 'ai-commit-summary',
14667
15071
  key: 'I',
@@ -14683,6 +15087,15 @@ function getLogInkWorkflowActions() {
14683
15087
  ];
14684
15088
  }
14685
15089
  function getLogInkWorkflowActionByKey(inputValue) {
15090
+ // Workflow actions with an empty `key` are palette-only — they
15091
+ // exist so the command palette can surface them but should never
15092
+ // match a raw keystroke. Without this guard, any unbound key
15093
+ // (left/right arrow, function keys) that arrives with an empty
15094
+ // inputValue would `find()` the first empty-key entry —
15095
+ // `cherry-pick-commit` — and pop its confirmation dialog.
15096
+ if (!inputValue) {
15097
+ return undefined;
15098
+ }
14686
15099
  return getLogInkWorkflowActions().find((action) => action.key === inputValue);
14687
15100
  }
14688
15101
  function getLogInkWorkflowActionById(id) {
@@ -14812,6 +15225,13 @@ const LOG_INK_KEY_BINDINGS = [
14812
15225
  description: 'Toggle compact and full graph display.',
14813
15226
  contexts: ['normal', 'commits'],
14814
15227
  },
15228
+ {
15229
+ id: 'toggleDiffViewMode',
15230
+ keys: ['d'],
15231
+ label: 'split/unified',
15232
+ description: 'Toggle the diff view between unified and side-by-side split rendering. Falls back to unified on narrow terminals.',
15233
+ contexts: ['commits'],
15234
+ },
14815
15235
  {
14816
15236
  id: 'navigateHome',
14817
15237
  keys: ['gh'],
@@ -14868,6 +15288,13 @@ const LOG_INK_KEY_BINDINGS = [
14868
15288
  description: 'Push the linked worktrees view.',
14869
15289
  contexts: ['normal'],
14870
15290
  },
15291
+ {
15292
+ id: 'navigatePullRequest',
15293
+ keys: ['gp'],
15294
+ label: 'pull request',
15295
+ description: 'Push the dedicated pull-request action panel for the current branch.',
15296
+ contexts: ['normal'],
15297
+ },
14871
15298
  {
14872
15299
  id: 'navigateBack',
14873
15300
  keys: ['<', 'esc'],
@@ -14996,6 +15423,7 @@ const GLOBAL_BINDING_IDS = [
14996
15423
  'navigateTags',
14997
15424
  'navigateStash',
14998
15425
  'navigateWorktrees',
15426
+ 'navigatePullRequest',
14999
15427
  'navigateBack',
15000
15428
  ];
15001
15429
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -15056,8 +15484,37 @@ function getLogInkFooterHints(options) {
15056
15484
  };
15057
15485
  }
15058
15486
  if (options.focus === 'sidebar') {
15487
+ // Per-tab hints when the active tab has selectable items — the user
15488
+ // can act on the cursored entity without leaving the workstation
15489
+ // view. Status tab + empty content tabs fall back to the generic
15490
+ // "enter open" hint that drills into the dedicated view.
15491
+ const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
15492
+ if (itemsPresent && options.sidebarTab === 'branches') {
15493
+ return {
15494
+ contextual: ['↑/↓ branches', '←/→ tab', 'enter checkout', 'D delete', 'R rename', 'u upstream'],
15495
+ global: NORMAL_GLOBAL_HINTS,
15496
+ };
15497
+ }
15498
+ if (itemsPresent && options.sidebarTab === 'stashes') {
15499
+ return {
15500
+ contextual: ['↑/↓ stashes', '←/→ tab', 'enter diff', 'a apply', 'p pop', 'X drop'],
15501
+ global: NORMAL_GLOBAL_HINTS,
15502
+ };
15503
+ }
15504
+ if (itemsPresent && options.sidebarTab === 'tags') {
15505
+ return {
15506
+ contextual: ['↑/↓ tags', '←/→ tab', '+ new', 'P push', 'T delete'],
15507
+ global: NORMAL_GLOBAL_HINTS,
15508
+ };
15509
+ }
15510
+ if (itemsPresent && options.sidebarTab === 'worktrees') {
15511
+ return {
15512
+ contextual: ['↑/↓ worktrees', '←/→ tab', 'W remove'],
15513
+ global: NORMAL_GLOBAL_HINTS,
15514
+ };
15515
+ }
15059
15516
  return {
15060
- contextual: ['[/] tab', '1-5 jump', 'tab focus'],
15517
+ contextual: ['←/→ tab', '1-5 jump', 'enter open', 'tab focus'],
15061
15518
  global: NORMAL_GLOBAL_HINTS,
15062
15519
  };
15063
15520
  }
@@ -15074,17 +15531,23 @@ function getLogInkFooterHints(options) {
15074
15531
  };
15075
15532
  }
15076
15533
  if (options.activeView === 'diff') {
15534
+ // Surface what `d` will switch *to* — labels the next mode rather
15535
+ // than the current one so the hint reads as a verb. The split-mode
15536
+ // hint is only shown for the read-only diff sources (commit/stash);
15537
+ // the worktree diff stays unified-only for now.
15538
+ const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
15077
15539
  if (options.diffSource === 'stash') {
15078
15540
  return {
15079
- contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'o edit', 'y yank', 'esc back'],
15541
+ contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', 'o edit', splitToggleHint, 'y yank', 'esc back'],
15080
15542
  global: NORMAL_GLOBAL_HINTS,
15081
15543
  };
15082
15544
  }
15083
15545
  if (options.diffSource === 'commit') {
15084
15546
  // Commit-diff explore: read-only diff, but `c` cherry-picks the
15085
- // cursored file from the commit into the worktree.
15547
+ // cursored file from the commit into the worktree, and `H`
15548
+ // (or `gH` for index) applies just the cursored hunk.
15086
15549
  return {
15087
- contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'y/Y yank', 'esc back'],
15550
+ contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'y/Y yank', 'esc back'],
15088
15551
  global: NORMAL_GLOBAL_HINTS,
15089
15552
  };
15090
15553
  }
@@ -15123,8 +15586,26 @@ function getLogInkFooterHints(options) {
15123
15586
  global: NORMAL_GLOBAL_HINTS,
15124
15587
  };
15125
15588
  }
15589
+ if (options.activeView === 'pull-request') {
15590
+ return {
15591
+ // #783 — full PR action panel. Five mutating ops scoped to this
15592
+ // view: m / x / a / R / c, plus O for open-in-browser (already
15593
+ // a global). Each routes through y-confirm or an input prompt;
15594
+ // none fire silently.
15595
+ contextual: ['m merge', 'x close', 'a approve', 'R changes', 'c comment', 'O open', 'esc back'],
15596
+ global: NORMAL_GLOBAL_HINTS,
15597
+ };
15598
+ }
15126
15599
  return {
15127
- contextual: ['↑/↓ move', 'enter diff', 'c cherry-pick', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15600
+ // History view default hints. Mutating ops (`c` cherry-pick, `R`
15601
+ // revert, `Z` reset, `i` interactive-rebase) all route through a
15602
+ // y-confirm or mode prompt — none fire silently from the keystroke.
15603
+ // `B` create-branch-here and `gT` create-tag-here use a prompt as
15604
+ // the affirmative gate (typing the name is the confirmation).
15605
+ // Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
15606
+ // the footer stays scannable; full descriptions live in `?` help
15607
+ // and the palette.
15608
+ contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15128
15609
  global: NORMAL_GLOBAL_HINTS,
15129
15610
  };
15130
15611
  }
@@ -15267,39 +15748,6 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
15267
15748
  .map((entry) => entry.command);
15268
15749
  }
15269
15750
 
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
15751
  /**
15304
15752
  * OSC 8 hyperlink helpers for the Ink TUI (P5.1).
15305
15753
  *
@@ -15371,6 +15819,84 @@ function formatHyperlink(text, url, env = process.env) {
15371
15819
  return `${OSC_PREFIX}${url}${ST}${text}${OSC_PREFIX}${ST}`;
15372
15820
  }
15373
15821
 
15822
+ /**
15823
+ * Extract a single hunk from a unified-patch diff so it can be fed to
15824
+ * `git apply` (or `git apply --cached`) for a hunk-level cherry-pick.
15825
+ *
15826
+ * The TUI's diff explore views render two flavors of patch text:
15827
+ *
15828
+ * - stash-diff: full `git stash show -p` output, which includes
15829
+ * `diff --git`, `---`, `+++`, and one or more `@@ ... @@` hunks
15830
+ * per file.
15831
+ * - commit-diff: the per-file `filePreview.hunks` array, which is
15832
+ * hunks-only (no `diff --git` / `---` / `+++` headers).
15833
+ *
15834
+ * Either way, this helper walks `lines` from `cursorOffset` backwards
15835
+ * to find the most recent `@@` header, walks forward to the end of
15836
+ * that hunk's body, and synthesizes a fresh `diff --git` /
15837
+ * `---` / `+++` set using the caller-provided path. The output is a
15838
+ * complete, self-contained patch suitable for `git apply` without
15839
+ * having to preserve original headers from `lines`.
15840
+ */
15841
+ const HUNK_HEADER_PREFIX = '@@';
15842
+ const DIFF_GIT_PREFIX = 'diff --git ';
15843
+ /**
15844
+ * Find the index of the `@@` hunk header at or before `cursorOffset`.
15845
+ * Returns -1 when the cursor sits before the first hunk in the patch
15846
+ * (i.e. on a `diff --git` / `---` / `+++` header line) — caller treats
15847
+ * that as "no hunk at cursor" and surfaces a status message.
15848
+ */
15849
+ function findHunkHeaderAtOrBefore(lines, cursorOffset) {
15850
+ const start = Math.min(cursorOffset, lines.length - 1);
15851
+ for (let i = start; i >= 0; i -= 1) {
15852
+ if (lines[i]?.startsWith(HUNK_HEADER_PREFIX)) {
15853
+ return i;
15854
+ }
15855
+ }
15856
+ return -1;
15857
+ }
15858
+ /**
15859
+ * Walk forward from a hunk header to either the next `@@` header or
15860
+ * the next `diff --git` line — that's where this hunk's body ends.
15861
+ * The end index is exclusive (the line at `endIndex` is NOT part of
15862
+ * this hunk).
15863
+ */
15864
+ function findHunkBodyEnd(lines, headerIndex) {
15865
+ for (let i = headerIndex + 1; i < lines.length; i += 1) {
15866
+ const line = lines[i];
15867
+ if (line?.startsWith(HUNK_HEADER_PREFIX) || line?.startsWith(DIFF_GIT_PREFIX)) {
15868
+ return i;
15869
+ }
15870
+ }
15871
+ return lines.length;
15872
+ }
15873
+ function extractDiffHunk(input) {
15874
+ const { lines, cursorOffset, path } = input;
15875
+ if (!lines.length || !path) {
15876
+ return null;
15877
+ }
15878
+ const headerIndex = findHunkHeaderAtOrBefore(lines, cursorOffset);
15879
+ if (headerIndex < 0) {
15880
+ return null;
15881
+ }
15882
+ const bodyEnd = findHunkBodyEnd(lines, headerIndex);
15883
+ // Header itself + at least one body line. An empty hunk body would
15884
+ // mean the patch is malformed and `git apply` would reject it; bail
15885
+ // out early so the caller can surface a clear status message.
15886
+ if (bodyEnd <= headerIndex + 1) {
15887
+ return null;
15888
+ }
15889
+ const hunkLines = lines.slice(headerIndex, bodyEnd);
15890
+ const patchText = [
15891
+ `diff --git a/${path} b/${path}`,
15892
+ `--- a/${path}`,
15893
+ `+++ b/${path}`,
15894
+ ...hunkLines,
15895
+ '',
15896
+ ].join('\n');
15897
+ return { patchText };
15898
+ }
15899
+
15374
15900
  /**
15375
15901
  * Sort modes for the promoted views (P4.2).
15376
15902
  *
@@ -15390,23 +15916,31 @@ function cycleBranchSort(mode) {
15390
15916
  return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
15391
15917
  }
15392
15918
  function sortBranches(branches, mode) {
15393
- const copy = branches.slice();
15919
+ // Pin the current branch at index 0 regardless of sort mode (#806
15920
+ // follow-up). Lands the user's cursor on the active branch by
15921
+ // default and keeps the most-relevant row glued to the top of the
15922
+ // list as they cycle sorts.
15923
+ const current = branches.find((entry) => entry.current);
15924
+ const rest = branches.filter((entry) => !entry.current);
15925
+ const sortedRest = rest.slice();
15394
15926
  switch (mode) {
15395
15927
  case 'name':
15396
- return copy.sort((a, b) => a.shortName.localeCompare(b.shortName));
15928
+ sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
15929
+ break;
15397
15930
  case 'recent':
15398
15931
  // ISO-shaped dates compare byte-for-byte; descending so the freshest
15399
15932
  // branch sits at the top.
15400
- return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
15933
+ sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
15401
15934
  a.shortName.localeCompare(b.shortName));
15935
+ break;
15402
15936
  case 'ahead':
15403
15937
  // ahead-first; ties broken by behind, then by name. Keeps "this branch
15404
15938
  // has unmerged work" in the user's first scroll.
15405
- return copy.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
15939
+ sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
15406
15940
  a.shortName.localeCompare(b.shortName));
15407
- default:
15408
- return copy;
15941
+ break;
15409
15942
  }
15943
+ return current ? [current, ...sortedRest] : sortedRest;
15410
15944
  }
15411
15945
  const TAG_SORT_MODES = ['recent', 'name'];
15412
15946
  const DEFAULT_TAG_SORT_MODE = 'recent';
@@ -15740,6 +16274,8 @@ function createLogInkState(rows, options = {}) {
15740
16274
  sidebarTab: 'status',
15741
16275
  userSidebarTab: 'status',
15742
16276
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
16277
+ diffViewMode: 'unified',
16278
+ inspectorTab: 'inspector',
15743
16279
  };
15744
16280
  }
15745
16281
  function getSelectedInkCommit(state) {
@@ -15797,6 +16333,28 @@ function applyLogInkAction(state, action) {
15797
16333
  pendingCommitFocused: false,
15798
16334
  pendingKey: undefined,
15799
16335
  };
16336
+ case 'selectCommitByHash': {
16337
+ // Locates a commit by its full or short hash within the active
16338
+ // filtered list and snaps the cursor to it. Used by the
16339
+ // branch/tag auto-jump effect (#806 follow-up): cursoring a
16340
+ // branch in the sidebar tracks the history view to that
16341
+ // branch's tip without the user manually scrolling. No-op when
16342
+ // the hash isn't in the loaded list (the runtime surfaces a
16343
+ // status hint in that case).
16344
+ const target = action.hash;
16345
+ const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
16346
+ if (index < 0) {
16347
+ return state;
16348
+ }
16349
+ return {
16350
+ ...state,
16351
+ selectedIndex: index,
16352
+ selectedFileIndex: 0,
16353
+ diffPreviewOffset: 0,
16354
+ pendingCommitFocused: false,
16355
+ pendingKey: undefined,
16356
+ };
16357
+ }
15800
16358
  case 'focusPendingCommit':
15801
16359
  return {
15802
16360
  ...state,
@@ -15836,6 +16394,34 @@ function applyLogInkAction(state, action) {
15836
16394
  selectedBranchIndex: clampIndex$1(state.selectedBranchIndex + action.delta, action.count),
15837
16395
  pendingKey: undefined,
15838
16396
  };
16397
+ case 'resetBranchSelection':
16398
+ // Snap the branches sidebar / view cursor back to position 0.
16399
+ // Used after a successful checkout (#806 follow-up): combined
16400
+ // with the "current branch pinned at top" rule from #809, this
16401
+ // lands the user's cursor on the just-checked-out branch.
16402
+ return {
16403
+ ...state,
16404
+ selectedBranchIndex: 0,
16405
+ pendingKey: undefined,
16406
+ };
16407
+ case 'setInspectorTab':
16408
+ return {
16409
+ ...state,
16410
+ inspectorTab: action.value,
16411
+ pendingKey: undefined,
16412
+ };
16413
+ case 'cycleInspectorTab': {
16414
+ // Two-tab toggle — `delta` is symmetrical so direction does not
16415
+ // matter, but we keep the action shape consistent with the
16416
+ // sidebar's `nextSidebarTab` / `previousSidebarTab` so callers
16417
+ // can mirror the sidebar pattern verbatim.
16418
+ const next = state.inspectorTab === 'inspector' ? 'actions' : 'inspector';
16419
+ return {
16420
+ ...state,
16421
+ inspectorTab: next,
16422
+ pendingKey: undefined,
16423
+ };
16424
+ }
15839
16425
  case 'moveTag':
15840
16426
  return {
15841
16427
  ...state,
@@ -15877,6 +16463,7 @@ function applyLogInkAction(state, action) {
15877
16463
  kind: action.kind,
15878
16464
  label: action.label,
15879
16465
  value: action.initial || '',
16466
+ multiline: action.multiline,
15880
16467
  },
15881
16468
  pendingKey: undefined,
15882
16469
  };
@@ -15909,6 +16496,27 @@ function applyLogInkAction(state, action) {
15909
16496
  }
15910
16497
  case 'setHistoryFetchArgs':
15911
16498
  return { ...state, historyFetchArgs: action.value, pendingKey: undefined };
16499
+ case 'toggleDiffViewMode':
16500
+ // Reset the scroll offsets so the new mode opens at the top — long
16501
+ // lines wrap differently in split mode (the renderer truncates per
16502
+ // column instead of per row), so the saved offset can land on a
16503
+ // different visual line. Snap to the top is simpler than mapping
16504
+ // unified offsets to split offsets.
16505
+ return {
16506
+ ...state,
16507
+ diffViewMode: state.diffViewMode === 'unified' ? 'split' : 'unified',
16508
+ diffPreviewOffset: 0,
16509
+ worktreeDiffOffset: 0,
16510
+ pendingKey: undefined,
16511
+ };
16512
+ case 'setDiffViewMode':
16513
+ return {
16514
+ ...state,
16515
+ diffViewMode: action.value,
16516
+ diffPreviewOffset: 0,
16517
+ worktreeDiffOffset: 0,
16518
+ pendingKey: undefined,
16519
+ };
15912
16520
  case 'moveToBottom':
15913
16521
  return {
15914
16522
  ...state,
@@ -16187,12 +16795,151 @@ function applyLogInkAction(state, action) {
16187
16795
  }
16188
16796
  }
16189
16797
 
16798
+ /**
16799
+ * In-sidebar selection helpers (#791 follow-up — sidebar entity ops).
16800
+ *
16801
+ * The workstation sidebar's branches / tags / stashes / worktrees tabs
16802
+ * used to be read-only previews — to act on an entity the user had to
16803
+ * drill into the dedicated promoted view. With the per-entity ops
16804
+ * gated to also fire on `state.focus === 'sidebar'` plus a matching
16805
+ * `sidebarTab`, j/k navigates the visible list inside the sidebar
16806
+ * itself, Enter performs the primary action (checkout / open diff),
16807
+ * and the existing per-view secondary keys (a/p/X/D/R/u/+P) are now
16808
+ * reachable without leaving the workstation view.
16809
+ *
16810
+ * The sidebar accordion is short — the visible window for an active
16811
+ * tab is capped (defaults below) so a long branch list doesn't
16812
+ * collapse the rest of the chrome. When the cursor scrolls past the
16813
+ * visible window, this module produces a sliding window that keeps it
16814
+ * in view; the dedicated view stays the right home for "show me all
16815
+ * 80 branches at once."
16816
+ */
16817
+ const DEFAULT_SIDEBAR_VISIBLE = 8;
16818
+ /**
16819
+ * Compute the sliding window so that `selected` stays inside it while
16820
+ * the window remains anchored at the top whenever possible (so short
16821
+ * lists don't scroll for no reason). When the cursor moves past the
16822
+ * window, the window slides just enough to keep the cursor in view —
16823
+ * matching the commit history's `clampWindowStart` behaviour for
16824
+ * familiarity.
16825
+ */
16826
+ function getSidebarVisibleWindow(total, selected, visible = DEFAULT_SIDEBAR_VISIBLE) {
16827
+ const size = Math.max(1, Math.min(visible, total));
16828
+ if (total <= visible) {
16829
+ return { start: 0, size, truncatedAbove: 0, truncatedBelow: 0 };
16830
+ }
16831
+ const half = Math.floor(size / 2);
16832
+ const idealStart = selected - half;
16833
+ const maxStart = total - size;
16834
+ const start = Math.max(0, Math.min(idealStart, maxStart));
16835
+ return {
16836
+ start,
16837
+ size,
16838
+ truncatedAbove: start,
16839
+ truncatedBelow: total - (start + size),
16840
+ };
16841
+ }
16842
+ /**
16843
+ * True when an in-sidebar action (j/k move, Enter checkout, etc.)
16844
+ * should fire instead of the generic drill-in / tab-cycle behaviour.
16845
+ *
16846
+ * Status tab is excluded because its preview shows worktree files —
16847
+ * those have their own selection model in the dedicated status view
16848
+ * and the sidebar doesn't surface them as selectable rows.
16849
+ */
16850
+ function sidebarTabHasSelectableItems(sidebarTab, itemCount) {
16851
+ if (!itemCount || itemCount <= 0)
16852
+ return false;
16853
+ return sidebarTab === 'branches' ||
16854
+ sidebarTab === 'tags' ||
16855
+ sidebarTab === 'stashes' ||
16856
+ sidebarTab === 'worktrees';
16857
+ }
16858
+
16190
16859
  function action(actionValue) {
16191
16860
  return {
16192
16861
  type: 'action',
16193
16862
  action: actionValue,
16194
16863
  };
16195
16864
  }
16865
+ /**
16866
+ * Build the events needed to apply the hunk under the diff cursor. The
16867
+ * runtime workflow handler expects payload format `<target>\n<patch>`
16868
+ * — splitting on the first newline keeps the patch body intact for
16869
+ * targets like `worktree` and `index` (no newlines in the prefix).
16870
+ *
16871
+ * Returns [] when the user isn't on a commit-diff / stash-diff explore,
16872
+ * or when no hunk can be extracted at the current cursor offset
16873
+ * (e.g. cursor sits on a `diff --git` header before the first `@@`).
16874
+ * Callers fall back to a contextual status message when this returns [].
16875
+ */
16876
+ function buildApplyHunkEvents(state, context, target) {
16877
+ if (state.activeView !== 'diff')
16878
+ return [];
16879
+ if (state.diffSource !== 'commit' && state.diffSource !== 'stash')
16880
+ return [];
16881
+ const lines = context.diffLinesForHunkApply;
16882
+ if (!lines || lines.length === 0)
16883
+ return [];
16884
+ const path = state.diffSource === 'stash'
16885
+ ? context.stashDiffSelectedPath
16886
+ : context.commitDiffSelectedPath;
16887
+ if (!path)
16888
+ return [];
16889
+ const extracted = extractDiffHunk({
16890
+ lines,
16891
+ cursorOffset: state.diffPreviewOffset,
16892
+ path,
16893
+ });
16894
+ if (!extracted)
16895
+ return [];
16896
+ const id = target === 'index' ? 'apply-hunk-index' : 'apply-hunk-worktree';
16897
+ return [{
16898
+ type: 'runWorkflowAction',
16899
+ id,
16900
+ payload: `${target}\n${extracted.patchText}`,
16901
+ }];
16902
+ }
16903
+ /**
16904
+ * Per-entity action-target predicates. The promoted views (`branches`,
16905
+ * `tags`, `stash`, `worktrees`) each scope a set of ops to their
16906
+ * dedicated surface. The same ops also fire when the user has the
16907
+ * sidebar focused on the matching tab — that's how in-sidebar
16908
+ * selection (#791 follow-up) lets the user checkout / apply / drop
16909
+ * without leaving the workstation view.
16910
+ */
16911
+ function isBranchActionTarget(state) {
16912
+ return (state.activeView === 'branches' && state.focus === 'commits') ||
16913
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
16914
+ }
16915
+ function isTagActionTarget(state) {
16916
+ return (state.activeView === 'tags' && state.focus === 'commits') ||
16917
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
16918
+ }
16919
+ function isStashActionTarget(state) {
16920
+ return (state.activeView === 'stash' && state.focus === 'commits') ||
16921
+ (state.focus === 'sidebar' && state.sidebarTab === 'stashes');
16922
+ }
16923
+ function isWorktreeActionTarget(state) {
16924
+ return (state.activeView === 'worktrees' && state.focus === 'commits') ||
16925
+ (state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
16926
+ }
16927
+ /**
16928
+ * Item count for the active sidebar tab — used by the generic
16929
+ * sidebar-Enter handler to decide whether to defer to the per-entity
16930
+ * Enter (when items are present and the user is cursoring through
16931
+ * them) or to drill into the dedicated view (when the tab is empty
16932
+ * or has no per-entity Enter handler defined).
16933
+ */
16934
+ function getSidebarItemCount(sidebarTab, context) {
16935
+ switch (sidebarTab) {
16936
+ case 'branches': return context.branchCount;
16937
+ case 'tags': return context.tagCount;
16938
+ case 'stashes': return context.stashCount;
16939
+ case 'worktrees': return context.worktreeListCount;
16940
+ default: return undefined;
16941
+ }
16942
+ }
16196
16943
  /**
16197
16944
  * Translate a palette command into the same events its keystroke would have
16198
16945
  * produced. Phase 6 makes `:` a real launcher: this is the single mapping
@@ -16272,6 +17019,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
16272
17019
  return [action({ type: 'pushView', value: 'stash' })];
16273
17020
  case 'navigateWorktrees':
16274
17021
  return [action({ type: 'pushView', value: 'worktrees' })];
17022
+ case 'navigatePullRequest':
17023
+ return [action({ type: 'pushView', value: 'pull-request' })];
16275
17024
  case 'navigateBack':
16276
17025
  return [action({ type: 'popView' })];
16277
17026
  case 'openSelected': {
@@ -16329,10 +17078,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
16329
17078
  case 'clearSearch':
16330
17079
  return [action({ type: 'clearFilter' })];
16331
17080
  case 'cycleSort':
16332
- if (state.activeView === 'branches') {
17081
+ if (isBranchActionTarget(state)) {
16333
17082
  return [action({ type: 'cycleBranchSort' })];
16334
17083
  }
16335
- if (state.activeView === 'tags') {
17084
+ if (isTagActionTarget(state)) {
16336
17085
  return [action({ type: 'cycleTagSort' })];
16337
17086
  }
16338
17087
  return [action({
@@ -16369,6 +17118,77 @@ function hasUnsavedComposeDraft(state) {
16369
17118
  }
16370
17119
  return Boolean(compose.summary.trim() || compose.body.trim());
16371
17120
  }
17121
+ /**
17122
+ * Submit the active input prompt — used by Enter on single-line
17123
+ * prompts and by Ctrl+D on multi-line prompts (#806). Most prompt
17124
+ * kinds dispatch a workflow whose id matches the kind
17125
+ * (`create-branch`, `rename-branch`, etc.). A few are exceptions:
17126
+ * - `reset-mode` (#777) collects soft/mixed/hard and forwards the
17127
+ * mode as the payload to `reset-to-commit`.
17128
+ * - `pr-merge-strategy` (#783) validates the strategy and routes to
17129
+ * `merge-pr` via the y-confirm path.
17130
+ * - `pr-comment` dispatches `comment-pr` directly — the body itself
17131
+ * is the affirmative action.
17132
+ * - `pr-request-changes` routes to `request-changes-pr` via
17133
+ * y-confirm because the review is publicly visible.
17134
+ * Each exception validates here so a typo doesn't surface as a
17135
+ * "workflow not yet wired" status downstream.
17136
+ *
17137
+ * Empty values yield a hint instead of a no-op so the user knows what
17138
+ * to do — the same UX whether they pressed Enter (single-line) or
17139
+ * Ctrl+D (multi-line).
17140
+ */
17141
+ function submitInputPrompt(state) {
17142
+ if (!state.inputPrompt)
17143
+ return [];
17144
+ const value = state.inputPrompt.value.trim();
17145
+ if (!value) {
17146
+ return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
17147
+ }
17148
+ if (state.inputPrompt.kind === 'reset-mode') {
17149
+ const mode = value.toLowerCase();
17150
+ if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
17151
+ return [action({
17152
+ type: 'setStatus',
17153
+ value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
17154
+ })];
17155
+ }
17156
+ return [
17157
+ { type: 'runWorkflowAction', id: 'reset-to-commit', payload: mode },
17158
+ action({ type: 'closeInputPrompt' }),
17159
+ ];
17160
+ }
17161
+ if (state.inputPrompt.kind === 'pr-merge-strategy') {
17162
+ const strategy = value.toLowerCase();
17163
+ if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
17164
+ return [action({
17165
+ type: 'setStatus',
17166
+ value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
17167
+ })];
17168
+ }
17169
+ return [
17170
+ action({ type: 'setPendingConfirmation', value: 'merge-pr', payload: strategy }),
17171
+ action({ type: 'closeInputPrompt' }),
17172
+ ];
17173
+ }
17174
+ if (state.inputPrompt.kind === 'pr-comment') {
17175
+ return [
17176
+ { type: 'runWorkflowAction', id: 'comment-pr', payload: value },
17177
+ action({ type: 'closeInputPrompt' }),
17178
+ ];
17179
+ }
17180
+ if (state.inputPrompt.kind === 'pr-request-changes') {
17181
+ return [
17182
+ action({ type: 'setPendingConfirmation', value: 'request-changes-pr', payload: value }),
17183
+ action({ type: 'closeInputPrompt' }),
17184
+ ];
17185
+ }
17186
+ const id = state.inputPrompt.kind;
17187
+ return [
17188
+ { type: 'runWorkflowAction', id, payload: value },
17189
+ action({ type: 'closeInputPrompt' }),
17190
+ ];
17191
+ }
16372
17192
  function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16373
17193
  if (key.ctrl && inputValue === 'c') {
16374
17194
  if (hasUnsavedComposeDraft(state) && !state.pendingMutationConfirmation) {
@@ -16381,22 +17201,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16381
17201
  // filter/confirmation/compose handlers so a prompt opened from inside
16382
17202
  // any of those still captures focus cleanly.
16383
17203
  if (state.inputPrompt) {
17204
+ const isMultiline = Boolean(state.inputPrompt.multiline);
16384
17205
  if (key.escape) {
16385
17206
  return [
16386
17207
  action({ type: 'closeInputPrompt' }),
16387
17208
  action({ type: 'setStatus', value: 'cancelled' }),
16388
17209
  ];
16389
17210
  }
17211
+ // Multi-line prompts (#806): Ctrl+D submits (Unix EOF convention,
17212
+ // mirrors `git commit -m -` and HEREDOC patterns). Plain Enter
17213
+ // inserts a newline so the user can compose review bodies / PR
17214
+ // comments naturally without opening $EDITOR.
17215
+ if (isMultiline && key.ctrl && inputValue === 'd') {
17216
+ return submitInputPrompt(state);
17217
+ }
17218
+ if (isMultiline && key.return) {
17219
+ return [action({ type: 'appendInputPrompt', value: '\n' })];
17220
+ }
16390
17221
  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
- ];
17222
+ return submitInputPrompt(state);
16400
17223
  }
16401
17224
  if (key.backspace || key.delete) {
16402
17225
  return [action({ type: 'backspaceInputPrompt' })];
@@ -16658,6 +17481,56 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16658
17481
  action({ type: 'setStatus', value: 'jumped to worktrees' }),
16659
17482
  ];
16660
17483
  }
17484
+ // `gp` jumps to the dedicated pull-request action panel (#783).
17485
+ // Lowercase `p` matches the pattern of other navigation chords
17486
+ // (gh / gs / gd / gc / gb / gt / gz / gw). The panel renders the
17487
+ // current branch's PR via `gh pr view --json` enriched fields and
17488
+ // exposes m / x / a / R / c action keys scoped to the view.
17489
+ if (state.pendingKey === 'g' && inputValue === 'p') {
17490
+ return [
17491
+ action({ type: 'pushView', value: 'pull-request' }),
17492
+ action({ type: 'setStatus', value: 'jumped to pull request' }),
17493
+ ];
17494
+ }
17495
+ // `gH` chord: apply the cursored hunk to the index (`git apply
17496
+ // --cached`). Sibling of bare `H` which targets the worktree.
17497
+ // Discoverable via the footer hint on diff views and the help
17498
+ // overlay; the explicit chord keeps `H` (single keystroke) for
17499
+ // the more common worktree case.
17500
+ if (state.pendingKey === 'g' && inputValue === 'H') {
17501
+ const events = buildApplyHunkEvents(state, context, 'index');
17502
+ if (events.length) {
17503
+ return [action({ type: 'setPendingKey', value: undefined }), ...events];
17504
+ }
17505
+ return [
17506
+ action({ type: 'setPendingKey', value: undefined }),
17507
+ action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
17508
+ ];
17509
+ }
17510
+ // `gT` chord: create a lightweight tag at the cursored commit on the
17511
+ // history view. Bare `T` is taken (delete-tag on the tags view) so we
17512
+ // use the chord. Mirrors `gH` exactly — uppercase letter after the
17513
+ // `g` chord prefix, distinct from the lowercase `gt` chord which
17514
+ // jumps to the tags view. The prompt is the affirmative gate.
17515
+ if (state.pendingKey === 'g' && inputValue === 'T') {
17516
+ if (state.activeView === 'history' &&
17517
+ state.focus === 'commits' &&
17518
+ state.filteredCommits.length > 0 &&
17519
+ !state.pendingCommitFocused) {
17520
+ return [
17521
+ action({ type: 'setPendingKey', value: undefined }),
17522
+ action({
17523
+ type: 'openInputPrompt',
17524
+ kind: 'create-tag-here',
17525
+ label: 'New tag name (at cursored commit)',
17526
+ }),
17527
+ ];
17528
+ }
17529
+ return [
17530
+ action({ type: 'setPendingKey', value: undefined }),
17531
+ action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
17532
+ ];
17533
+ }
16661
17534
  if (inputValue === 'g') {
16662
17535
  if (state.pendingKey === 'g') {
16663
17536
  return [
@@ -16667,6 +17540,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16667
17540
  }
16668
17541
  return [action({ type: 'setPendingKey', value: 'g' })];
16669
17542
  }
17543
+ // `d` on the diff view toggles between unified and side-by-side split
17544
+ // rendering (#785). Scoped to the diff view so the letter stays free
17545
+ // for other surfaces. The chord branch above already claimed `gd`,
17546
+ // so by the time we get here `pendingKey` is not `g`.
17547
+ if (inputValue === 'd' && state.activeView === 'diff') {
17548
+ const next = state.diffViewMode === 'unified' ? 'split' : 'unified';
17549
+ return [
17550
+ action({ type: 'toggleDiffViewMode' }),
17551
+ action({
17552
+ type: 'setStatus',
17553
+ value: next === 'split'
17554
+ ? 'Switched to side-by-side diff'
17555
+ : 'Switched to unified diff',
17556
+ }),
17557
+ ];
17558
+ }
16670
17559
  if (inputValue === '\\') {
16671
17560
  return [action({ type: 'toggleGraph' })];
16672
17561
  }
@@ -16689,10 +17578,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16689
17578
  return [{ type: 'refreshContext' }];
16690
17579
  }
16691
17580
  if (inputValue === 's') {
16692
- if (state.activeView === 'branches') {
17581
+ if (isBranchActionTarget(state)) {
16693
17582
  return [action({ type: 'cycleBranchSort' })];
16694
17583
  }
16695
- if (state.activeView === 'tags') {
17584
+ if (isTagActionTarget(state)) {
16696
17585
  return [action({ type: 'cycleTagSort' })];
16697
17586
  }
16698
17587
  // Falls through so other views (history/status/diff/compose/stash) still
@@ -16723,6 +17612,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16723
17612
  hunkOffsets: context.commitDiffHunkOffsets,
16724
17613
  })];
16725
17614
  }
17615
+ // Inspector focused: cycle the inspector tab. The renderer only
17616
+ // honors the tab field on short terminals (where the inspector
17617
+ // collapses into a tabbed layout), but we let the user pre-set
17618
+ // their preference on tall terminals too.
17619
+ if (state.focus === 'detail') {
17620
+ return [action({ type: 'cycleInspectorTab', delta: -1 })];
17621
+ }
16726
17622
  return [action({ type: 'previousSidebarTab' })];
16727
17623
  }
16728
17624
  if (inputValue === ']') {
@@ -16747,6 +17643,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16747
17643
  hunkOffsets: context.commitDiffHunkOffsets,
16748
17644
  })];
16749
17645
  }
17646
+ if (state.focus === 'detail') {
17647
+ return [action({ type: 'cycleInspectorTab', delta: 1 })];
17648
+ }
16750
17649
  return [action({ type: 'nextSidebarTab' })];
16751
17650
  }
16752
17651
  // Status surface intercepts 1/2/3 before the sidebar-tab numeric
@@ -16763,6 +17662,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16763
17662
  if (key.tab) {
16764
17663
  return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
16765
17664
  }
17665
+ // ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
17666
+ // Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
17667
+ // vertical axis (↑/↓ below) is "within the active tab's items".
17668
+ // [/] still works as a keyboard alternative for users who prefer
17669
+ // non-arrow keys.
17670
+ if (key.leftArrow && state.focus === 'sidebar') {
17671
+ return [action({ type: 'previousSidebarTab' })];
17672
+ }
17673
+ if (key.rightArrow && state.focus === 'sidebar') {
17674
+ return [action({ type: 'nextSidebarTab' })];
17675
+ }
16766
17676
  if (key.upArrow || inputValue === 'k') {
16767
17677
  if (state.focus === 'detail' && context.detailFileCount) {
16768
17678
  return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
@@ -16792,16 +17702,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16792
17702
  previewLineCount: context.previewLineCount,
16793
17703
  })];
16794
17704
  }
16795
- if (state.activeView === 'branches' && context.branchCount) {
17705
+ if (isBranchActionTarget(state) && context.branchCount) {
16796
17706
  return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
16797
17707
  }
16798
- if (state.activeView === 'tags' && context.tagCount) {
17708
+ if (isTagActionTarget(state) && context.tagCount) {
16799
17709
  return [action({ type: 'moveTag', delta: -1, count: context.tagCount })];
16800
17710
  }
16801
- if (state.activeView === 'stash' && context.stashCount) {
17711
+ if (isStashActionTarget(state) && context.stashCount) {
16802
17712
  return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
16803
17713
  }
16804
- if (state.activeView === 'worktrees' && context.worktreeListCount) {
17714
+ if (isWorktreeActionTarget(state) && context.worktreeListCount) {
16805
17715
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
16806
17716
  }
16807
17717
  if (state.activeView === 'history' &&
@@ -16811,6 +17721,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16811
17721
  context.worktreeDirty) {
16812
17722
  return [action({ type: 'focusPendingCommit' })];
16813
17723
  }
17724
+ // Sidebar fallback: when no entity claim above succeeds (status
17725
+ // tab or empty content tab), ↑ falls through to cycling sidebar
17726
+ // tabs so the user always has a way to navigate. With ←/→ above
17727
+ // already handling tab switching, this is mostly a vim-style
17728
+ // safety net for `k`.
16814
17729
  return [
16815
17730
  action(state.focus === 'sidebar'
16816
17731
  ? { type: 'previousSidebarTab' }
@@ -16845,16 +17760,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16845
17760
  previewLineCount: context.previewLineCount,
16846
17761
  })];
16847
17762
  }
16848
- if (state.activeView === 'branches' && context.branchCount) {
17763
+ if (isBranchActionTarget(state) && context.branchCount) {
16849
17764
  return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
16850
17765
  }
16851
- if (state.activeView === 'tags' && context.tagCount) {
17766
+ if (isTagActionTarget(state) && context.tagCount) {
16852
17767
  return [action({ type: 'moveTag', delta: 1, count: context.tagCount })];
16853
17768
  }
16854
- if (state.activeView === 'stash' && context.stashCount) {
17769
+ if (isStashActionTarget(state) && context.stashCount) {
16855
17770
  return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
16856
17771
  }
16857
- if (state.activeView === 'worktrees' && context.worktreeListCount) {
17772
+ if (isWorktreeActionTarget(state) && context.worktreeListCount) {
16858
17773
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
16859
17774
  }
16860
17775
  return [
@@ -16958,30 +17873,42 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16958
17873
  }
16959
17874
  }
16960
17875
  // 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.
17876
+ // (status / branches / tags / stash) but only when the sidebar tab
17877
+ // either has no per-entity Enter handler defined (status, tags,
17878
+ // worktrees) or has zero items (so the dedicated view's empty-state
17879
+ // tells the user what to do next).
16965
17880
  //
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.
17881
+ // When the sidebar IS focused on a content tab WITH items, this
17882
+ // handler defers to the per-entity Enter below (checkout-branch for
17883
+ // branches, navigateOpenDiffForStash for stashes) so the user can
17884
+ // act on the cursored item without leaving the workstation view —
17885
+ // the in-sidebar selection win from #791 follow-up.
17886
+ //
17887
+ // The drill-in moves focus out of the sidebar into the newly opened
17888
+ // list — otherwise ↑/↓ keep navigating the sidebar instead of the
17889
+ // just-opened view, which made the drill-in feel half-done.
16969
17890
  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
- ];
17891
+ const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
17892
+ const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
17893
+ sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
17894
+ if (!hasInSidebarPrimaryAction) {
17895
+ const tabToView = {
17896
+ status: 'status',
17897
+ branches: 'branches',
17898
+ tags: 'tags',
17899
+ stashes: 'stash',
17900
+ worktrees: 'worktrees',
17901
+ };
17902
+ const target = tabToView[state.sidebarTab];
17903
+ if (target) {
17904
+ return [
17905
+ action({ type: 'pushView', value: target }),
17906
+ action({ type: 'setFocus', value: 'commits' }),
17907
+ ];
17908
+ }
17909
+ return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
16983
17910
  }
16984
- return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
17911
+ // Fall through per-entity Enter handler below claims the keystroke.
16985
17912
  }
16986
17913
  if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
16987
17914
  return [action({
@@ -16990,8 +17917,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
16990
17917
  })];
16991
17918
  }
16992
17919
  // 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) {
17920
+ // action — no confirmation prompt. Fires from either the dedicated
17921
+ // branches view or from the sidebar when the branches tab is focused
17922
+ // with items.
17923
+ if (key.return && isBranchActionTarget(state) && context.branchCount) {
16995
17924
  return [{ type: 'runWorkflowAction', id: 'checkout-branch' }];
16996
17925
  }
16997
17926
  // `+` opens a create-branch / create-tag prompt depending on context.
@@ -17018,32 +17947,33 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17018
17947
  }
17019
17948
  // Per-view stash actions: `a` apply (keep the stash), `p` pop (apply
17020
17949
  // 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) {
17950
+ // routes through the y-confirm path. Scoped to the stash target so
17951
+ // the letters stay free elsewhere — the target predicate also fires
17952
+ // when the sidebar's stashes tab is focused with items.
17953
+ if (inputValue === 'a' && isStashActionTarget(state) && context.stashCount) {
17024
17954
  return [{ type: 'runWorkflowAction', id: 'apply-stash' }];
17025
17955
  }
17026
- if (inputValue === 'p' && state.activeView === 'stash' && context.stashCount) {
17956
+ if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
17027
17957
  return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
17028
17958
  }
17029
17959
  // 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
17960
+ // is scoped to the tags target so it doesn't collide with `p` for
17031
17961
  // pop-stash. Note: this also takes precedence over the global
17032
17962
  // push-current-branch workflow's `P` key.
17033
- if (inputValue === 'P' && state.activeView === 'tags' && context.tagCount) {
17963
+ if (inputValue === 'P' && isTagActionTarget(state) && context.tagCount) {
17034
17964
  return [{ type: 'runWorkflowAction', id: 'push-tag' }];
17035
17965
  }
17036
17966
  // Per-view branches actions: `R` renames the selected branch, `u`
17037
17967
  // sets its upstream. Both open the input prompt so the user can type
17038
17968
  // the new value. Pre-fills are handled by the prompt's `initial`.
17039
- if (inputValue === 'R' && state.activeView === 'branches' && context.branchCount) {
17969
+ if (inputValue === 'R' && isBranchActionTarget(state) && context.branchCount) {
17040
17970
  return [action({
17041
17971
  type: 'openInputPrompt',
17042
17972
  kind: 'rename-branch',
17043
17973
  label: 'Rename branch to',
17044
17974
  })];
17045
17975
  }
17046
- if (inputValue === 'u' && state.activeView === 'branches' && context.branchCount) {
17976
+ if (inputValue === 'u' && isBranchActionTarget(state) && context.branchCount) {
17047
17977
  return [action({
17048
17978
  type: 'openInputPrompt',
17049
17979
  kind: 'set-upstream',
@@ -17051,11 +17981,48 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17051
17981
  })];
17052
17982
  }
17053
17983
  // 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) {
17984
+ // confirmation). Scoped per-target so this letter is free elsewhere
17985
+ // (especially the `R` rename binding on the branches target).
17986
+ if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
17057
17987
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
17058
17988
  }
17989
+ // #783 — full PR action panel keys, scoped to the pull-request view.
17990
+ // All five wrap a `gh pr <verb>` invocation; merge / request-changes /
17991
+ // comment open prompts first, the rest route through the y-confirm
17992
+ // path because they're irreversible (or near-irreversible).
17993
+ if (inputValue === 'm' && state.activeView === 'pull-request') {
17994
+ return [action({
17995
+ type: 'openInputPrompt',
17996
+ kind: 'pr-merge-strategy',
17997
+ label: 'Merge strategy (merge / squash / rebase)',
17998
+ })];
17999
+ }
18000
+ if (inputValue === 'x' && state.activeView === 'pull-request') {
18001
+ return [action({ type: 'setPendingConfirmation', value: 'close-pr' })];
18002
+ }
18003
+ if (inputValue === 'a' && state.activeView === 'pull-request') {
18004
+ return [action({ type: 'setPendingConfirmation', value: 'approve-pr' })];
18005
+ }
18006
+ if (inputValue === 'R' && state.activeView === 'pull-request') {
18007
+ // Free-form review body — multi-line so the reviewer can structure
18008
+ // their feedback naturally without opening $EDITOR (#806).
18009
+ return [action({
18010
+ type: 'openInputPrompt',
18011
+ kind: 'pr-request-changes',
18012
+ label: 'Request changes — review body (Enter newline · Ctrl+D submit)',
18013
+ multiline: true,
18014
+ })];
18015
+ }
18016
+ if (inputValue === 'c' && state.activeView === 'pull-request') {
18017
+ // Free-form comment body — multi-line for the same reason as
18018
+ // pr-request-changes.
18019
+ return [action({
18020
+ type: 'openInputPrompt',
18021
+ kind: 'pr-comment',
18022
+ label: 'Comment body (Enter newline · Ctrl+D submit)',
18023
+ multiline: true,
18024
+ })];
18025
+ }
17059
18026
  // Global stash hotkey: `S` opens a stash-message prompt and
17060
18027
  // `createStash` runs once submitted. Available everywhere there's
17061
18028
  // not a more modal handler in front of it.
@@ -17113,6 +18080,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17113
18080
  payload: `${context.commitDiffSelectedSha} ${context.commitDiffSelectedPath}`,
17114
18081
  })];
17115
18082
  }
18083
+ // `H` on a commit-diff or stash-diff explore extracts the hunk under
18084
+ // the cursor and applies it to the working tree (`git apply`). The
18085
+ // sibling `gH` chord targets the index (`git apply --cached`). Both
18086
+ // bypass the y-confirm path because `git apply` is non-destructive
18087
+ // (it'll fail loudly on conflict and `git apply -R` undoes a clean
18088
+ // apply).
18089
+ if (inputValue === 'H') {
18090
+ const events = buildApplyHunkEvents(state, context, 'worktree');
18091
+ if (events.length) {
18092
+ return events;
18093
+ }
18094
+ if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
18095
+ return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
18096
+ }
18097
+ }
17116
18098
  // `c` on the history view cherry-picks the full selected commit on
17117
18099
  // top of the current branch. Routed through the y-confirm flow since
17118
18100
  // it can produce conflicts and is a real working-tree mutation.
@@ -17123,6 +18105,62 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17123
18105
  !state.pendingCommitFocused) {
17124
18106
  return [action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' })];
17125
18107
  }
18108
+ // `R` reverts the cursored commit by adding an inverse commit on top
18109
+ // of HEAD. Same y-confirm gate as cherry-pick — non-rewriting but
18110
+ // still a real mutation.
18111
+ if (inputValue === 'R' &&
18112
+ state.activeView === 'history' &&
18113
+ state.focus === 'commits' &&
18114
+ state.filteredCommits.length > 0 &&
18115
+ !state.pendingCommitFocused) {
18116
+ return [action({ type: 'setPendingConfirmation', value: 'revert-commit' })];
18117
+ }
18118
+ // `Z` resets the current branch tip to the cursored commit. Opens a
18119
+ // mode prompt (soft / mixed / hard) instead of jumping straight to
18120
+ // confirmation because the choice changes the destructiveness
18121
+ // dramatically — `--hard` discards working-tree changes. The prompt
18122
+ // submission special-cases `kind === 'reset-mode'` to forward the
18123
+ // mode through `reset-to-commit` (see prompt-submit handler above).
18124
+ // No `initial` value: existing prompts append to initial rather than
18125
+ // replacing it, which would surprise the user typing the mode.
18126
+ if (inputValue === 'Z' &&
18127
+ state.activeView === 'history' &&
18128
+ state.focus === 'commits' &&
18129
+ state.filteredCommits.length > 0 &&
18130
+ !state.pendingCommitFocused) {
18131
+ return [action({
18132
+ type: 'openInputPrompt',
18133
+ kind: 'reset-mode',
18134
+ label: 'Reset mode (soft / mixed / hard)',
18135
+ })];
18136
+ }
18137
+ // `i` (lowercase) starts an interactive rebase from the cursored
18138
+ // commit's parent. Lowercase keeps the existing global `I`
18139
+ // ai-commit-summary workflow reachable on the history view; `i`
18140
+ // also matches the `git rebase -i` flag mnemonic.
18141
+ if (inputValue === 'i' &&
18142
+ state.activeView === 'history' &&
18143
+ state.focus === 'commits' &&
18144
+ state.filteredCommits.length > 0 &&
18145
+ !state.pendingCommitFocused) {
18146
+ return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
18147
+ }
18148
+ // `B` opens a create-branch prompt rooted at the cursored commit
18149
+ // (`git branch <name> <sha>` — does NOT switch to the new branch).
18150
+ // The prompt itself is the affirmative gate, so no separate y-confirm.
18151
+ // Bare uppercase `B` since the lowercase `b` is used by the `gb`
18152
+ // chord prefix and we want a single keystroke for this common op.
18153
+ if (inputValue === 'B' &&
18154
+ state.activeView === 'history' &&
18155
+ state.focus === 'commits' &&
18156
+ state.filteredCommits.length > 0 &&
18157
+ !state.pendingCommitFocused) {
18158
+ return [action({
18159
+ type: 'openInputPrompt',
18160
+ kind: 'create-branch-here',
18161
+ label: 'New branch name (at cursored commit)',
18162
+ })];
18163
+ }
17126
18164
  // `y` / `Y` yank the contextually relevant identifier from the active
17127
18165
  // view to the system clipboard:
17128
18166
  // history → cursored commit hash (Y for short hash)
@@ -17138,13 +18176,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17138
18176
  if (state.activeView === 'history' && state.filteredCommits.length > 0) {
17139
18177
  return [{ type: 'yankFromActiveView', short }];
17140
18178
  }
17141
- if (state.activeView === 'branches' && context.branchCount) {
18179
+ if (isBranchActionTarget(state) && context.branchCount) {
17142
18180
  return [{ type: 'yankFromActiveView' }];
17143
18181
  }
17144
- if (state.activeView === 'tags' && context.tagCount) {
18182
+ if (isTagActionTarget(state) && context.tagCount) {
17145
18183
  return [{ type: 'yankFromActiveView' }];
17146
18184
  }
17147
- if (state.activeView === 'stash' && context.stashCount && context.stashSelectedRef) {
18185
+ if (isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
17148
18186
  return [{ type: 'yankFromActiveView' }];
17149
18187
  }
17150
18188
  if (state.activeView === 'status' && context.worktreeSelectedPath) {
@@ -17162,8 +18200,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17162
18200
  // Enter on a stash row pushes the diff view scoped to that stash.
17163
18201
  // The runtime loads `git stash show -p <ref>` once the view is
17164
18202
  // 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) {
18203
+ // context lookup here. Fires from either the dedicated stash view or
18204
+ // from the sidebar when the stashes tab is focused with items.
18205
+ if (key.return && isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
17167
18206
  return [action({
17168
18207
  type: 'navigateOpenDiffForStash',
17169
18208
  ref: context.stashSelectedRef,
@@ -17224,6 +18263,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17224
18263
  * fall back to "already seen" so we never block startup.
17225
18264
  */
17226
18265
  const MARKER_BASENAME = 'onboarding.seen';
18266
+ function resolveCacheDir$2() {
18267
+ const xdg = process.env.XDG_CACHE_HOME;
18268
+ if (xdg && xdg.trim().length > 0) {
18269
+ return path$1.join(xdg, 'coco');
18270
+ }
18271
+ return path$1.join(os$1.homedir(), '.cache', 'coco');
18272
+ }
18273
+ function getOnboardingMarkerPath() {
18274
+ return path$1.join(resolveCacheDir$2(), MARKER_BASENAME);
18275
+ }
18276
+ function hasSeenOnboarding() {
18277
+ try {
18278
+ return fs$1.existsSync(getOnboardingMarkerPath());
18279
+ }
18280
+ catch {
18281
+ // If we can't even stat the path (sandboxed env, etc.), treat the
18282
+ // user as "seen" so we don't keep showing a panel they can never
18283
+ // dismiss persistently.
18284
+ return true;
18285
+ }
18286
+ }
18287
+ function markOnboardingSeen() {
18288
+ const markerPath = getOnboardingMarkerPath();
18289
+ try {
18290
+ fs$1.mkdirSync(path$1.dirname(markerPath), { recursive: true });
18291
+ fs$1.writeFileSync(markerPath, '');
18292
+ }
18293
+ catch {
18294
+ // Best-effort persistence; swallow.
18295
+ }
18296
+ }
18297
+
18298
+ /**
18299
+ * Persist the user's preferred diff view mode (unified vs side-by-side
18300
+ * split — #785) per repo. Mirrors `inkSidebarPersistence.ts` so the
18301
+ * cache layout, error model, and key derivation stay consistent across
18302
+ * settings: best-effort, XDG-friendly, no PII in the cache filename.
18303
+ */
18304
+ const VALID_MODES = ['unified', 'split'];
17227
18305
  function resolveCacheDir$1() {
17228
18306
  const xdg = process.env.XDG_CACHE_HOME;
17229
18307
  if (xdg && xdg.trim().length > 0) {
@@ -17231,25 +18309,32 @@ function resolveCacheDir$1() {
17231
18309
  }
17232
18310
  return path$1.join(os$1.homedir(), '.cache', 'coco');
17233
18311
  }
17234
- function getOnboardingMarkerPath() {
17235
- return path$1.join(resolveCacheDir$1(), MARKER_BASENAME);
18312
+ function repoKey$1(repoPath) {
18313
+ // sha1 is used here as a non-security cache-key derivation — we just
18314
+ // need a deterministic short identifier for the marker filename. No
18315
+ // PII or auth context is hashed.
18316
+ // DevSkim: ignore DS126858
18317
+ return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
17236
18318
  }
17237
- function hasSeenOnboarding() {
18319
+ function getDiffViewModeMarkerPath(repoPath) {
18320
+ return path$1.join(resolveCacheDir$1(), `diff-view-mode.${repoKey$1(repoPath)}`);
18321
+ }
18322
+ function getSavedDiffViewMode(repoPath) {
17238
18323
  try {
17239
- return fs$1.existsSync(getOnboardingMarkerPath());
18324
+ const raw = fs$1.readFileSync(getDiffViewModeMarkerPath(repoPath), 'utf8').trim();
18325
+ return VALID_MODES.includes(raw)
18326
+ ? raw
18327
+ : undefined;
17240
18328
  }
17241
18329
  catch {
17242
- // If we can't even stat the path (sandboxed env, etc.), treat the
17243
- // user as "seen" so we don't keep showing a panel they can never
17244
- // dismiss persistently.
17245
- return true;
18330
+ return undefined;
17246
18331
  }
17247
18332
  }
17248
- function markOnboardingSeen() {
17249
- const markerPath = getOnboardingMarkerPath();
18333
+ function saveDiffViewMode(repoPath, mode) {
18334
+ const marker = getDiffViewModeMarkerPath(repoPath);
17250
18335
  try {
17251
- fs$1.mkdirSync(path$1.dirname(markerPath), { recursive: true });
17252
- fs$1.writeFileSync(markerPath, '');
18336
+ fs$1.mkdirSync(path$1.dirname(marker), { recursive: true });
18337
+ fs$1.writeFileSync(marker, mode);
17253
18338
  }
17254
18339
  catch {
17255
18340
  // Best-effort persistence; swallow.
@@ -17315,6 +18400,144 @@ function saveSidebarTab(repoPath, tab) {
17315
18400
  }
17316
18401
  }
17317
18402
 
18403
+ /**
18404
+ * Pair-alignment helper for the side-by-side diff view (#785).
18405
+ *
18406
+ * Takes the unified-diff line array that the renderer already paints (one
18407
+ * line per element, the leading character drives `+`/`-`/context coloring)
18408
+ * and re-shapes it into two-column rows the split renderer can lay out
18409
+ * without further parsing. Pure / synchronous so it can be exercised from
18410
+ * tests without spinning up Ink.
18411
+ *
18412
+ * Algorithm:
18413
+ * 1. Walk lines in order. `@@` headers seed a new hunk and reset the
18414
+ * `oldLineNo` / `newLineNo` cursors from the header range.
18415
+ * 2. Inside a hunk, group the consecutive runs of `-` and `+` lines that
18416
+ * follow each other. Each run of removals + the immediately-following
18417
+ * run of additions forms a "change block" that pairs up element-wise:
18418
+ * row[i] = { left: removals[i], right: additions[i] }. When one side
18419
+ * is shorter, pad with `kind: 'empty'` rows so the columns stay
18420
+ * aligned.
18421
+ * 3. Context lines emit as a paired row with the same text on both
18422
+ * sides and the synthesized line numbers from each cursor.
18423
+ * 4. Diff metadata (`diff `, `index `, `--- `, `+++ `, etc.) emit as
18424
+ * `kind: 'header'` rows so the split view still has a section break.
18425
+ * 5. A context line that interrupts a change block forces the in-flight
18426
+ * block to flush before the context row is emitted — pairs are never
18427
+ * drawn across context boundaries (matches lazygit / fugitive
18428
+ * behavior, and is what the issue specifies).
18429
+ *
18430
+ * Long lines are not wrapped here — the renderer truncates per column at
18431
+ * paint time so this helper stays pure and trivially testable.
18432
+ */
18433
+ const EMPTY_LEFT = { text: '', kind: 'empty' };
18434
+ const EMPTY_RIGHT = { text: '', kind: 'empty' };
18435
+ /**
18436
+ * Parse the start line numbers out of an `@@ -A,B +C,D @@` header. Returns
18437
+ * `[oldStart, newStart]`; either falls back to 1 when the header is
18438
+ * malformed (which only happens with synthetic / hand-crafted patches).
18439
+ */
18440
+ function parseHunkHeader(line) {
18441
+ const match = /@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(line);
18442
+ if (!match) {
18443
+ return [1, 1];
18444
+ }
18445
+ return [Number(match[1]) || 1, Number(match[2]) || 1];
18446
+ }
18447
+ function isDiffHeader(line) {
18448
+ return (line.startsWith('diff ') ||
18449
+ line.startsWith('index ') ||
18450
+ line.startsWith('--- ') ||
18451
+ line.startsWith('+++ ') ||
18452
+ line.startsWith('similarity ') ||
18453
+ line.startsWith('rename ') ||
18454
+ line.startsWith('copy ') ||
18455
+ line.startsWith('new file ') ||
18456
+ line.startsWith('deleted file ') ||
18457
+ line.startsWith('old mode ') ||
18458
+ line.startsWith('new mode ') ||
18459
+ line.startsWith('Binary files '));
18460
+ }
18461
+ /**
18462
+ * Flush a pending change block (removals + additions accumulated from a
18463
+ * contiguous `-`/`+` run) into paired rows. Pads the shorter side with
18464
+ * empty placeholders so columns stay aligned.
18465
+ */
18466
+ function flushChangeBlock(removals, additions, rows) {
18467
+ const max = Math.max(removals.length, additions.length);
18468
+ for (let i = 0; i < max; i++) {
18469
+ const left = removals[i] || EMPTY_LEFT;
18470
+ const right = additions[i] || EMPTY_RIGHT;
18471
+ rows.push({ left, right });
18472
+ }
18473
+ removals.length = 0;
18474
+ additions.length = 0;
18475
+ }
18476
+ function buildSplitDiffRows(unifiedLines) {
18477
+ const rows = [];
18478
+ let oldLineNo = 0;
18479
+ let newLineNo = 0;
18480
+ let inHunk = false;
18481
+ const removals = [];
18482
+ const additions = [];
18483
+ const flushHeader = (text) => {
18484
+ flushChangeBlock(removals, additions, rows);
18485
+ rows.push({
18486
+ left: { text, kind: 'header' },
18487
+ right: { text, kind: 'header' },
18488
+ });
18489
+ };
18490
+ for (const raw of unifiedLines) {
18491
+ if (raw.startsWith('@@')) {
18492
+ flushChangeBlock(removals, additions, rows);
18493
+ const [oldStart, newStart] = parseHunkHeader(raw);
18494
+ oldLineNo = oldStart;
18495
+ newLineNo = newStart;
18496
+ inHunk = true;
18497
+ rows.push({
18498
+ left: { text: raw, kind: 'header' },
18499
+ right: { text: raw, kind: 'header' },
18500
+ });
18501
+ continue;
18502
+ }
18503
+ if (!inHunk || isDiffHeader(raw)) {
18504
+ flushHeader(raw);
18505
+ continue;
18506
+ }
18507
+ if (raw.startsWith('-')) {
18508
+ removals.push({
18509
+ text: raw.slice(1),
18510
+ lineNumber: oldLineNo,
18511
+ kind: 'remove',
18512
+ });
18513
+ oldLineNo += 1;
18514
+ continue;
18515
+ }
18516
+ if (raw.startsWith('+')) {
18517
+ additions.push({
18518
+ text: raw.slice(1),
18519
+ lineNumber: newLineNo,
18520
+ kind: 'add',
18521
+ });
18522
+ newLineNo += 1;
18523
+ continue;
18524
+ }
18525
+ // Context line (or `` marker, which we
18526
+ // treat like a context row so it lands on both sides — readers
18527
+ // expect to see it in either column).
18528
+ flushChangeBlock(removals, additions, rows);
18529
+ const text = raw.startsWith(' ') ? raw.slice(1) : raw;
18530
+ rows.push({
18531
+ left: { text, lineNumber: oldLineNo, kind: 'context' },
18532
+ right: { text, lineNumber: newLineNo, kind: 'context' },
18533
+ });
18534
+ oldLineNo += 1;
18535
+ newLineNo += 1;
18536
+ }
18537
+ flushChangeBlock(removals, additions, rows);
18538
+ return rows;
18539
+ }
18540
+
17318
18541
  /**
17319
18542
  * Promoted-view selection rectification on filter changes (P4.5).
17320
18543
  *
@@ -17564,10 +18787,25 @@ const LOG_INK_MIN_COLUMNS = 80;
17564
18787
  const LOG_INK_MIN_ROWS = 24;
17565
18788
  const LOG_INK_DEFAULT_COLUMNS = 120;
17566
18789
  const LOG_INK_DEFAULT_ROWS = 40;
18790
+ /**
18791
+ * Terminal-row threshold below which the inspector switches to a
18792
+ * tabbed layout (commit-detail vs actions). Picked empirically: at
18793
+ * 28 rows the inspector's full stack (~30 rows when fully populated)
18794
+ * starts clipping the actions section; below that, the tabbed mode
18795
+ * gives both views their own air.
18796
+ */
18797
+ const INSPECTOR_TABBED_BELOW_ROWS = 28;
17567
18798
  function getLogInkLayout(input) {
17568
18799
  const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
17569
18800
  const rows = input.rows || LOG_INK_DEFAULT_ROWS;
17570
- const detailWidth = Math.max(30, Math.min(56, Math.floor(columns * 0.34)));
18801
+ // Inspector width at rest 20-32 cells (~22% of width), focused
18802
+ // 36-60 cells (~40% of width). Narrow rest state keeps the commit
18803
+ // graph dominant; focus expansion gives the inspector room for long
18804
+ // commit bodies / file lists / action labels. Mirrors the sidebar
18805
+ // pattern (sidebarFocused above): instant transition per render.
18806
+ const detailWidth = input.inspectorFocused
18807
+ ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
18808
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
17571
18809
  // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
17572
18810
  // (~36% of width). The transition is instant per render — focus tab to
17573
18811
  // expand, focus away to collapse.
@@ -17582,6 +18820,7 @@ function getLogInkLayout(input) {
17582
18820
  rows,
17583
18821
  sidebarWidth,
17584
18822
  tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
18823
+ inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
17585
18824
  };
17586
18825
  }
17587
18826
 
@@ -17736,7 +18975,8 @@ function createLogInkTheme(options = {}) {
17736
18975
  /**
17737
18976
  * Format a branch's relationship to its upstream.
17738
18977
  * - no upstream → "no upstream"
17739
- * - even → "even with <upstream>"
18978
+ * - even → "" (the boring default — keep the row tight; the row
18979
+ * marker already encodes "synced")
17740
18980
  * - divergent → "↑<ahead> ↓<behind> <upstream>" (only the non-zero side
17741
18981
  * is rendered so the line stays tight). ASCII mode falls back to the
17742
18982
  * legacy `+N/-N` form.
@@ -17746,7 +18986,7 @@ function formatBranchDivergence(branch, options = {}) {
17746
18986
  return 'no upstream';
17747
18987
  }
17748
18988
  if (branch.ahead === 0 && branch.behind === 0) {
17749
- return `even with ${branch.upstream}`;
18989
+ return '';
17750
18990
  }
17751
18991
  if (options.ascii) {
17752
18992
  return `+${branch.ahead}/-${branch.behind} ${branch.upstream}`;
@@ -17760,14 +19000,76 @@ function formatBranchDivergence(branch, options = {}) {
17760
19000
  }
17761
19001
  /**
17762
19002
  * Single-cell marker shown to the left of a branch name in lists.
17763
- * `*` = current, `◌` = no upstream (detached from a remote), space otherwise.
19003
+ *
19004
+ * - `*` — current branch (regardless of remote state)
19005
+ * - `◌` — no upstream
19006
+ * - `≡` — has upstream + synced (ahead === 0 && behind === 0)
19007
+ * - `↕` — has upstream + diverged (any non-zero ahead/behind)
19008
+ * - ` ` — fallback / no info
19009
+ *
19010
+ * ASCII fallbacks (legible without box-drawing/arrow glyphs):
19011
+ * - `?` for "no upstream", `=` for synced, `~` for diverged.
17764
19012
  */
17765
19013
  function branchRowMarker(branch, options = {}) {
17766
19014
  if (branch.current)
17767
19015
  return '*';
17768
19016
  if (!branch.upstream)
17769
19017
  return options.ascii ? '?' : '◌';
17770
- return ' ';
19018
+ const ahead = branch.ahead ?? 0;
19019
+ const behind = branch.behind ?? 0;
19020
+ if (ahead === 0 && behind === 0) {
19021
+ return options.ascii ? '=' : '≡';
19022
+ }
19023
+ return options.ascii ? '~' : '↕';
19024
+ }
19025
+ /**
19026
+ * Compact, human-friendly relative timestamp for the branch row.
19027
+ * Inputs:
19028
+ * - `iso` — committer-date in `YYYY-MM-DD` form (as produced by
19029
+ * `for-each-ref` with `committerdate:short`).
19030
+ * - `now` — reference instant; pass it explicitly so callers can pin it
19031
+ * for deterministic tests.
19032
+ *
19033
+ * Outputs (rounded toward the nearest unit):
19034
+ * - `today`, `1d ago`, `2d ago` … up to 13d
19035
+ * - `2w ago` … up to 8w
19036
+ * - `2mo ago` … up to 12mo
19037
+ * - `2y ago` for older
19038
+ * - `''` for malformed inputs (caller renders nothing).
19039
+ *
19040
+ * "in the future" inputs (clock skew, bad data) collapse to `today`.
19041
+ */
19042
+ function formatBranchLastTouched(iso, now) {
19043
+ if (!iso)
19044
+ return '';
19045
+ // Tolerate either `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS…` ISO strings.
19046
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
19047
+ if (!match)
19048
+ return '';
19049
+ const year = Number.parseInt(match[1], 10);
19050
+ const month = Number.parseInt(match[2], 10);
19051
+ const day = Number.parseInt(match[3], 10);
19052
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
19053
+ return '';
19054
+ // Compare at day granularity in UTC so a branch touched "yesterday"
19055
+ // never reads "today" depending on the operator's timezone.
19056
+ const branchUtc = Date.UTC(year, month - 1, day);
19057
+ const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
19058
+ const diffMs = nowUtc - branchUtc;
19059
+ const oneDay = 24 * 60 * 60 * 1000;
19060
+ const days = Math.floor(diffMs / oneDay);
19061
+ if (days <= 0)
19062
+ return 'today';
19063
+ if (days < 14)
19064
+ return `${days}d ago`;
19065
+ const weeks = Math.floor(days / 7);
19066
+ if (weeks < 9)
19067
+ return `${weeks}w ago`;
19068
+ const months = Math.floor(days / 30);
19069
+ if (months < 12)
19070
+ return `${months}mo ago`;
19071
+ const years = Math.floor(days / 365);
19072
+ return `${years}y ago`;
17771
19073
  }
17772
19074
  /**
17773
19075
  * Pick the glyph + color for a PR state badge.
@@ -18197,7 +19499,7 @@ function createChangelogArgv(input) {
18197
19499
  ...input,
18198
19500
  };
18199
19501
  }
18200
- function compactOutputLines$2(output) {
19502
+ function compactOutputLines$3(output) {
18201
19503
  return output
18202
19504
  .split('\n')
18203
19505
  .map((line) => line.trim())
@@ -18221,7 +19523,7 @@ async function captureStdout(action) {
18221
19523
  }
18222
19524
  }
18223
19525
  function formatCapturedAiOutput(output) {
18224
- const lines = compactOutputLines$2(output);
19526
+ const lines = compactOutputLines$3(output);
18225
19527
  const telemetry = lines.filter((line) => line.includes('[llm:summary]'));
18226
19528
  const content = lines.filter((line) => !line.includes('[llm]') && !line.includes('[llm:summary]'));
18227
19529
  const editable = content.join('\n');
@@ -18518,8 +19820,65 @@ function parsePullRequestInfo(output) {
18518
19820
  if (!trimmed) {
18519
19821
  return undefined;
18520
19822
  }
18521
- return JSON.parse(trimmed);
19823
+ const raw = JSON.parse(trimmed);
19824
+ const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
19825
+ ? String(raw.author.login)
19826
+ : undefined;
19827
+ return {
19828
+ number: raw.number,
19829
+ title: raw.title,
19830
+ url: raw.url,
19831
+ state: raw.state,
19832
+ isDraft: raw.isDraft,
19833
+ headRefName: raw.headRefName,
19834
+ baseRefName: raw.baseRefName,
19835
+ body: typeof raw.body === 'string' ? raw.body : undefined,
19836
+ author,
19837
+ reviewDecision: typeof raw.reviewDecision === 'string' ? raw.reviewDecision : undefined,
19838
+ mergeable: typeof raw.mergeable === 'string' ? raw.mergeable : undefined,
19839
+ mergeStateStatus: typeof raw.mergeStateStatus === 'string' ? raw.mergeStateStatus : undefined,
19840
+ statusCheckRollup: Array.isArray(raw.statusCheckRollup)
19841
+ ? raw.statusCheckRollup.map((entry) => ({
19842
+ name: String(entry.name || entry.context || 'check'),
19843
+ status: typeof entry.status === 'string' ? entry.status : undefined,
19844
+ conclusion: typeof entry.conclusion === 'string' ? entry.conclusion : undefined,
19845
+ }))
19846
+ : undefined,
19847
+ reviews: Array.isArray(raw.reviews)
19848
+ ? raw.reviews.map((entry) => {
19849
+ const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
19850
+ ? String(entry.author.login)
19851
+ : '';
19852
+ return {
19853
+ author,
19854
+ state: typeof entry.state === 'string' ? entry.state : '',
19855
+ };
19856
+ }).filter((review) => review.author)
19857
+ : undefined,
19858
+ };
18522
19859
  }
19860
+ /**
19861
+ * `gh pr view --json` field list. Centralized so the data fetcher and
19862
+ * any future re-fetch (e.g., refresh after a merge action) request the
19863
+ * same shape — the parser depends on every field being present, even
19864
+ * if optional, so they're safe to deserialize.
19865
+ */
19866
+ const PULL_REQUEST_VIEW_JSON_FIELDS = [
19867
+ 'number',
19868
+ 'title',
19869
+ 'url',
19870
+ 'state',
19871
+ 'isDraft',
19872
+ 'headRefName',
19873
+ 'baseRefName',
19874
+ 'body',
19875
+ 'author',
19876
+ 'reviewDecision',
19877
+ 'mergeable',
19878
+ 'mergeStateStatus',
19879
+ 'statusCheckRollup',
19880
+ 'reviews',
19881
+ ].join(',');
18523
19882
  async function getPullRequestOverview(git, runner = defaultGhRunner) {
18524
19883
  const [repository, currentBranchOutput] = await Promise.all([
18525
19884
  getGitHubRepository(git),
@@ -18551,7 +19910,7 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
18551
19910
  'pr',
18552
19911
  'view',
18553
19912
  '--json',
18554
- 'number,title,url,state,isDraft,headRefName,baseRefName',
19913
+ PULL_REQUEST_VIEW_JSON_FIELDS,
18555
19914
  ]);
18556
19915
  return {
18557
19916
  available: true,
@@ -18718,7 +20077,7 @@ function providerBranchName(branch) {
18718
20077
  return branch.shortName;
18719
20078
  }
18720
20079
 
18721
- function compactOutputLines$1(output) {
20080
+ function compactOutputLines$2(output) {
18722
20081
  return output
18723
20082
  .split('\n')
18724
20083
  .map((line) => line.trim())
@@ -18733,7 +20092,7 @@ async function runAction$5(action, successMessage) {
18733
20092
  };
18734
20093
  }
18735
20094
  catch (error) {
18736
- const lines = compactOutputLines$1(error.message);
20095
+ const lines = compactOutputLines$2(error.message);
18737
20096
  return {
18738
20097
  ok: false,
18739
20098
  message: lines[0] || 'History action failed.',
@@ -18875,7 +20234,7 @@ async function compareCommits(git, from, to) {
18875
20234
  }
18876
20235
  try {
18877
20236
  const output = await git.raw(['diff', '--stat', '--color=never', `${from.hash}..${to.hash}`]);
18878
- const lines = compactOutputLines$1(output);
20237
+ const lines = compactOutputLines$2(output);
18879
20238
  return {
18880
20239
  ok: true,
18881
20240
  message: `Compared ${from.shortHash}..${to.shortHash}`,
@@ -18957,6 +20316,63 @@ function resetToCommit(git, commit, mode) {
18957
20316
  : result.details,
18958
20317
  }));
18959
20318
  }
20319
+ /**
20320
+ * Create a new local branch pointed at <commit>, without switching to it.
20321
+ *
20322
+ * This is the "create branch from cursored commit" history action — the
20323
+ * user types the new branch name into an input prompt and we run
20324
+ * `git branch <name> <sha>` (NOT `git switch -c`, which is what
20325
+ * `branchActions.createBranch` does for the create-branch-at-HEAD flow).
20326
+ * The split exists because GitKraken-style "create branch here" is
20327
+ * specifically about marking a historical commit, not about switching
20328
+ * onto a new working branch.
20329
+ *
20330
+ * Note for the inspector follow-up: workflow surfacing is driven by the
20331
+ * registry in `inkWorkflows.ts`, not a hardcoded action list — adding
20332
+ * `create-branch-here` there is enough for the inspector / palette to
20333
+ * pick this up.
20334
+ */
20335
+ function createBranchFromCommit(git, name, commit) {
20336
+ const trimmedName = name.trim();
20337
+ if (!commit) {
20338
+ return Promise.resolve({
20339
+ ok: false,
20340
+ message: 'No commit selected.',
20341
+ });
20342
+ }
20343
+ if (!trimmedName) {
20344
+ return Promise.resolve({
20345
+ ok: false,
20346
+ message: 'Branch name required.',
20347
+ });
20348
+ }
20349
+ return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['branch', trimmedName, commit.hash]), `Created branch ${trimmedName} at ${commit.shortHash}`)));
20350
+ }
20351
+ /**
20352
+ * Create a lightweight tag pointed at <commit>.
20353
+ *
20354
+ * Mirrors `createBranchFromCommit` for the tag side: the user types a
20355
+ * tag name into an input prompt and we run `git tag <name> <sha>`
20356
+ * (lightweight, no `-a`/`-m`). Annotated tags remain available through
20357
+ * the existing `+` flow on the tags view; this is the per-commit
20358
+ * shortcut.
20359
+ */
20360
+ function createTagAtCommit(git, name, commit) {
20361
+ const trimmedName = name.trim();
20362
+ if (!commit) {
20363
+ return Promise.resolve({
20364
+ ok: false,
20365
+ message: 'No commit selected.',
20366
+ });
20367
+ }
20368
+ if (!trimmedName) {
20369
+ return Promise.resolve({
20370
+ ok: false,
20371
+ message: 'Tag name required.',
20372
+ });
20373
+ }
20374
+ return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['tag', trimmedName, commit.hash]), `Created tag ${trimmedName} at ${commit.shortHash}`)));
20375
+ }
18960
20376
  function startInteractiveRebase(git, commit) {
18961
20377
  if (!commit) {
18962
20378
  return Promise.resolve({
@@ -19042,6 +20458,61 @@ function createPullRequest(input, runner = defaultGhRunner) {
19042
20458
  };
19043
20459
  });
19044
20460
  }
20461
+ function isPullRequestMergeStrategy(value) {
20462
+ return value === 'merge' || value === 'squash' || value === 'rebase';
20463
+ }
20464
+ function buildMergePullRequestArgs(strategy) {
20465
+ // `--auto` and `--admin` are intentionally omitted — they're rarely
20466
+ // what a user wants from a TUI and require explicit gh auth scopes.
20467
+ // `--delete-branch` is opt-in via a future flag; default leaves the
20468
+ // branch in place so the user can verify before cleanup.
20469
+ return ['pr', 'merge', `--${strategy}`];
20470
+ }
20471
+ function mergePullRequest(strategy, runner = defaultGhRunner) {
20472
+ return runGhAction(runner, buildMergePullRequestArgs(strategy), (output) => ({
20473
+ ok: true,
20474
+ message: output.trim() || `Merged pull request with ${strategy}`,
20475
+ }));
20476
+ }
20477
+ function closePullRequest(runner = defaultGhRunner) {
20478
+ return runGhAction(runner, ['pr', 'close'], (output) => ({
20479
+ ok: true,
20480
+ message: output.trim() || 'Closed pull request',
20481
+ }));
20482
+ }
20483
+ /**
20484
+ * `gh pr review --approve` requires the user's gh auth to have scope
20485
+ * to write reviews — same scope that the in-browser approve button
20486
+ * uses. The runner surfaces auth failures via the standard error path.
20487
+ */
20488
+ function approvePullRequest(runner = defaultGhRunner) {
20489
+ return runGhAction(runner, ['pr', 'review', '--approve'], (output) => ({
20490
+ ok: true,
20491
+ message: output.trim() || 'Approved pull request',
20492
+ }));
20493
+ }
20494
+ /**
20495
+ * Request changes — `gh pr review` requires a body with this verb so
20496
+ * the empty-body case is rejected upstream by the input prompt.
20497
+ */
20498
+ function requestChangesPullRequest(body, runner = defaultGhRunner) {
20499
+ if (!body.trim()) {
20500
+ return Promise.resolve({ ok: false, message: 'Review body required for change-request' });
20501
+ }
20502
+ return runGhAction(runner, ['pr', 'review', '--request-changes', '--body', body], (output) => ({
20503
+ ok: true,
20504
+ message: output.trim() || 'Requested changes',
20505
+ }));
20506
+ }
20507
+ function commentPullRequest(body, runner = defaultGhRunner) {
20508
+ if (!body.trim()) {
20509
+ return Promise.resolve({ ok: false, message: 'Comment body required' });
20510
+ }
20511
+ return runGhAction(runner, ['pr', 'comment', '--body', body], (output) => ({
20512
+ ok: true,
20513
+ message: output.trim() || 'Comment added',
20514
+ }));
20515
+ }
19045
20516
 
19046
20517
  async function runAction$4(action, successMessage) {
19047
20518
  try {
@@ -19666,7 +21137,7 @@ function applyLogTuiAction(state, action) {
19666
21137
  }
19667
21138
  }
19668
21139
 
19669
- function compactOutputLines(output) {
21140
+ function compactOutputLines$1(output) {
19670
21141
  return output
19671
21142
  .split('\n')
19672
21143
  .map((line) => line.trim())
@@ -19681,7 +21152,7 @@ async function runAction(action, successMessage) {
19681
21152
  };
19682
21153
  }
19683
21154
  catch (error) {
19684
- const details = compactOutputLines(error.message);
21155
+ const details = compactOutputLines$1(error.message);
19685
21156
  return {
19686
21157
  ok: false,
19687
21158
  message: details[0] || 'Git operation action failed.',
@@ -21360,6 +22831,356 @@ async function startInteractiveLog(git, rows, streams = {}) {
21360
22831
  });
21361
22832
  }
21362
22833
 
22834
+ function compactOutputLines(output) {
22835
+ return output
22836
+ .split('\n')
22837
+ .map((line) => line.trim())
22838
+ .filter(Boolean);
22839
+ }
22840
+ async function safeUnlink(path) {
22841
+ try {
22842
+ await promises.unlink(path);
22843
+ }
22844
+ catch (error) {
22845
+ // ENOENT is fine — the temp file was never created or already
22846
+ // cleaned up. Anything else we silently swallow because the
22847
+ // worst-case impact is a single ~1KB file in $TMPDIR.
22848
+ error.code;
22849
+ }
22850
+ }
22851
+ /**
22852
+ * Write a unified-diff patch to a temp file and feed it to
22853
+ * `git apply` (or `git apply --cached` when target === 'index').
22854
+ *
22855
+ * This is the runner behind the `apply-hunk-worktree` /
22856
+ * `apply-hunk-index` workflow actions — the input handler builds
22857
+ * `patchText` from the cursored hunk via `extractDiffHunk` and the
22858
+ * runtime hands it here.
22859
+ *
22860
+ * `--whitespace=nowarn` keeps `git apply` quiet about trailing
22861
+ * whitespace differences (the most common false positive when the
22862
+ * patch comes from a stash made on a different platform). Real
22863
+ * conflicts still surface via the non-zero exit code.
22864
+ *
22865
+ * The patch is written to a temp file rather than piped on stdin
22866
+ * because some `simple-git` adapters don't expose a clean stdin
22867
+ * channel for `git.raw`; the tempfile path keeps the runner
22868
+ * portable across environments.
22869
+ */
22870
+ async function applyHunkPatch(git, patchText, options) {
22871
+ if (!patchText.trim()) {
22872
+ return {
22873
+ ok: false,
22874
+ message: 'No hunk under cursor to apply.',
22875
+ };
22876
+ }
22877
+ const targetLabel = options.target === 'index' ? 'index' : 'worktree';
22878
+ const tempPath = join(tmpdir(), `coco-hunk-${randomUUID()}.patch`);
22879
+ try {
22880
+ await promises.writeFile(tempPath, patchText, 'utf8');
22881
+ const args = ['apply'];
22882
+ if (options.target === 'index') {
22883
+ args.push('--cached');
22884
+ }
22885
+ args.push('--whitespace=nowarn');
22886
+ args.push(tempPath);
22887
+ try {
22888
+ await git.raw(args);
22889
+ return {
22890
+ ok: true,
22891
+ message: `Applied hunk to ${targetLabel}`,
22892
+ };
22893
+ }
22894
+ catch (error) {
22895
+ const lines = compactOutputLines(error.message);
22896
+ return {
22897
+ ok: false,
22898
+ message: lines[0] || `Failed to apply hunk to ${targetLabel}`,
22899
+ details: lines.slice(1, 6),
22900
+ };
22901
+ }
22902
+ }
22903
+ catch (error) {
22904
+ return {
22905
+ ok: false,
22906
+ message: `Could not stage hunk for apply: ${error.message}`,
22907
+ };
22908
+ }
22909
+ finally {
22910
+ await safeUnlink(tempPath);
22911
+ }
22912
+ }
22913
+
22914
+ function formatStashHeaderIdentity(ref, stashes) {
22915
+ if (!ref) {
22916
+ return { subtitle: 'no stash', bodyLine: 'Stash:' };
22917
+ }
22918
+ const index = stashes?.findIndex((entry) => entry.ref === ref) ?? -1;
22919
+ const entry = index >= 0 ? stashes[index] : undefined;
22920
+ if (!entry) {
22921
+ return {
22922
+ subtitle: ref,
22923
+ bodyLine: `Stash: ${ref}`,
22924
+ };
22925
+ }
22926
+ const onBranch = entry.branch && entry.branch !== '<unknown>' ? ` on ${entry.branch}` : '';
22927
+ const message = entry.message?.trim() || '(no message)';
22928
+ return {
22929
+ subtitle: `@{${index}} ${message}${onBranch}`,
22930
+ bodyLine: `Stash: ${ref}${onBranch} — ${message}`,
22931
+ };
22932
+ }
22933
+
22934
+ /**
22935
+ * Normalize gh's two parallel signals (`status` for in-flight check
22936
+ * runs, `conclusion` for completed runs and status contexts) into a
22937
+ * single status enum the renderer can map to a glyph + color.
22938
+ */
22939
+ function normalizePullRequestCheckStatus(check) {
22940
+ const status = (check.status || '').toUpperCase();
22941
+ const conclusion = (check.conclusion || '').toUpperCase();
22942
+ // In-flight check runs: gh emits `status: IN_PROGRESS|QUEUED` with
22943
+ // no conclusion yet. `PENDING` covers status-context runs that are
22944
+ // still waiting on a reporter.
22945
+ if (!conclusion && (status === 'IN_PROGRESS' || status === 'QUEUED' || status === 'PENDING')) {
22946
+ return 'pending';
22947
+ }
22948
+ switch (conclusion || status) {
22949
+ case 'SUCCESS':
22950
+ return 'success';
22951
+ case 'FAILURE':
22952
+ case 'ERROR':
22953
+ case 'TIMED_OUT':
22954
+ case 'ACTION_REQUIRED':
22955
+ return 'failure';
22956
+ case 'NEUTRAL':
22957
+ return 'neutral';
22958
+ case 'SKIPPED':
22959
+ case 'CANCELLED':
22960
+ return 'skipped';
22961
+ default:
22962
+ return 'pending';
22963
+ }
22964
+ }
22965
+ /**
22966
+ * Glyph for a normalized check status. ASCII fallbacks keep the panel
22967
+ * usable on legacy terminals where the geometric shapes block isn't
22968
+ * rendered.
22969
+ */
22970
+ function pullRequestCheckGlyph(status, options = {}) {
22971
+ if (options.ascii) {
22972
+ switch (status) {
22973
+ case 'success': return '+';
22974
+ case 'failure': return 'x';
22975
+ case 'pending': return '.';
22976
+ case 'neutral': return '-';
22977
+ case 'skipped': return '/';
22978
+ }
22979
+ }
22980
+ switch (status) {
22981
+ case 'success': return '✓';
22982
+ case 'failure': return '✗';
22983
+ case 'pending': return '◌';
22984
+ case 'neutral': return '○';
22985
+ case 'skipped': return '∼';
22986
+ }
22987
+ }
22988
+ function summarizePullRequestChecks(checks) {
22989
+ const summary = {
22990
+ total: 0, success: 0, failure: 0, pending: 0, neutral: 0, skipped: 0,
22991
+ };
22992
+ if (!checks)
22993
+ return summary;
22994
+ for (const check of checks) {
22995
+ summary.total += 1;
22996
+ summary[normalizePullRequestCheckStatus(check)] += 1;
22997
+ }
22998
+ return summary;
22999
+ }
23000
+ /**
23001
+ * One-line summary like `5 checks · 4 ✓ · 1 ◌` for the panel header.
23002
+ * Hides zero-count categories so the line stays scannable.
23003
+ */
23004
+ function formatPullRequestChecksSummary(summary, options = {}) {
23005
+ if (summary.total === 0) {
23006
+ return 'No status checks reported';
23007
+ }
23008
+ const parts = [`${summary.total} ${summary.total === 1 ? 'check' : 'checks'}`];
23009
+ const push = (count, status) => {
23010
+ if (count > 0)
23011
+ parts.push(`${count} ${pullRequestCheckGlyph(status, options)}`);
23012
+ };
23013
+ push(summary.success, 'success');
23014
+ push(summary.failure, 'failure');
23015
+ push(summary.pending, 'pending');
23016
+ push(summary.neutral, 'neutral');
23017
+ push(summary.skipped, 'skipped');
23018
+ return parts.join(' · ');
23019
+ }
23020
+ function buildPullRequestCheckRows(checks, options = {}) {
23021
+ if (!checks)
23022
+ return [];
23023
+ return checks.map((check) => {
23024
+ const status = normalizePullRequestCheckStatus(check);
23025
+ return {
23026
+ glyph: pullRequestCheckGlyph(status, options),
23027
+ name: check.name,
23028
+ status,
23029
+ detail: (check.conclusion || check.status || '').toLowerCase(),
23030
+ };
23031
+ });
23032
+ }
23033
+ function summarizePullRequestReviews(reviews, reviewDecision) {
23034
+ const summary = {
23035
+ total: 0, approved: 0, changesRequested: 0, commented: 0, dismissed: 0, pending: 0,
23036
+ decisionLabel: reviewDecision || undefined,
23037
+ };
23038
+ if (!reviews)
23039
+ return summary;
23040
+ for (const review of reviews) {
23041
+ summary.total += 1;
23042
+ switch (review.state.toUpperCase()) {
23043
+ case 'APPROVED':
23044
+ summary.approved += 1;
23045
+ break;
23046
+ case 'CHANGES_REQUESTED':
23047
+ summary.changesRequested += 1;
23048
+ break;
23049
+ case 'COMMENTED':
23050
+ summary.commented += 1;
23051
+ break;
23052
+ case 'DISMISSED':
23053
+ summary.dismissed += 1;
23054
+ break;
23055
+ case 'PENDING':
23056
+ summary.pending += 1;
23057
+ break;
23058
+ }
23059
+ }
23060
+ return summary;
23061
+ }
23062
+ function formatPullRequestReviewsSummary(summary) {
23063
+ const decision = summary.decisionLabel
23064
+ ? summary.decisionLabel.replace(/_/g, ' ').toLowerCase()
23065
+ : undefined;
23066
+ if (summary.total === 0) {
23067
+ return decision ? `No reviews · ${decision}` : 'No reviews submitted';
23068
+ }
23069
+ const parts = [`${summary.total} ${summary.total === 1 ? 'review' : 'reviews'}`];
23070
+ if (summary.approved > 0)
23071
+ parts.push(`${summary.approved} approved`);
23072
+ if (summary.changesRequested > 0)
23073
+ parts.push(`${summary.changesRequested} changes requested`);
23074
+ if (summary.commented > 0)
23075
+ parts.push(`${summary.commented} commented`);
23076
+ if (summary.pending > 0)
23077
+ parts.push(`${summary.pending} pending`);
23078
+ if (summary.dismissed > 0)
23079
+ parts.push(`${summary.dismissed} dismissed`);
23080
+ if (decision)
23081
+ parts.push(`decision: ${decision}`);
23082
+ return parts.join(' · ');
23083
+ }
23084
+ /**
23085
+ * One-line state badge for the header, e.g. `OPEN · draft` or `MERGED`.
23086
+ * Mergeable / merge-state is appended as a secondary chip when the PR
23087
+ * is open so the user sees `MERGEABLE` / `CONFLICTING` at a glance.
23088
+ */
23089
+ function formatPullRequestStateLine(pr) {
23090
+ const parts = [pr.state];
23091
+ if (pr.isDraft)
23092
+ parts.push('draft');
23093
+ if (pr.state === 'OPEN' && pr.mergeable) {
23094
+ parts.push(pr.mergeable.toLowerCase());
23095
+ }
23096
+ if (pr.state === 'OPEN' && pr.mergeStateStatus && pr.mergeStateStatus !== 'CLEAN') {
23097
+ parts.push(pr.mergeStateStatus.toLowerCase());
23098
+ }
23099
+ return parts.join(' · ');
23100
+ }
23101
+
23102
+ /**
23103
+ * Hardcoded per-entity action lists surfaced inside the right-hand
23104
+ * inspector panel. The inspector used to repeat the repo / branch /
23105
+ * status content the top header and left sidebar already show; we drop
23106
+ * that trailer in favor of an actionable cheat-sheet so the user knows
23107
+ * exactly which keystrokes apply to whatever they have under the cursor.
23108
+ *
23109
+ * Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
23110
+ * - Most per-entity actions live in `inkInput.ts` as direct keystroke
23111
+ * handlers (e.g. `c` cherry-pick, `R` revert) rather than as
23112
+ * globally-registered bindings, so the registry would be a partial
23113
+ * view at best.
23114
+ * - The bindings registry's `contexts` model (normal / search / focus
23115
+ * name) does not cleanly map to inspector entity types like "branch"
23116
+ * or "tag". Filtering it would mean replicating the same per-view
23117
+ * scoping logic the input dispatcher already encodes.
23118
+ * - New per-entity actions are added infrequently — the maintenance
23119
+ * cost of mirroring them here is low and keeps this file the single
23120
+ * source of truth for "what shows in the inspector".
23121
+ *
23122
+ * If you wire up a new per-entity keystroke in `inkInput.ts` — for
23123
+ * example a "create branch from this commit" or "create tag from this
23124
+ * commit" action — add the matching row to the relevant array below so
23125
+ * it shows up in the inspector automatically.
23126
+ */
23127
+ const HISTORY_COMMIT_ACTIONS = [
23128
+ { key: 'enter', label: 'Open diff' },
23129
+ { key: 'c', label: 'Cherry-pick' },
23130
+ { key: 'R', label: 'Revert', destructive: true },
23131
+ { key: 'Z', label: 'Reset to commit', destructive: true },
23132
+ { key: 'i', label: 'Interactive rebase', destructive: true },
23133
+ { key: 'y', label: 'Yank hash' },
23134
+ { key: 'Y', label: 'Yank short hash' },
23135
+ { key: 'O', label: 'Open in browser' },
23136
+ ];
23137
+ const BRANCH_ACTIONS = [
23138
+ { key: 'enter', label: 'Checkout' },
23139
+ { key: '+', label: 'New branch' },
23140
+ { key: 'R', label: 'Rename' },
23141
+ { key: 'u', label: 'Set upstream' },
23142
+ { key: 'D', label: 'Delete', destructive: true },
23143
+ { key: 'P', label: 'Push current' },
23144
+ { key: 'F', label: 'Fetch all' },
23145
+ { key: 'y', label: 'Yank name' },
23146
+ ];
23147
+ const TAG_ACTIONS = [
23148
+ { key: '+', label: 'New tag' },
23149
+ { key: 'P', label: 'Push tag' },
23150
+ { key: 'T', label: 'Delete', destructive: true },
23151
+ { key: 'R', label: 'Delete remote', destructive: true },
23152
+ { key: 'y', label: 'Yank name' },
23153
+ ];
23154
+ const STASH_ACTIONS = [
23155
+ { key: 'enter', label: 'Open diff' },
23156
+ { key: 'a', label: 'Apply' },
23157
+ { key: 'p', label: 'Pop' },
23158
+ { key: 'X', label: 'Drop', destructive: true },
23159
+ { key: 'y', label: 'Yank ref' },
23160
+ ];
23161
+ const WORKTREE_ACTIONS = [
23162
+ { key: 'W', label: 'Remove', destructive: true },
23163
+ { key: 'y', label: 'Yank path' },
23164
+ ];
23165
+ function getInspectorActions(context) {
23166
+ switch (context) {
23167
+ case 'history-commit':
23168
+ return HISTORY_COMMIT_ACTIONS;
23169
+ case 'branch':
23170
+ return BRANCH_ACTIONS;
23171
+ case 'tag':
23172
+ return TAG_ACTIONS;
23173
+ case 'stash':
23174
+ return STASH_ACTIONS;
23175
+ case 'worktree':
23176
+ return WORKTREE_ACTIONS;
23177
+ default: {
23178
+ const exhaustive = context;
23179
+ throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
23180
+ }
23181
+ }
23182
+ }
23183
+
21363
23184
  function sectionLines(title, diff) {
21364
23185
  const lines = diff.split('\n').map((line) => line.trimEnd());
21365
23186
  return [
@@ -21575,6 +23396,89 @@ function diffLineProps(line, theme) {
21575
23396
  }
21576
23397
  return {};
21577
23398
  }
23399
+ /**
23400
+ * Minimum terminal width below which the split diff falls back to
23401
+ * unified rendering (#785). Each column needs ~50 columns for code to
23402
+ * read comfortably plus border + padding overhead, so anything narrower
23403
+ * than ~120 columns gets the unified view regardless of the user's
23404
+ * preference. The preference is preserved — switching back to a wide
23405
+ * terminal restores split mode automatically.
23406
+ */
23407
+ const MIN_SPLIT_DIFF_WIDTH = 120;
23408
+ function isSplitDiffViable(state, width) {
23409
+ return state.diffViewMode === 'split' && width >= MIN_SPLIT_DIFF_WIDTH;
23410
+ }
23411
+ /**
23412
+ * Style props for one side of a split-diff row, derived from the row's
23413
+ * `kind` rather than the leading character (because the helper has
23414
+ * already stripped the leading +/-/space). Keeps the colors aligned with
23415
+ * `diffLineProps`.
23416
+ */
23417
+ function splitDiffSideProps(kind, theme) {
23418
+ if (kind === 'header') {
23419
+ if (theme.noColor)
23420
+ return { dimColor: true };
23421
+ return { color: theme.colors.accent };
23422
+ }
23423
+ if (kind === 'empty') {
23424
+ return { dimColor: true };
23425
+ }
23426
+ if (theme.noColor) {
23427
+ return { dimColor: kind === 'context' };
23428
+ }
23429
+ if (kind === 'add')
23430
+ return { color: theme.colors.gitAdded };
23431
+ if (kind === 'remove')
23432
+ return { color: theme.colors.gitDeleted };
23433
+ return {};
23434
+ }
23435
+ /**
23436
+ * Format one column of a split-diff row: an optional 4-digit line
23437
+ * number prefix + the line text, padded/truncated to the column width.
23438
+ * Empty rows render a faint `·` placeholder so the alignment gap is
23439
+ * visible at a glance.
23440
+ */
23441
+ function formatSplitDiffCell(side, columnWidth) {
23442
+ if (side.kind === 'empty') {
23443
+ const placeholder = ' · ';
23444
+ return placeholder.padEnd(columnWidth);
23445
+ }
23446
+ if (side.kind === 'header') {
23447
+ return truncate$1(side.text, columnWidth).padEnd(columnWidth);
23448
+ }
23449
+ const lineNo = side.lineNumber !== undefined ? String(side.lineNumber).padStart(4) : ' ';
23450
+ // Strip the trailing newline that some diffs include. Keeps column
23451
+ // widths predictable.
23452
+ const text = side.text.replace(/\n$/, '');
23453
+ // 4 digits + 1 space gutter = 5 chars; reserve that off the column
23454
+ // before truncating the text.
23455
+ const textRoom = Math.max(1, columnWidth - 5);
23456
+ return `${lineNo} ${truncate$1(text, textRoom)}`.padEnd(columnWidth);
23457
+ }
23458
+ /**
23459
+ * Render the split-diff body as a list of two-column rows. The caller
23460
+ * is responsible for slicing the unified-line array to the visible
23461
+ * window — the helper just transforms that slice into Ink nodes.
23462
+ */
23463
+ function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, theme, keyPrefix) {
23464
+ const { Box, Text } = components;
23465
+ const rows = buildSplitDiffRows(unifiedSlice);
23466
+ // Reserve 3 columns of gutter (1 left padding from the Box + 1 column
23467
+ // separator + 1 right padding) so neither side touches the border.
23468
+ const usable = Math.max(20, width - 4);
23469
+ const gutter = 1;
23470
+ const half = Math.max(10, Math.floor((usable - gutter) / 2));
23471
+ return rows.map((row, index) => {
23472
+ const leftProps = splitDiffSideProps(row.left.kind, theme);
23473
+ const rightProps = splitDiffSideProps(row.right.kind, theme);
23474
+ const leftText = formatSplitDiffCell(row.left, half);
23475
+ const rightText = formatSplitDiffCell(row.right, half);
23476
+ return h(Box, {
23477
+ key: `${keyPrefix}-${startOffset + index}`,
23478
+ flexDirection: 'row',
23479
+ }, 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)));
23480
+ });
23481
+ }
21578
23482
  /**
21579
23483
  * Pick a theme color for a single name-status code (`A`, `M`, `D`,
21580
23484
  * `R100`, etc.) so the inspector and commit-diff file list render with
@@ -21628,68 +23532,6 @@ function sidebarTabLabel(tab) {
21628
23532
  return tab;
21629
23533
  }
21630
23534
  }
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
23535
  async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
21694
23536
  const input = streams.input || process.stdin;
21695
23537
  const output = streams.output || process.stdout;
@@ -22031,6 +23873,13 @@ function LogInkApp(deps) {
22031
23873
  if (saved && saved !== state.userSidebarTab) {
22032
23874
  dispatch({ type: 'restoreSidebarTab', value: saved });
22033
23875
  }
23876
+ // Diff view mode persistence (#785). Same per-repo cache pattern
23877
+ // as the sidebar tab — restore the user's last preference if
23878
+ // they had one. New repos / fresh installs default to unified.
23879
+ const savedDiffMode = getSavedDiffViewMode(repoRoot);
23880
+ if (savedDiffMode && savedDiffMode !== state.diffViewMode) {
23881
+ dispatch({ type: 'setDiffViewMode', value: savedDiffMode });
23882
+ }
22034
23883
  }
22035
23884
  catch {
22036
23885
  // Not in a worktree, or revparse failed; nothing to restore.
@@ -22044,6 +23893,12 @@ function LogInkApp(deps) {
22044
23893
  return;
22045
23894
  saveSidebarTab(repoRoot, state.userSidebarTab);
22046
23895
  }, [state.userSidebarTab]);
23896
+ React.useEffect(() => {
23897
+ const repoRoot = repoRootRef.current;
23898
+ if (!repoRoot)
23899
+ return;
23900
+ saveDiffViewMode(repoRoot, state.diffViewMode);
23901
+ }, [state.diffViewMode]);
22047
23902
  // P-stash-explorer: load `git stash show -p <ref>` once the diff view
22048
23903
  // becomes active with diffSource='stash'. Best-effort — empty stashes
22049
23904
  // or read errors fall through to a "no diff" hint at the render site.
@@ -22125,6 +23980,80 @@ function LogInkApp(deps) {
22125
23980
  active = false;
22126
23981
  };
22127
23982
  }, [git, selected?.hash]);
23983
+ // #806 follow-up — auto-jump the history view to whichever branch /
23984
+ // tag the user is currently cursoring in the sidebar (or the
23985
+ // dedicated branches / tags view). Debounced so cursor-scrolling
23986
+ // through a long branch list doesn't dispatch on every keystroke.
23987
+ // No-op when the cursored ref's tip isn't in the loaded commit
23988
+ // window (under compact mode the cursored branch's tip may not be
23989
+ // fetched yet); a status hint surfaces in that case so the user
23990
+ // knows to toggle full graph or load older commits.
23991
+ React.useEffect(() => {
23992
+ const onBranchTab = state.activeView === 'branches' ||
23993
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
23994
+ const onTagTab = state.activeView === 'tags' ||
23995
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
23996
+ if (!onBranchTab && !onTagTab)
23997
+ return;
23998
+ let cancelled = false;
23999
+ const timer = setTimeout(() => {
24000
+ if (cancelled)
24001
+ return;
24002
+ let targetHash;
24003
+ let targetLabel;
24004
+ if (onBranchTab) {
24005
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24006
+ const visible = state.filter
24007
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24008
+ : all;
24009
+ const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24010
+ if (branch) {
24011
+ targetHash = branch.hash;
24012
+ targetLabel = `branch ${branch.shortName}`;
24013
+ }
24014
+ }
24015
+ else if (onTagTab) {
24016
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
24017
+ const visible = state.filter
24018
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24019
+ : all;
24020
+ const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24021
+ if (tag) {
24022
+ targetHash = tag.hash;
24023
+ targetLabel = `tag ${tag.name}`;
24024
+ }
24025
+ }
24026
+ if (!targetHash)
24027
+ return;
24028
+ const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24029
+ if (loaded) {
24030
+ dispatch({ type: 'selectCommitByHash', hash: targetHash });
24031
+ // Confirmation status message so the user gets feedback even
24032
+ // when the dedicated branches / tags view is occupying the
24033
+ // main panel and the history cursor moves invisibly behind it.
24034
+ dispatch({
24035
+ type: 'setStatus',
24036
+ value: `Synced history to ${targetLabel} tip`,
24037
+ });
24038
+ }
24039
+ else {
24040
+ dispatch({
24041
+ type: 'setStatus',
24042
+ value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24043
+ });
24044
+ }
24045
+ }, 150);
24046
+ return () => {
24047
+ cancelled = true;
24048
+ clearTimeout(timer);
24049
+ };
24050
+ }, [
24051
+ dispatch, context.branches, context.tags,
24052
+ state.activeView, state.focus, state.sidebarTab,
24053
+ state.selectedBranchIndex, state.selectedTagIndex,
24054
+ state.branchSort, state.tagSort, state.filter,
24055
+ state.filteredCommits,
24056
+ ]);
22128
24057
  React.useEffect(() => {
22129
24058
  let active = true;
22130
24059
  async function loadWorktreeDiff() {
@@ -22336,6 +24265,27 @@ function LogInkApp(deps) {
22336
24265
  // disappears. Called from the y-confirm path for delete-branch / delete-
22337
24266
  // tag / drop-stash / remove-worktree / abort-operation.
22338
24267
  const runWorkflowAction = React.useCallback(async (id, payload) => {
24268
+ // Hunk-apply payload format: `<target>\n<patchText>` — the input
24269
+ // handler synthesizes both pieces (target from the keystroke,
24270
+ // patch text from extractDiffHunk against the live diff lines)
24271
+ // and packs them into the single `payload` field. Splitting on
24272
+ // the first newline keeps the patch body intact.
24273
+ const runApplyHunk = (expectedTarget, raw) => {
24274
+ if (!raw) {
24275
+ return Promise.resolve({ ok: false, message: 'No hunk under cursor to apply.' });
24276
+ }
24277
+ const newlineIndex = raw.indexOf('\n');
24278
+ if (newlineIndex < 0) {
24279
+ return Promise.resolve({ ok: false, message: 'Malformed hunk-apply payload.' });
24280
+ }
24281
+ const target = raw.slice(0, newlineIndex) === 'index' ? 'index' : 'worktree';
24282
+ const patchText = raw.slice(newlineIndex + 1);
24283
+ // The input handler is the source of truth for target — but if a
24284
+ // palette-injected payload mismatches the workflow id, prefer
24285
+ // the workflow id so the user sees the action they asked for.
24286
+ const effectiveTarget = expectedTarget || target;
24287
+ return applyHunkPatch(git, patchText, { target: effectiveTarget });
24288
+ };
22339
24289
  const handlers = {
22340
24290
  'create-branch': async () => {
22341
24291
  const name = payload?.trim();
@@ -22441,6 +24391,68 @@ function LogInkApp(deps) {
22441
24391
  message: commit.message,
22442
24392
  });
22443
24393
  },
24394
+ 'revert-commit': async () => {
24395
+ const commit = getSelectedInkCommit(state);
24396
+ if (!commit)
24397
+ return { ok: false, message: 'No commit selected' };
24398
+ return revertCommit(git, {
24399
+ hash: commit.hash,
24400
+ shortHash: commit.shortHash,
24401
+ message: commit.message,
24402
+ });
24403
+ },
24404
+ 'reset-to-commit': async () => {
24405
+ const commit = getSelectedInkCommit(state);
24406
+ if (!commit)
24407
+ return { ok: false, message: 'No commit selected' };
24408
+ // Mode arrives via the action's `payload` field — the input
24409
+ // handler runs the reset-mode prompt (kind: 'reset-mode') and
24410
+ // routes the typed value here. Default to `mixed` (git's own
24411
+ // default) when the user submitted an empty value.
24412
+ const raw = payload?.trim().toLowerCase() || 'mixed';
24413
+ if (!isResetMode(raw)) {
24414
+ return { ok: false, message: `Unknown reset mode: ${raw}. Use soft, mixed, or hard.` };
24415
+ }
24416
+ return resetToCommit(git, {
24417
+ hash: commit.hash,
24418
+ shortHash: commit.shortHash,
24419
+ message: commit.message,
24420
+ }, raw);
24421
+ },
24422
+ 'interactive-rebase': async () => {
24423
+ const commit = getSelectedInkCommit(state);
24424
+ if (!commit)
24425
+ return { ok: false, message: 'No commit selected' };
24426
+ return startInteractiveRebase(git, {
24427
+ hash: commit.hash,
24428
+ shortHash: commit.shortHash,
24429
+ message: commit.message,
24430
+ });
24431
+ },
24432
+ 'create-branch-here': async () => {
24433
+ const commit = getSelectedInkCommit(state);
24434
+ const name = payload?.trim();
24435
+ if (!commit)
24436
+ return { ok: false, message: 'No commit selected' };
24437
+ if (!name)
24438
+ return { ok: false, message: 'Branch name required' };
24439
+ return createBranchFromCommit(git, name, {
24440
+ hash: commit.hash,
24441
+ shortHash: commit.shortHash,
24442
+ });
24443
+ },
24444
+ 'create-tag-here': async () => {
24445
+ const commit = getSelectedInkCommit(state);
24446
+ const name = payload?.trim();
24447
+ if (!commit)
24448
+ return { ok: false, message: 'No commit selected' };
24449
+ if (!name)
24450
+ return { ok: false, message: 'Tag name required' };
24451
+ return createTagAtCommit(git, name, {
24452
+ hash: commit.hash,
24453
+ shortHash: commit.shortHash,
24454
+ });
24455
+ },
22444
24456
  'checkout-file-from-commit': async () => {
22445
24457
  // payload is "<sha> <path>" so we pass both through a single
22446
24458
  // string field on the action.
@@ -22456,6 +24468,8 @@ function LogInkApp(deps) {
22456
24468
  return { ok: false, message: 'No commit file under cursor' };
22457
24469
  return checkoutFileFromCommit(git, sha, path);
22458
24470
  },
24471
+ 'apply-hunk-worktree': async () => runApplyHunk('worktree', payload),
24472
+ 'apply-hunk-index': async () => runApplyHunk('index', payload),
22459
24473
  'remove-worktree': async () => {
22460
24474
  const all = context.worktreeList?.worktrees || [];
22461
24475
  // Resolve the target from the visible (filtered) list so a
@@ -22491,6 +24505,17 @@ function LogInkApp(deps) {
22491
24505
  if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
22492
24506
  return { ok: false, message: 'No GitHub remote detected for this repo' };
22493
24507
  }
24508
+ // History view: prefer the cursored commit's URL so `O` from
24509
+ // a commit context lands the user on the commit page rather
24510
+ // than the repo root or the current PR. The user-visible
24511
+ // intent of `O` is "open whatever I'm cursoring on the web";
24512
+ // a commit is what the cursor is on in the history view.
24513
+ if (state.activeView === 'history') {
24514
+ const commit = getSelectedInkCommit(state);
24515
+ if (commit) {
24516
+ return openProviderUrl(repo, { type: 'commit', commit: commit.hash });
24517
+ }
24518
+ }
22494
24519
  const pr = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
22495
24520
  if (pr) {
22496
24521
  return openProviderUrl(repo, { type: 'pull-request', number: pr.number });
@@ -22544,6 +24569,32 @@ function LogInkApp(deps) {
22544
24569
  return { ok: false, message: 'Stash message required' };
22545
24570
  return createStash(git, message);
22546
24571
  },
24572
+ // #783 — full PR action panel handlers. Each wraps the matching
24573
+ // pullRequestActions verb. Strategy / body arrives via `payload`
24574
+ // — input prompts validate before they reach here, but the
24575
+ // strategy guard stays as a defensive belt-and-suspenders since
24576
+ // a future palette path could call us with a raw value.
24577
+ 'merge-pr': async () => {
24578
+ const strategy = (payload || 'merge').toLowerCase();
24579
+ if (!isPullRequestMergeStrategy(strategy)) {
24580
+ return { ok: false, message: `Unknown merge strategy: ${strategy}. Use merge, squash, or rebase.` };
24581
+ }
24582
+ return mergePullRequest(strategy);
24583
+ },
24584
+ 'close-pr': async () => closePullRequest(),
24585
+ 'approve-pr': async () => approvePullRequest(),
24586
+ 'request-changes-pr': async () => {
24587
+ const body = payload?.trim();
24588
+ if (!body)
24589
+ return { ok: false, message: 'Review body required for change-request' };
24590
+ return requestChangesPullRequest(body);
24591
+ },
24592
+ 'comment-pr': async () => {
24593
+ const body = payload?.trim();
24594
+ if (!body)
24595
+ return { ok: false, message: 'Comment body required' };
24596
+ return commentPullRequest(body);
24597
+ },
22547
24598
  };
22548
24599
  const handler = handlers[id];
22549
24600
  if (!handler) {
@@ -22552,9 +24603,21 @@ function LogInkApp(deps) {
22552
24603
  }
22553
24604
  const result = await handler();
22554
24605
  dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
22555
- // Silent refresh so the deleted item disappears from the list without
22556
- // flickering the surfaces through a 'loading' phase.
22557
- await refreshContext({ silent: true });
24606
+ // Checkout-branch is the one workflow where we want a *visible*
24607
+ // refresh so the user sees the branches sidebar repaint with the
24608
+ // new current branch (per #806 follow-up). Snap the cursor to
24609
+ // position 0 first so when the refresh completes and the new
24610
+ // current branch lands at the top (per #809's pin-current rule),
24611
+ // the cursor is already there waiting.
24612
+ if (id === 'checkout-branch' && result?.ok) {
24613
+ dispatch({ type: 'resetBranchSelection' });
24614
+ await refreshContext();
24615
+ }
24616
+ else {
24617
+ // Silent refresh so the deleted item disappears from the list
24618
+ // without flickering the surfaces through a 'loading' phase.
24619
+ await refreshContext({ silent: true });
24620
+ }
22558
24621
  }, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
22559
24622
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
22560
24623
  state.tagSort]);
@@ -22834,6 +24897,51 @@ function LogInkApp(deps) {
22834
24897
  });
22835
24898
  })();
22836
24899
  }, [dispatch, git, logArgv, state.historyFetchArgs]);
24900
+ // Graph mode toggle (`g` key, #791 follow-up). The header label flips
24901
+ // between "compact graph" and "full graph", but unless we re-fetch with
24902
+ // the right `view`, the underlying rows still come from the user's
24903
+ // initial argv (default `--first-parent --no-merges`) and the renderer
24904
+ // has no topology to draw — defeating the per-lane / junction work.
24905
+ // Mirrors the historyFetchArgs effect: skip first run, request-id ref
24906
+ // for stale-completion guard, swap rows in place via replaceRows.
24907
+ const toggleGraphEffectInitialized = React.useRef(false);
24908
+ const toggleGraphRequestRef = React.useRef(0);
24909
+ React.useEffect(() => {
24910
+ if (!logArgv)
24911
+ return;
24912
+ if (!toggleGraphEffectInitialized.current) {
24913
+ toggleGraphEffectInitialized.current = true;
24914
+ return;
24915
+ }
24916
+ const requestId = toggleGraphRequestRef.current + 1;
24917
+ toggleGraphRequestRef.current = requestId;
24918
+ const merged = buildToggleGraphArgs(logArgv, state.fullGraph);
24919
+ dispatch({
24920
+ type: 'setStatus',
24921
+ value: state.fullGraph
24922
+ ? 'Loading full topology…'
24923
+ : 'Loading compact history…',
24924
+ });
24925
+ void (async () => {
24926
+ const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
24927
+ if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
24928
+ return;
24929
+ }
24930
+ if (!nextRows) {
24931
+ dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
24932
+ return;
24933
+ }
24934
+ dispatch({ type: 'replaceRows', rows: nextRows });
24935
+ const matched = getCommitRows(nextRows).length;
24936
+ setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
24937
+ dispatch({
24938
+ type: 'setStatus',
24939
+ value: state.fullGraph
24940
+ ? `Showing ${matched} commits across all branches`
24941
+ : `Showing ${matched} commits (compact)`,
24942
+ });
24943
+ })();
24944
+ }, [dispatch, git, logArgv, state.fullGraph]);
22837
24945
  const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
22838
24946
  .map((line, index) => (line.startsWith('@@') ? index : -1))
22839
24947
  .filter((index) => index >= 0)), [filePreview]);
@@ -22928,6 +25036,17 @@ function LogInkApp(deps) {
22928
25036
  ? selected?.hash
22929
25037
  : undefined,
22930
25038
  worktreeDirty,
25039
+ // H / gH need the actual diff text (not just hunk offsets) to
25040
+ // slice the cursored hunk into a `git apply` patch. Stash uses
25041
+ // the full `git stash show -p` output; commit-diff uses the
25042
+ // per-file `filePreview.hunks` array. Either way, extractDiffHunk
25043
+ // walks `@@` headers and synthesizes a fresh diff --git / --- /
25044
+ // +++ header set using the path the caller already resolved.
25045
+ diffLinesForHunkApply: state.diffSource === 'stash'
25046
+ ? stashDiffLines
25047
+ : state.diffSource === 'commit'
25048
+ ? filePreview?.hunks
25049
+ : undefined,
22931
25050
  }).forEach((event) => {
22932
25051
  if (event.type === 'exit') {
22933
25052
  exit();
@@ -22980,6 +25099,7 @@ function LogInkApp(deps) {
22980
25099
  columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
22981
25100
  rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
22982
25101
  sidebarFocused: state.focus === 'sidebar',
25102
+ inspectorFocused: state.focus === 'detail',
22983
25103
  });
22984
25104
  if (layout.tooSmall) {
22985
25105
  return h(Box, {
@@ -22994,7 +25114,7 @@ function LogInkApp(deps) {
22994
25114
  if (showOnboarding) {
22995
25115
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
22996
25116
  }
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));
25117
+ return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
22998
25118
  }
22999
25119
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
23000
25120
  const { Box, Text } = components;
@@ -23047,7 +25167,7 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
23047
25167
  ? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
23048
25168
  : undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
23049
25169
  }
23050
- function renderSidebar(h, components, state, context, contextStatus, width, theme) {
25170
+ function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme) {
23051
25171
  const { Box, Text } = components;
23052
25172
  const focused = state.focus === 'sidebar';
23053
25173
  const tabs = getLogInkSidebarTabs();
@@ -23071,7 +25191,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
23071
25191
  dimColor: !isActive,
23072
25192
  }, headerText));
23073
25193
  if (isActive) {
23074
- blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme));
25194
+ blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
23075
25195
  }
23076
25196
  return blocks;
23077
25197
  });
@@ -23090,15 +25210,113 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
23090
25210
  * surface; every other tab falls through to `sidebarLines` for its
23091
25211
  * string-based summary.
23092
25212
  */
23093
- function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme) {
25213
+ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
25214
+ // Available rows for the active tab's list. The sidebar chrome
25215
+ // takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
25216
+ // spacers); the branches tab eats 3 more for its summary header
25217
+ // (Current / Worktree / spacer). Floor of 8 keeps short terminals
25218
+ // usable; tall terminals (40+ rows) get noticeably more items.
25219
+ const sidebarChrome = 10;
25220
+ const branchHeaderRows = tab === 'branches' ? 3 : 0;
25221
+ const visibleListCount = Math.max(8, bodyRows - sidebarChrome - branchHeaderRows);
23094
25222
  if (tab === 'status') {
23095
25223
  return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
23096
25224
  }
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)));
25225
+ // Branches / tags / stashes / worktrees: render selectable rows so
25226
+ // ↑/↓ navigates within the sidebar list and Enter / per-entity keys
25227
+ // act on the cursored item without needing to drill into the
25228
+ // dedicated view (#791 follow-up — in-sidebar selection).
25229
+ const focused = state.focus === 'sidebar' && state.sidebarTab === tab;
25230
+ if (tab === 'branches') {
25231
+ if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
25232
+ return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
25233
+ }
25234
+ const branches = context.branches;
25235
+ if (!branches) {
25236
+ return [h(Text, { key: 'tab-branches-empty', dimColor: true }, ' Branches unavailable')];
25237
+ }
25238
+ const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
25239
+ const headerRows = [
25240
+ h(Text, { key: 'tab-branches-current', dimColor: true }, truncate$1(` Current: ${branches.currentBranch || '<detached>'}`, width - 4)),
25241
+ h(Text, { key: 'tab-branches-state', dimColor: true }, ` Worktree: ${branches.dirty ? 'dirty' : 'clean'}`),
25242
+ h(Text, { key: 'tab-branches-spacer' }, ''),
25243
+ ];
25244
+ return [
25245
+ ...headerRows,
25246
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
25247
+ ];
25248
+ }
25249
+ if (tab === 'tags') {
25250
+ if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
25251
+ return [h(Text, { key: 'tab-tags-loading', dimColor: true }, ' Loading tags…')];
25252
+ }
25253
+ const tags = sortTags(context.tags?.tags || [], state.tagSort);
25254
+ if (tags.length === 0) {
25255
+ return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
25256
+ }
25257
+ return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
25258
+ }
25259
+ if (tab === 'stashes') {
25260
+ if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
25261
+ return [h(Text, { key: 'tab-stashes-loading', dimColor: true }, ' Loading stashes…')];
25262
+ }
25263
+ const stashes = context.stashes?.stashes || [];
25264
+ if (stashes.length === 0) {
25265
+ return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
25266
+ }
25267
+ return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
25268
+ }
25269
+ // worktrees
25270
+ if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
25271
+ return [h(Text, { key: 'tab-worktrees-loading', dimColor: true }, ' Loading worktrees…')];
25272
+ }
25273
+ const worktrees = context.worktreeList?.worktrees || [];
25274
+ if (worktrees.length === 0) {
25275
+ return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
25276
+ }
25277
+ return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
25278
+ const marker = worktree.current ? '*' : ' ';
25279
+ const wstate = worktree.dirty ? 'dirty' : 'clean';
25280
+ return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
25281
+ }, 'tab-worktrees', visibleListCount);
25282
+ }
25283
+ /**
25284
+ * Render a sliding-window list of selectable sidebar rows. The cursor
25285
+ * highlights the row at `selectedIndex` only when `focused` is true so
25286
+ * an unfocused sidebar doesn't compete visually with the active panel.
25287
+ * Sliding window keeps the cursor in view as the user navigates a long
25288
+ * list; truncation hints surface the count of hidden rows.
25289
+ */
25290
+ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
25291
+ if (items.length === 0)
25292
+ return [];
25293
+ const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
25294
+ const elements = [];
25295
+ if (window.truncatedAbove > 0) {
25296
+ elements.push(h(Text, {
25297
+ key: `${keyPrefix}-trunc-above`,
25298
+ dimColor: true,
25299
+ }, truncate$1(` … ${window.truncatedAbove} more above`, width - 4)));
25300
+ }
25301
+ for (let offset = 0; offset < window.size; offset += 1) {
25302
+ const index = window.start + offset;
25303
+ if (index >= items.length)
25304
+ break;
25305
+ const isSelected = focused && index === selectedIndex;
25306
+ const text = toRowText(items[index], index);
25307
+ elements.push(h(Text, {
25308
+ key: `${keyPrefix}-row-${index}`,
25309
+ backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
25310
+ inverse: isSelected,
25311
+ }, truncate$1(` ${text}`, width - 4)));
25312
+ }
25313
+ if (window.truncatedBelow > 0) {
25314
+ elements.push(h(Text, {
25315
+ key: `${keyPrefix}-trunc-below`,
25316
+ dimColor: true,
25317
+ }, truncate$1(` … ${window.truncatedBelow} more below`, width - 4)));
25318
+ }
25319
+ return elements;
23102
25320
  }
23103
25321
  function renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme) {
23104
25322
  if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
@@ -23156,6 +25374,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
23156
25374
  if (state.activeView === 'worktrees') {
23157
25375
  return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
23158
25376
  }
25377
+ if (state.activeView === 'pull-request') {
25378
+ return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
25379
+ }
23159
25380
  return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
23160
25381
  }
23161
25382
  function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
@@ -23205,15 +25426,46 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
23205
25426
  }))
23206
25427
  : visible.items.map((item, index) => {
23207
25428
  if (item.type === 'graph') {
25429
+ if (item.laneSegments && !theme.ascii) {
25430
+ return h(Text, { key: `graph-${index}-${item.graph}` }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`));
25431
+ }
23208
25432
  return h(Text, {
23209
25433
  key: `graph-${index}-${item.graph}`,
23210
25434
  color: theme.noColor ? undefined : theme.colors.muted,
23211
25435
  dimColor: theme.noColor,
23212
25436
  }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
23213
25437
  }
23214
- return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index);
25438
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
23215
25439
  }));
23216
25440
  }
25441
+ /**
25442
+ * Render `LaneSegment[]` as a flat list of Text spans, one per lane
25443
+ * (#791 stage 2). Each segment paints in its lane's palette color so
25444
+ * the eye can follow a branch column-by-column; segments without a
25445
+ * lane id (spaces, padding, decorations) fall back to the muted graph
25446
+ * color so they visually recede.
25447
+ *
25448
+ * Final padding is appended as its own span so callers do not need to
25449
+ * pre-pad the graph string before computing lane segments.
25450
+ */
25451
+ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
25452
+ const muted = theme.noColor ? undefined : theme.colors.muted;
25453
+ const elements = [];
25454
+ let totalLen = 0;
25455
+ segments.forEach((seg, idx) => {
25456
+ const laneColor = getLaneColor(seg.laneId, theme);
25457
+ elements.push(h(Text, {
25458
+ key: `${keyPrefix}-${idx}`,
25459
+ color: laneColor ?? muted,
25460
+ dimColor: theme.noColor && seg.laneId === undefined,
25461
+ }, seg.text));
25462
+ totalLen += seg.text.length;
25463
+ });
25464
+ if (padTo > totalLen) {
25465
+ elements.push(h(Text, { key: `${keyPrefix}-pad` }, ' '.repeat(padTo - totalLen)));
25466
+ }
25467
+ return elements;
25468
+ }
23217
25469
  /**
23218
25470
  * Render a single commit row with each segment in its own colored span.
23219
25471
  * Graph chars render in `theme.colors.muted` so the topology visually
@@ -23226,8 +25478,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
23226
25478
  * Truncation is per-segment so the variable-length message field gets
23227
25479
  * the leftover budget after fixed segments are accounted for.
23228
25480
  */
23229
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index) {
23230
- const renderedGraph = substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii });
25481
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
23231
25482
  const refs = formatInkRefLabels(commit.refs);
23232
25483
  const totalWidth = 140;
23233
25484
  const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
@@ -23236,11 +25487,17 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
23236
25487
  const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
23237
25488
  const accent = theme.noColor ? undefined : theme.colors.accent;
23238
25489
  const muted = theme.noColor ? undefined : theme.colors.muted;
25490
+ // Lane-colored graph spans when full graph mode + non-ASCII rendering
25491
+ // is in play; otherwise fall back to the legacy single-muted span so
25492
+ // compact mode and legacy terminals stay visually unchanged.
25493
+ const graphChildren = laneSegments && !theme.ascii
25494
+ ? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `c${index}`)
25495
+ : [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
23239
25496
  return h(Text, {
23240
25497
  key: `${commit.hash}-${index}`,
23241
25498
  backgroundColor: selectedBg,
23242
25499
  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);
25500
+ }, ...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
25501
  }
23245
25502
  /**
23246
25503
  * Render the synthetic "(+) new commit" affordance shown above the real
@@ -23473,11 +25730,35 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
23473
25730
  const cursor = isSelected ? '>' : ' ';
23474
25731
  const marker = branchRowMarker(branch, { ascii: theme.ascii });
23475
25732
  const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
25733
+ const lastTouched = formatBranchLastTouched(branch.date, new Date());
25734
+ // Split the row into spans so the timestamp stays dim even on the
25735
+ // currently-selected (bold) row. The leading marker + name keep
25736
+ // their original column widths; the timestamp is right-padded so
25737
+ // the divergence column stays aligned across rows.
25738
+ const namePadded = branch.shortName.padEnd(28);
25739
+ const timestampPadded = lastTouched.padEnd(8);
25740
+ const lineDim = !isSelected && !branch.current;
25741
+ const head = `${cursor} ${marker} ${namePadded} `;
25742
+ const trailingDivergence = divergence ? ` ${divergence}` : '';
25743
+ // Truncate the assembled line cooperatively so we never overflow
25744
+ // the panel; the timestamp is short and the divergence is the
25745
+ // most expendable, but the existing 140 cap is ample.
25746
+ const fullText = `${head}${timestampPadded}${trailingDivergence}`;
25747
+ const truncated = truncate$1(fullText, 140);
25748
+ // If truncation chopped into the timestamp/divergence portion,
25749
+ // fall back to a single Text to keep the visible width honest.
25750
+ if (truncated !== fullText) {
25751
+ return h(Text, {
25752
+ key: `branch-${index}`,
25753
+ bold: isSelected,
25754
+ dimColor: lineDim,
25755
+ }, truncated);
25756
+ }
23476
25757
  return h(Text, {
23477
25758
  key: `branch-${index}`,
23478
25759
  bold: isSelected,
23479
- dimColor: !isSelected && !branch.current,
23480
- }, truncate$1(`${cursor} ${marker} ${branch.shortName.padEnd(28)} ${divergence}`, 140));
25760
+ dimColor: lineDim,
25761
+ }, head, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
23481
25762
  });
23482
25763
  return h(Box, {
23483
25764
  borderColor: focusBorderColor(theme, focused),
@@ -23630,6 +25911,98 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
23630
25911
  width,
23631
25912
  }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Worktrees', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
23632
25913
  }
25914
+ /**
25915
+ * Pull-request action panel (#783) — renders the current branch's PR
25916
+ * with header, checks table, reviews summary, and a body preview.
25917
+ * Action keys (m / x / a / R / c / O) are wired in inkInput.ts and
25918
+ * surfaced via the footer; this renderer is read-only.
25919
+ *
25920
+ * Three loading / fallback states matter:
25921
+ * - Provider data still loading → "Loading pull request..."
25922
+ * - GitHub remote present but no PR for the current branch → empty
25923
+ * state hint pointing the user at `C` to create one.
25924
+ * - GitHub CLI missing / unauthenticated → unavailable hint.
25925
+ */
25926
+ function renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
25927
+ const { Box, Text } = components;
25928
+ const focused = state.focus === 'commits';
25929
+ const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
25930
+ const pullRequestOverview = context.pullRequest;
25931
+ // Use the dedicated `pullRequest` overview only — the `provider`
25932
+ // shape carries a slimmer ProviderPullRequestStatus that lacks
25933
+ // url / headRefName / body / mergeable / reviews. The dedicated
25934
+ // overview hits `gh pr view --json` with the full enriched field
25935
+ // list (PULL_REQUEST_VIEW_JSON_FIELDS) so the panel has everything.
25936
+ const pr = pullRequestOverview?.currentPullRequest;
25937
+ const muted = theme.noColor ? undefined : theme.colors.muted;
25938
+ const accent = theme.noColor ? undefined : theme.colors.accent;
25939
+ const containerProps = {
25940
+ borderColor: focusBorderColor(theme, focused),
25941
+ borderStyle: theme.borderStyle,
25942
+ flexDirection: 'column',
25943
+ flexShrink: 0,
25944
+ paddingX: 1,
25945
+ width,
25946
+ };
25947
+ if (loading && !pr) {
25948
+ 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' })));
25949
+ }
25950
+ if (!pr) {
25951
+ const hint = pullRequestOverview?.message
25952
+ || 'No pull request detected for this branch. Press `C` (or `:create-pr`) to create one.';
25953
+ 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)));
25954
+ }
25955
+ const checks = summarizePullRequestChecks(pr.statusCheckRollup);
25956
+ const reviews = summarizePullRequestReviews(pr.reviews, pr.reviewDecision);
25957
+ const checkRows = buildPullRequestCheckRows(pr.statusCheckRollup, { ascii: theme.ascii });
25958
+ const checkColor = (s) => {
25959
+ if (theme.noColor)
25960
+ return undefined;
25961
+ if (s === 'success')
25962
+ return theme.colors.success;
25963
+ if (s === 'failure')
25964
+ return theme.colors.danger;
25965
+ if (s === 'pending')
25966
+ return theme.colors.warning;
25967
+ return theme.colors.muted;
25968
+ };
25969
+ // Reserve a few rows for the header/section labels; the rest go to
25970
+ // the checks table. Body preview gets the leftover rows so the
25971
+ // surface stays vertically balanced even on tall terminals.
25972
+ const checkBudget = Math.max(3, Math.min(checkRows.length, Math.floor(bodyRows / 2)));
25973
+ const visibleChecks = checkRows.slice(0, checkBudget);
25974
+ const truncatedChecks = checkRows.length - visibleChecks.length;
25975
+ const bodyPreviewBudget = Math.max(2, bodyRows - 8 - visibleChecks.length);
25976
+ const bodyLines = (pr.body || '').split(/\r?\n/).filter((line) => line.trim().length > 0);
25977
+ const visibleBodyLines = bodyLines.slice(0, bodyPreviewBudget);
25978
+ const truncatedBodyLines = bodyLines.length - visibleBodyLines.length;
25979
+ const headerRight = `#${pr.number} · ${pr.headRefName} → ${pr.baseRefName}`;
25980
+ const stateLine = formatPullRequestStateLine(pr);
25981
+ const author = pr.author ? `by @${pr.author}` : '';
25982
+ 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, ''),
25983
+ // Checks section
25984
+ 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, {
25985
+ key: `pr-check-${index}`,
25986
+ color: checkColor(row.status),
25987
+ }, truncate$1(` ${row.glyph} ${row.name.padEnd(28)} ${row.detail}`, width - 4))), ...(truncatedChecks > 0
25988
+ ? [h(Text, { key: 'pr-checks-trunc', dimColor: true }, truncate$1(` … ${truncatedChecks} more`, width - 4))]
25989
+ : []), h(Text, undefined, ''),
25990
+ // Reviews section
25991
+ h(Text, { bold: true, color: accent }, 'Reviews'), h(Text, { dimColor: true }, truncate$1(` ${formatPullRequestReviewsSummary(reviews)}`, width - 4)), h(Text, undefined, ''),
25992
+ // Body preview
25993
+ ...(visibleBodyLines.length > 0
25994
+ ? [
25995
+ h(Text, { key: 'pr-body-label', bold: true, color: accent }, 'Description'),
25996
+ ...visibleBodyLines.map((line, index) => h(Text, {
25997
+ key: `pr-body-${index}`,
25998
+ color: muted,
25999
+ }, truncate$1(` ${line}`, width - 4))),
26000
+ ...(truncatedBodyLines > 0
26001
+ ? [h(Text, { key: 'pr-body-trunc', dimColor: true }, truncate$1(` … ${truncatedBodyLines} more lines`, width - 4))]
26002
+ : []),
26003
+ ]
26004
+ : []));
26005
+ }
23633
26006
  /**
23634
26007
  * Filter input cursor for the promoted views (branches/tags/stash).
23635
26008
  * History already shows the same `filter: foo_` affordance in its header
@@ -23660,6 +26033,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23660
26033
  // cherry-picks the file at the cursor.
23661
26034
  if (state.diffSource === 'stash') {
23662
26035
  const lines = stashDiffLines || [];
26036
+ const splitActive = isSplitDiffViable(state, width);
26037
+ const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
23663
26038
  const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
23664
26039
  const stashFiles = parseStashDiffFiles(lines);
23665
26040
  const fileCount = stashFiles.length;
@@ -23680,11 +26055,19 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23680
26055
  const currentFileIndex = currentFile
23681
26056
  ? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
23682
26057
  : -1;
23683
- const headerLines = stashDiffLoading
23684
- ? [`Loading diff for ${state.stashDiffRef || 'stash'}...`]
26058
+ // Look up the active stash entry so the panel header can show a
26059
+ // human-identifier instead of the raw `stash@{<iso-date>}` ref.
26060
+ // The git ref is the timestamp form (we fetch with --date=iso for
26061
+ // stable parsing) which reads as noise in the title bar; the
26062
+ // message + branch + index combination is what the user wrote down
26063
+ // when they ran `git stash`. Body still shows the full ref so it
26064
+ // stays unambiguous.
26065
+ const stashIdentity = formatStashHeaderIdentity(state.stashDiffRef, context.stashes?.stashes);
26066
+ const baseHeaderLines = stashDiffLoading
26067
+ ? [`Loading diff for ${stashIdentity.subtitle}...`]
23685
26068
  : lines.length
23686
26069
  ? [
23687
- `Stash: ${state.stashDiffRef || ''}`,
26070
+ stashIdentity.bodyLine,
23688
26071
  fileCount > 0 && currentFile
23689
26072
  ? `File ${currentFileIndex + 1}/${fileCount}: ${currentFile.path}`
23690
26073
  : 'No files in this stash.',
@@ -23692,6 +26075,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23692
26075
  '',
23693
26076
  ]
23694
26077
  : ['No diff to display for this stash.'];
26078
+ const headerLines = splitRequestedButTooNarrow
26079
+ ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
26080
+ : baseHeaderLines;
26081
+ const stashBodyNodes = stashDiffLoading || !lines.length
26082
+ ? []
26083
+ : splitActive
26084
+ ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
26085
+ : visibleLines.map((line, index) => h(Text, {
26086
+ key: `stash-diff-line-${state.diffPreviewOffset + index}`,
26087
+ ...diffLineProps(line, theme),
26088
+ }, truncate$1(line, width - 4)));
23695
26089
  return h(Box, {
23696
26090
  borderColor: focusBorderColor(theme, focused),
23697
26091
  borderStyle: theme.borderStyle,
@@ -23699,15 +26093,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23699
26093
  flexShrink: 0,
23700
26094
  paddingX: 1,
23701
26095
  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, {
26096
+ }, 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
26097
  key: `stash-diff-header-${index}`,
23704
26098
  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)))));
26099
+ }, truncate$1(line, width - 4))), ...stashBodyNodes);
23711
26100
  }
23712
26101
  // diffSource disambiguates: 'commit' was set when the user opened the
23713
26102
  // diff via history → Enter (read-only commit-diff explore), 'worktree'
@@ -23718,6 +26107,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23718
26107
  (state.diffSource === undefined && !worktreeFile && Boolean(selectedDetailFile));
23719
26108
  if (useCommitDiff) {
23720
26109
  const previewHunks = filePreview?.hunks || [];
26110
+ const splitActive = isSplitDiffViable(state, width);
26111
+ const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
23721
26112
  const visiblePreviewHunks = previewHunks.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
23722
26113
  const hunkCount = commitDiffHunkOffsets?.length || 0;
23723
26114
  const currentHunkIndex = hunkCount > 0
@@ -23728,7 +26119,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23728
26119
  const currentHunkLabel = hunkCount > 0
23729
26120
  ? `Hunk ${Math.min(hunkCount - currentHunkIndex, hunkCount)}/${hunkCount}`
23730
26121
  : 'No hunks for this file.';
23731
- const headerLines = filePreviewLoading
26122
+ const baseHeaderLines = filePreviewLoading
23732
26123
  ? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
23733
26124
  : previewHunks.length
23734
26125
  ? [
@@ -23738,6 +26129,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23738
26129
  '',
23739
26130
  ]
23740
26131
  : ['No diff preview available for this file.'];
26132
+ const headerLines = splitRequestedButTooNarrow
26133
+ ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
26134
+ : baseHeaderLines;
26135
+ const commitBodyNodes = filePreviewLoading || !previewHunks.length
26136
+ ? []
26137
+ : splitActive
26138
+ ? renderSplitDiffBody(h, components, visiblePreviewHunks, state.diffPreviewOffset, width, theme, 'commit-diff-split')
26139
+ : visiblePreviewHunks.map((line, index) => h(Text, {
26140
+ key: `diff-surface-line-${state.diffPreviewOffset + index}`,
26141
+ ...diffLineProps(line, theme),
26142
+ }, truncate$1(line, 140)));
23741
26143
  return h(Box, {
23742
26144
  borderColor: focusBorderColor(theme, focused),
23743
26145
  borderStyle: theme.borderStyle,
@@ -23745,15 +26147,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23745
26147
  flexShrink: 0,
23746
26148
  paddingX: 1,
23747
26149
  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, {
26150
+ }, 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
26151
  key: `diff-surface-header-${index}`,
23750
26152
  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)))));
26153
+ }, truncate$1(line, 140))), ...commitBodyNodes);
23757
26154
  }
23758
26155
  const diffLines = worktreeDiff?.lines || [];
23759
26156
  const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
@@ -23794,7 +26191,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
23794
26191
  }, truncate$1(line, 140)))
23795
26192
  : []));
23796
26193
  }
23797
- function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme) {
26194
+ function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme) {
23798
26195
  const focused = state.focus === 'detail';
23799
26196
  if (state.showHelp) {
23800
26197
  return renderHelpPanel(h, components, state, width, theme, focused);
@@ -23851,16 +26248,11 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
23851
26248
  if (state.activeView === 'stash') {
23852
26249
  return renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
23853
26250
  }
23854
- return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme, focused);
26251
+ return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
23855
26252
  }
23856
- function renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, theme, focused) {
26253
+ function renderHistoryInspector(h, components, state, context, _contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, tabbed, theme, focused) {
23857
26254
  const { Box, Text } = components;
23858
26255
  const selected = getSelectedInkCommit(state);
23859
- const workflowSections = getLogInkWorkflowSections({
23860
- ...context,
23861
- contextLoading: isLogInkContextLoading(contextStatus),
23862
- selectedCommit: selected,
23863
- });
23864
26256
  if (!detail) {
23865
26257
  const fallbackLines = [
23866
26258
  selected?.message || 'No commit selected.',
@@ -23876,7 +26268,7 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
23876
26268
  }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
23877
26269
  key: `detail-${index}`,
23878
26270
  dimColor: index > 1,
23879
- }, truncate$1(line, width - 4))));
26271
+ }, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
23880
26272
  }
23881
26273
  const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
23882
26274
  // P5.1 — link the commit hash and each ref out to GitHub when we know
@@ -23887,18 +26279,26 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
23887
26279
  const refNodes = detail.refs.length
23888
26280
  ? renderInspectorRefs(h, Text, detail.refs, repository)
23889
26281
  : null;
26282
+ // Inspector reorder (PR — drop duplicative Workflows trailer):
26283
+ // 1. Commit message (the headline of what you're looking at)
26284
+ // 2. Metadata (hash / author / date / refs / stats)
26285
+ // 3. Body preview (up to 8 lines now that the trailer is gone)
26286
+ // 4. Changed files list (cursored entry highlights)
26287
+ // 5. Actions cheat-sheet (per-entity keystrokes; destructive marked)
26288
+ // The Workflows: trailer that used to repeat the repo / branch /
26289
+ // status from the top header and left sidebar is intentionally gone.
23890
26290
  const headerNodes = [
23891
26291
  h(Text, { key: 'detail-msg' }, truncate$1(detail.message, width - 4)),
23892
26292
  h(Text, { key: 'detail-spacer-1' }, ''),
23893
26293
  h(Text, { key: 'detail-commit', dimColor: true }, 'Commit: ', commitLink),
23894
26294
  h(Text, { key: 'detail-author', dimColor: true }, truncate$1(`Author: ${detail.author}`, width - 4)),
23895
- h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
26295
+ h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
23896
26296
  refNodes
23897
- ? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
23898
- : h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
23899
- h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(statLine, width - 4)),
26297
+ ? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
26298
+ : h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
26299
+ h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(`Stats: ${statLine}`, width - 4)),
23900
26300
  h(Text, { key: 'detail-spacer-2' }, ''),
23901
- ...(detail.body ? detail.body.split('\n').slice(0, 6) : ['No commit body.']).map((line, index) => h(Text, {
26301
+ ...(detail.body ? detail.body.split('\n').slice(0, 8) : ['No commit body.']).map((line, index) => h(Text, {
23902
26302
  key: `detail-body-${index}`,
23903
26303
  dimColor: true,
23904
26304
  }, truncate$1(line, width - 4))),
@@ -23907,24 +26307,85 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
23907
26307
  ];
23908
26308
  const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
23909
26309
  const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
23910
- const trailerLines = [
23911
- '',
23912
- 'Workflows:',
23913
- ...workflowSections.flatMap((section) => [
23914
- section.title,
23915
- ...section.lines.slice(0, 3).map((line) => ` ${line}`),
23916
- ]).slice(0, 12),
23917
- ];
26310
+ // Tabbed mode (#806 follow-up — short terminals): render only the
26311
+ // active inspector tab with a `[Inspector] Actions` header so the
26312
+ // user knows what they're seeing and how to switch (`[/]` while
26313
+ // focus is on the inspector). Tall terminals stack both sections
26314
+ // as before.
26315
+ if (tabbed) {
26316
+ const activeTab = state.inspectorTab;
26317
+ const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
26318
+ bold: activeTab === 'inspector',
26319
+ dimColor: activeTab !== 'inspector',
26320
+ }, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
26321
+ bold: activeTab === 'actions',
26322
+ dimColor: activeTab !== 'actions',
26323
+ }, activeTab === 'actions' ? '[Actions]' : ' Actions '));
26324
+ return h(Box, {
26325
+ borderColor: focusBorderColor(theme, focused),
26326
+ borderStyle: theme.borderStyle,
26327
+ flexDirection: 'column',
26328
+ width,
26329
+ paddingX: 1,
26330
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
26331
+ ? [...headerNodes, ...fileListNodes]
26332
+ : renderInspectorActionsSection(h, Text, 'history-commit', width, theme)));
26333
+ }
23918
26334
  return h(Box, {
23919
26335
  borderColor: focusBorderColor(theme, focused),
23920
26336
  borderStyle: theme.borderStyle,
23921
26337
  flexDirection: 'column',
23922
26338
  width,
23923
26339
  paddingX: 1,
23924
- }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...trailerLines.map((line, index) => h(Text, {
23925
- key: `detail-trailer-${index}`,
23926
- dimColor: index > 0,
23927
- }, truncate$1(line, width - 4))));
26340
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
26341
+ }
26342
+ /**
26343
+ * Render the trailing "Actions:" section that surfaces which keystrokes
26344
+ * apply to whatever the inspector is focused on. Keys are colored with
26345
+ * `theme.colors.accent` so they pop as the actionable element. Destructive
26346
+ * actions get the danger color plus a `[!]` marker so they don't blend
26347
+ * into the cherry-pick / yank rows.
26348
+ *
26349
+ * Truncates labels when the inspector is narrow (down to the 26-cell
26350
+ * minimum from `getLogInkLayout`) so an overflowing label never wraps and
26351
+ * collides with the next row.
26352
+ */
26353
+ function renderInspectorActionsSection(h, Text, context, width, theme) {
26354
+ const actions = getInspectorActions(context);
26355
+ if (!actions.length)
26356
+ return [];
26357
+ // Width budget for each row: subtract padding + " " gutter, the key
26358
+ // column (left-padded to 5 cells so labels align), the " " gap
26359
+ // between key and label, and the optional " [!]" suffix (5 cells).
26360
+ const KEY_COLUMN = 5;
26361
+ const GAP = ' ';
26362
+ const DESTRUCTIVE_SUFFIX = ' [!]';
26363
+ const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
26364
+ const nodes = [
26365
+ h(Text, { key: 'actions-spacer' }, ''),
26366
+ h(Text, { key: 'actions-title' }, 'Actions:'),
26367
+ ...actions.map((action, index) => {
26368
+ const keyCell = action.key.padEnd(KEY_COLUMN);
26369
+ const label = truncate$1(action.label, labelBudget);
26370
+ const children = [
26371
+ h(Text, {
26372
+ key: `actions-${index}-key`,
26373
+ color: action.destructive ? theme.colors.danger : theme.colors.accent,
26374
+ }, keyCell),
26375
+ GAP,
26376
+ label,
26377
+ ];
26378
+ if (action.destructive) {
26379
+ children.push(h(Text, {
26380
+ key: `actions-${index}-mark`,
26381
+ color: theme.colors.danger,
26382
+ dimColor: false,
26383
+ }, DESTRUCTIVE_SUFFIX));
26384
+ }
26385
+ return h(Text, { key: `actions-${index}` }, ...children);
26386
+ }),
26387
+ ];
26388
+ return nodes;
23928
26389
  }
23929
26390
  /**
23930
26391
  * Build a commit URL for the repo when GitHub provider info is available.
@@ -24228,16 +26689,34 @@ function renderInputPromptPanel(h, components, state, width, theme, focused) {
24228
26689
  if (!prompt) {
24229
26690
  return h(Box, { width });
24230
26691
  }
26692
+ const accent = theme.noColor ? undefined : theme.colors.accent;
26693
+ // Multi-line prompts (#806) split on newline and render one Text
26694
+ // row per buffer line — the cursor sits at the end of the last
26695
+ // line via the trailing `_`. Single-line prompts collapse to the
26696
+ // original one-row layout for muscle-memory continuity.
26697
+ const promptLines = prompt.multiline ? prompt.value.split('\n') : [prompt.value];
26698
+ if (promptLines.length === 0) {
26699
+ promptLines.push('');
26700
+ }
26701
+ const valueRows = promptLines.map((line, index) => {
26702
+ const isLast = index === promptLines.length - 1;
26703
+ const display = isLast ? `${line}_` : line;
26704
+ return h(Text, {
26705
+ key: `prompt-line-${index}`,
26706
+ bold: true,
26707
+ color: accent,
26708
+ }, truncate$1(display, width - 4));
26709
+ });
26710
+ const hint = prompt.multiline
26711
+ ? 'Enter newline · Ctrl+d submit · Esc cancel · Ctrl+u clear'
26712
+ : 'Enter submit · Esc cancel · Ctrl+u clear';
24231
26713
  return h(Box, {
24232
26714
  borderColor: focusBorderColor(theme, focused),
24233
26715
  borderStyle: theme.borderStyle,
24234
26716
  flexDirection: 'column',
24235
26717
  width,
24236
26718
  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'));
26719
+ }, 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
26720
  }
24242
26721
  function renderConfirmationPanel(h, components, state, width, theme, focused) {
24243
26722
  const { Box, Text } = components;
@@ -24411,16 +26890,31 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
24411
26890
  ? [h(Text, { key: 'palette-recent-hint', dimColor: true }, '· marks recently-used')]
24412
26891
  : []), ...itemLines);
24413
26892
  }
24414
- function renderFooter(h, components, state, theme, idleTip) {
26893
+ function renderFooter(h, components, state, context, theme, idleTip) {
24415
26894
  const { Box, Text } = components;
26895
+ // Sidebar item count drives the per-tab footer hints — when items are
26896
+ // present the footer surfaces in-sidebar ops (checkout / apply / pop /
26897
+ // drop), otherwise it falls back to the generic "enter open" hint.
26898
+ const sidebarItemCount = (() => {
26899
+ switch (state.sidebarTab) {
26900
+ case 'branches': return context.branches?.localBranches.length;
26901
+ case 'tags': return context.tags?.tags.length;
26902
+ case 'stashes': return context.stashes?.stashes.length;
26903
+ case 'worktrees': return context.worktreeList?.worktrees.length;
26904
+ default: return undefined;
26905
+ }
26906
+ })();
24416
26907
  const hints = getLogInkFooterHints({
24417
26908
  activeView: state.activeView,
24418
26909
  diffSource: state.diffSource,
26910
+ diffViewMode: state.diffViewMode,
24419
26911
  filterMode: state.filterMode,
24420
26912
  focus: state.focus,
24421
26913
  pendingKey: state.pendingKey,
24422
26914
  showCommandPalette: state.showCommandPalette,
24423
26915
  showHelp: state.showHelp,
26916
+ sidebarTab: state.sidebarTab,
26917
+ sidebarItemCount,
24424
26918
  });
24425
26919
  // Real status messages always win; idle tips only fill the slot when it
24426
26920
  // would otherwise be empty.
@@ -24465,7 +26959,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
24465
26959
  const git = options.git || getRepo();
24466
26960
  const rows = options.rows || (await getLogRows(git, logArgv));
24467
26961
  await startInkInteractiveLog(git, rows, {}, {
24468
- appLabel: 'coco ui',
26962
+ appLabel: 'coco',
24469
26963
  idleTips: config.logTui?.idleTips,
24470
26964
  initialView: 'history',
24471
26965
  logArgv,
@@ -24478,7 +26972,7 @@ async function startCocoUi(argv) {
24478
26972
  const logArgv = createLogArgvFromUiArgv(argv);
24479
26973
  const rows = await getLogRows(git, logArgv);
24480
26974
  await startInkInteractiveLog(git, rows, {}, {
24481
- appLabel: 'coco ui',
26975
+ appLabel: 'coco',
24482
26976
  idleTips: config.logTui?.idleTips,
24483
26977
  initialView: argv.view || 'history',
24484
26978
  logArgv,