git-coco 0.38.0 → 0.40.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 +1130 -197
  2. package/dist/index.js +1130 -197
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.38.0";
81
+ const BUILD_VERSION = "0.40.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -14772,84 +14772,6 @@ function formatInkRefLabels(refs) {
14772
14772
  return refs.length ? ` ${refs.map((ref) => `[${ref}]`).join(' ')}` : '';
14773
14773
  }
14774
14774
 
14775
- function countLabel(count, singular, plural = `${singular}s`) {
14776
- return `${count} ${count === 1 ? singular : plural}`;
14777
- }
14778
- function getLogInkWorkflowSections(context) {
14779
- const currentBranch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
14780
- const dirty = context.branches?.dirty ? 'dirty worktree' : 'clean worktree';
14781
- const loading = context.contextLoading;
14782
- const currentPullRequest = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
14783
- const repository = context.provider?.repository;
14784
- const repoName = repository?.owner && repository.name
14785
- ? `${repository.owner}/${repository.name}`
14786
- : repository?.message || 'local repository';
14787
- const operation = context.operation;
14788
- const worktree = context.worktree;
14789
- return [
14790
- {
14791
- title: 'Branch',
14792
- lines: [
14793
- `Current: ${currentBranch}`,
14794
- `State: ${dirty}`,
14795
- loading && !context.branches
14796
- ? 'Branch data loading'
14797
- : context.branches
14798
- ? `${countLabel(context.branches.localBranches.length, 'local branch', 'local branches')} | ${countLabel(context.branches.remoteBranches.length, 'remote branch', 'remote branches')}`
14799
- : 'Branch data unavailable',
14800
- ],
14801
- },
14802
- {
14803
- title: 'Provider / PR',
14804
- lines: [
14805
- `Repository: ${repoName}`,
14806
- loading && !context.provider && !context.pullRequest
14807
- ? 'Provider and pull request data loading'
14808
- : currentPullRequest
14809
- ? `PR #${currentPullRequest.number} ${currentPullRequest.state}${currentPullRequest.isDraft ? ' draft' : ''}`
14810
- : 'No pull request detected for current branch',
14811
- context.provider?.authenticated === false ? 'Provider auth: offline' : 'Provider auth: available',
14812
- ],
14813
- },
14814
- {
14815
- title: 'Status',
14816
- lines: loading && !worktree
14817
- ? ['Status data loading']
14818
- : worktree
14819
- ? [
14820
- `${countLabel(worktree.stagedCount, 'staged file')}`,
14821
- `${countLabel(worktree.unstagedCount, 'unstaged file')}`,
14822
- `${countLabel(worktree.untrackedCount, 'untracked file')}`,
14823
- ]
14824
- : ['Status data unavailable'],
14825
- },
14826
- {
14827
- title: 'Tags / Stashes / Worktrees',
14828
- lines: [
14829
- loading && !context.tags ? 'Tags loading' : context.tags ? countLabel(context.tags.tags.length, 'tag') : 'Tags unavailable',
14830
- loading && !context.stashes ? 'Stashes loading' : context.stashes ? countLabel(context.stashes.stashes.length, 'stash', 'stashes') : 'Stashes unavailable',
14831
- context.worktreeList
14832
- ? countLabel(context.worktreeList.worktrees.length, 'worktree')
14833
- : loading
14834
- ? 'Worktrees loading'
14835
- : 'Worktrees unavailable',
14836
- ],
14837
- },
14838
- {
14839
- title: 'Operation / AI',
14840
- lines: [
14841
- loading && !operation
14842
- ? 'Operation data loading'
14843
- : operation?.operation
14844
- ? `${operation.operation} in progress with ${countLabel(operation.conflictedFiles.length, 'conflict')}`
14845
- : 'No merge, rebase, cherry-pick, or revert in progress',
14846
- context.selectedCommit
14847
- ? `AI actions target ${context.selectedCommit.shortHash}; estimates shown before execution`
14848
- : 'AI actions require a selected commit',
14849
- ],
14850
- },
14851
- ];
14852
- }
14853
14775
  function getLogInkWorkflowActions() {
14854
14776
  return [
14855
14777
  {
@@ -14983,6 +14905,35 @@ function getLogInkWorkflowActions() {
14983
14905
  kind: 'normal',
14984
14906
  requiresConfirmation: false,
14985
14907
  },
14908
+ // Status surface group-level batch ops (#791 follow-up). Triggered
14909
+ // by Enter when the cursor is on a status group header
14910
+ // (Staged / Unstaged / Untracked). Empty `key` keeps them
14911
+ // palette-discoverable without registering a global hotkey — the
14912
+ // Enter-on-header path in inkInput is the canonical trigger.
14913
+ {
14914
+ id: 'unstage-all-staged',
14915
+ key: '',
14916
+ label: 'Unstage all staged files',
14917
+ description: 'Unstage every file currently in the staged group.',
14918
+ kind: 'normal',
14919
+ requiresConfirmation: false,
14920
+ },
14921
+ {
14922
+ id: 'stage-all-unstaged',
14923
+ key: '',
14924
+ label: 'Stage all unstaged files',
14925
+ description: 'Stage every modified-but-not-staged file.',
14926
+ kind: 'normal',
14927
+ requiresConfirmation: false,
14928
+ },
14929
+ {
14930
+ id: 'stage-all-untracked',
14931
+ key: '',
14932
+ label: 'Stage all untracked files',
14933
+ description: 'Add every untracked file to the index after confirmation.',
14934
+ kind: 'destructive',
14935
+ requiresConfirmation: true,
14936
+ },
14986
14937
  {
14987
14938
  id: 'delete-branch',
14988
14939
  key: 'D',
@@ -15093,6 +15044,38 @@ function getLogInkWorkflowActions() {
15093
15044
  kind: 'destructive',
15094
15045
  requiresConfirmation: true,
15095
15046
  },
15047
+ {
15048
+ // Per-view-only: scoped to the history view in inkInput (key `B`).
15049
+ // The prompt itself is the affirmative gate — the user has to
15050
+ // type a branch name before anything happens — so this skips the
15051
+ // y-confirm path. Empty key keeps it palette-discoverable; the
15052
+ // palette path can't synthesize a branch name and surfaces a
15053
+ // hint instead.
15054
+ //
15055
+ // Distinct from `create-branch` (palette / `+` on branches view),
15056
+ // which uses `git switch -c` and switches onto the new branch.
15057
+ // This workflow uses `git branch <name> <sha>` and stays put —
15058
+ // GitKraken's "create branch here" semantic.
15059
+ id: 'create-branch-here',
15060
+ key: '',
15061
+ label: 'Create branch from commit',
15062
+ description: 'Create a branch pointed at the cursored commit (does not switch).',
15063
+ kind: 'normal',
15064
+ requiresConfirmation: false,
15065
+ },
15066
+ {
15067
+ // Per-view-only: scoped to the history view in inkInput via the
15068
+ // `gT` chord (bare `T` is taken by delete-tag on the tags view).
15069
+ // Same prompt-as-confirmation pattern as create-branch-here.
15070
+ // Lightweight tag — annotated tags remain available through the
15071
+ // existing `+` flow on the tags view.
15072
+ id: 'create-tag-here',
15073
+ key: '',
15074
+ label: 'Create tag at commit',
15075
+ description: 'Create a lightweight tag at the cursored commit.',
15076
+ kind: 'normal',
15077
+ requiresConfirmation: false,
15078
+ },
15096
15079
  {
15097
15080
  // Per-view-only: scoped to the history view in inkInput. `i`
15098
15081
  // (lowercase) is used instead of `I` so the existing `I`
@@ -15105,6 +15088,38 @@ function getLogInkWorkflowActions() {
15105
15088
  kind: 'destructive',
15106
15089
  requiresConfirmation: true,
15107
15090
  },
15091
+ {
15092
+ // Per-view-only: scoped to the history view in inkInput (key `B`).
15093
+ // The prompt itself is the affirmative gate — the user has to
15094
+ // type a branch name before anything happens — so this skips the
15095
+ // y-confirm path. Empty key keeps it palette-discoverable; the
15096
+ // palette path can't synthesize a branch name and surfaces a
15097
+ // hint instead.
15098
+ //
15099
+ // Distinct from `create-branch` (palette / `+` on branches view),
15100
+ // which uses `git switch -c` and switches onto the new branch.
15101
+ // This workflow uses `git branch <name> <sha>` and stays put —
15102
+ // GitKraken's "create branch here" semantic.
15103
+ id: 'create-branch-here',
15104
+ key: '',
15105
+ label: 'Create branch from commit',
15106
+ description: 'Create a branch pointed at the cursored commit (does not switch).',
15107
+ kind: 'normal',
15108
+ requiresConfirmation: false,
15109
+ },
15110
+ {
15111
+ // Per-view-only: scoped to the history view in inkInput via the
15112
+ // `gT` chord (bare `T` is taken by delete-tag on the tags view).
15113
+ // Same prompt-as-confirmation pattern as create-branch-here.
15114
+ // Lightweight tag — annotated tags remain available through the
15115
+ // existing `+` flow on the tags view.
15116
+ id: 'create-tag-here',
15117
+ key: '',
15118
+ label: 'Create tag at commit',
15119
+ description: 'Create a lightweight tag at the cursored commit.',
15120
+ kind: 'normal',
15121
+ requiresConfirmation: false,
15122
+ },
15108
15123
  {
15109
15124
  id: 'ai-commit-summary',
15110
15125
  key: 'I',
@@ -15639,9 +15654,12 @@ function getLogInkFooterHints(options) {
15639
15654
  // History view default hints. Mutating ops (`c` cherry-pick, `R`
15640
15655
  // revert, `Z` reset, `i` interactive-rebase) all route through a
15641
15656
  // y-confirm or mode prompt — none fire silently from the keystroke.
15642
- // Grouped into a compact `c/R/Z/i mutate` chip so the footer stays
15643
- // scannable; full descriptions live in `?` help and the palette.
15644
- contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15657
+ // `B` create-branch-here and `gT` create-tag-here use a prompt as
15658
+ // the affirmative gate (typing the name is the confirmation).
15659
+ // Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
15660
+ // the footer stays scannable; full descriptions live in `?` help
15661
+ // and the palette.
15662
+ contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
15645
15663
  global: NORMAL_GLOBAL_HINTS,
15646
15664
  };
15647
15665
  }
@@ -15933,6 +15951,88 @@ function extractDiffHunk(input) {
15933
15951
  return { patchText };
15934
15952
  }
15935
15953
 
15954
+ /**
15955
+ * Hardcoded per-entity action lists surfaced inside the right-hand
15956
+ * inspector panel. The inspector used to repeat the repo / branch /
15957
+ * status content the top header and left sidebar already show; we drop
15958
+ * that trailer in favor of an actionable cheat-sheet so the user knows
15959
+ * exactly which keystrokes apply to whatever they have under the cursor.
15960
+ *
15961
+ * Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
15962
+ * - Most per-entity actions live in `inkInput.ts` as direct keystroke
15963
+ * handlers (e.g. `c` cherry-pick, `R` revert) rather than as
15964
+ * globally-registered bindings, so the registry would be a partial
15965
+ * view at best.
15966
+ * - The bindings registry's `contexts` model (normal / search / focus
15967
+ * name) does not cleanly map to inspector entity types like "branch"
15968
+ * or "tag". Filtering it would mean replicating the same per-view
15969
+ * scoping logic the input dispatcher already encodes.
15970
+ * - New per-entity actions are added infrequently — the maintenance
15971
+ * cost of mirroring them here is low and keeps this file the single
15972
+ * source of truth for "what shows in the inspector".
15973
+ *
15974
+ * If you wire up a new per-entity keystroke in `inkInput.ts` — for
15975
+ * example a "create branch from this commit" or "create tag from this
15976
+ * commit" action — add the matching row to the relevant array below so
15977
+ * it shows up in the inspector automatically.
15978
+ */
15979
+ const HISTORY_COMMIT_ACTIONS = [
15980
+ { key: 'enter', label: 'Open diff' },
15981
+ { key: 'c', label: 'Cherry-pick' },
15982
+ { key: 'R', label: 'Revert', destructive: true },
15983
+ { key: 'Z', label: 'Reset to commit', destructive: true },
15984
+ { key: 'i', label: 'Interactive rebase', destructive: true },
15985
+ { key: 'y', label: 'Yank hash' },
15986
+ { key: 'Y', label: 'Yank short hash' },
15987
+ { key: 'O', label: 'Open in browser' },
15988
+ ];
15989
+ const BRANCH_ACTIONS = [
15990
+ { key: 'enter', label: 'Checkout' },
15991
+ { key: '+', label: 'New branch' },
15992
+ { key: 'R', label: 'Rename' },
15993
+ { key: 'u', label: 'Set upstream' },
15994
+ { key: 'D', label: 'Delete', destructive: true },
15995
+ { key: 'P', label: 'Push current' },
15996
+ { key: 'F', label: 'Fetch all' },
15997
+ { key: 'y', label: 'Yank name' },
15998
+ ];
15999
+ const TAG_ACTIONS = [
16000
+ { key: '+', label: 'New tag' },
16001
+ { key: 'P', label: 'Push tag' },
16002
+ { key: 'T', label: 'Delete', destructive: true },
16003
+ { key: 'R', label: 'Delete remote', destructive: true },
16004
+ { key: 'y', label: 'Yank name' },
16005
+ ];
16006
+ const STASH_ACTIONS = [
16007
+ { key: 'enter', label: 'Open diff' },
16008
+ { key: 'a', label: 'Apply' },
16009
+ { key: 'p', label: 'Pop' },
16010
+ { key: 'X', label: 'Drop', destructive: true },
16011
+ { key: 'y', label: 'Yank ref' },
16012
+ ];
16013
+ const WORKTREE_ACTIONS = [
16014
+ { key: 'W', label: 'Remove', destructive: true },
16015
+ { key: 'y', label: 'Yank path' },
16016
+ ];
16017
+ function getInspectorActions(context) {
16018
+ switch (context) {
16019
+ case 'history-commit':
16020
+ return HISTORY_COMMIT_ACTIONS;
16021
+ case 'branch':
16022
+ return BRANCH_ACTIONS;
16023
+ case 'tag':
16024
+ return TAG_ACTIONS;
16025
+ case 'stash':
16026
+ return STASH_ACTIONS;
16027
+ case 'worktree':
16028
+ return WORKTREE_ACTIONS;
16029
+ default: {
16030
+ const exhaustive = context;
16031
+ throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
16032
+ }
16033
+ }
16034
+ }
16035
+
15936
16036
  /**
15937
16037
  * Sort modes for the promoted views (P4.2).
15938
16038
  *
@@ -15952,23 +16052,31 @@ function cycleBranchSort(mode) {
15952
16052
  return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
15953
16053
  }
15954
16054
  function sortBranches(branches, mode) {
15955
- const copy = branches.slice();
16055
+ // Pin the current branch at index 0 regardless of sort mode (#806
16056
+ // follow-up). Lands the user's cursor on the active branch by
16057
+ // default and keeps the most-relevant row glued to the top of the
16058
+ // list as they cycle sorts.
16059
+ const current = branches.find((entry) => entry.current);
16060
+ const rest = branches.filter((entry) => !entry.current);
16061
+ const sortedRest = rest.slice();
15956
16062
  switch (mode) {
15957
16063
  case 'name':
15958
- return copy.sort((a, b) => a.shortName.localeCompare(b.shortName));
16064
+ sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
16065
+ break;
15959
16066
  case 'recent':
15960
16067
  // ISO-shaped dates compare byte-for-byte; descending so the freshest
15961
16068
  // branch sits at the top.
15962
- return copy.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
16069
+ sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
15963
16070
  a.shortName.localeCompare(b.shortName));
16071
+ break;
15964
16072
  case 'ahead':
15965
16073
  // ahead-first; ties broken by behind, then by name. Keeps "this branch
15966
16074
  // has unmerged work" in the user's first scroll.
15967
- return copy.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
16075
+ sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
15968
16076
  a.shortName.localeCompare(b.shortName));
15969
- default:
15970
- return copy;
16077
+ break;
15971
16078
  }
16079
+ return current ? [current, ...sortedRest] : sortedRest;
15972
16080
  }
15973
16081
  const TAG_SORT_MODES = ['recent', 'name'];
15974
16082
  const DEFAULT_TAG_SORT_MODE = 'recent';
@@ -16131,6 +16239,7 @@ function withPushedView(state, value) {
16131
16239
  diffSource: value === 'diff' ? state.diffSource : undefined,
16132
16240
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
16133
16241
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
16242
+ statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
16134
16243
  pendingKey: undefined,
16135
16244
  };
16136
16245
  }
@@ -16153,6 +16262,7 @@ function withPoppedView(state) {
16153
16262
  diffSource: next === 'diff' ? state.diffSource : undefined,
16154
16263
  stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
16155
16264
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
16265
+ statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
16156
16266
  pendingKey: undefined,
16157
16267
  };
16158
16268
  }
@@ -16170,6 +16280,7 @@ function withReplacedView(state, value) {
16170
16280
  diffSource: value === 'diff' ? state.diffSource : undefined,
16171
16281
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
16172
16282
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
16283
+ statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
16173
16284
  pendingKey: undefined,
16174
16285
  };
16175
16286
  }
@@ -16301,8 +16412,12 @@ function createLogInkState(rows, options = {}) {
16301
16412
  focus: 'commits',
16302
16413
  sidebarTab: 'status',
16303
16414
  userSidebarTab: 'status',
16415
+ sidebarHeaderFocused: false,
16416
+ statusGroupHeaderFocused: false,
16304
16417
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
16305
16418
  diffViewMode: 'unified',
16419
+ inspectorTab: 'inspector',
16420
+ inspectorActionIndex: 0,
16306
16421
  };
16307
16422
  }
16308
16423
  function getSelectedInkCommit(state) {
@@ -16343,12 +16458,21 @@ function applyLogInkAction(state, action) {
16343
16458
  return {
16344
16459
  ...state,
16345
16460
  focus: cycleValue(FOCUS_ORDER, state.focus, 1),
16461
+ // Reset header focus when leaving the sidebar so the next
16462
+ // re-entry starts on items rather than mid-flag.
16463
+ sidebarHeaderFocused: false,
16464
+ // Same idea for the status group header — Tab cycling away
16465
+ // from 'commits' should always land back on a real file when
16466
+ // the user returns.
16467
+ statusGroupHeaderFocused: false,
16346
16468
  pendingKey: undefined,
16347
16469
  };
16348
16470
  case 'focusPrevious':
16349
16471
  return {
16350
16472
  ...state,
16351
16473
  focus: cycleValue(FOCUS_ORDER, state.focus, -1),
16474
+ sidebarHeaderFocused: false,
16475
+ statusGroupHeaderFocused: false,
16352
16476
  pendingKey: undefined,
16353
16477
  };
16354
16478
  case 'move':
@@ -16360,6 +16484,28 @@ function applyLogInkAction(state, action) {
16360
16484
  pendingCommitFocused: false,
16361
16485
  pendingKey: undefined,
16362
16486
  };
16487
+ case 'selectCommitByHash': {
16488
+ // Locates a commit by its full or short hash within the active
16489
+ // filtered list and snaps the cursor to it. Used by the
16490
+ // branch/tag auto-jump effect (#806 follow-up): cursoring a
16491
+ // branch in the sidebar tracks the history view to that
16492
+ // branch's tip without the user manually scrolling. No-op when
16493
+ // the hash isn't in the loaded list (the runtime surfaces a
16494
+ // status hint in that case).
16495
+ const target = action.hash;
16496
+ const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
16497
+ if (index < 0) {
16498
+ return state;
16499
+ }
16500
+ return {
16501
+ ...state,
16502
+ selectedIndex: index,
16503
+ selectedFileIndex: 0,
16504
+ diffPreviewOffset: 0,
16505
+ pendingCommitFocused: false,
16506
+ pendingKey: undefined,
16507
+ };
16508
+ }
16363
16509
  case 'focusPendingCommit':
16364
16510
  return {
16365
16511
  ...state,
@@ -16391,6 +16537,9 @@ function applyLogInkAction(state, action) {
16391
16537
  selectedWorktreeFileIndex: clampIndex$1(state.selectedWorktreeFileIndex + action.delta, action.fileCount),
16392
16538
  selectedWorktreeHunkIndex: 0,
16393
16539
  worktreeDiffOffset: 0,
16540
+ // Cursor moved to a real file row — drop header focus so the
16541
+ // file Enter handler (open diff) is what fires next.
16542
+ statusGroupHeaderFocused: false,
16394
16543
  };
16395
16544
  }
16396
16545
  case 'moveBranch':
@@ -16399,6 +16548,77 @@ function applyLogInkAction(state, action) {
16399
16548
  selectedBranchIndex: clampIndex$1(state.selectedBranchIndex + action.delta, action.count),
16400
16549
  pendingKey: undefined,
16401
16550
  };
16551
+ case 'resetBranchSelection':
16552
+ // Snap the branches sidebar / view cursor back to position 0.
16553
+ // Used after a successful checkout (#806 follow-up): combined
16554
+ // with the "current branch pinned at top" rule from #809, this
16555
+ // lands the user's cursor on the just-checked-out branch.
16556
+ return {
16557
+ ...state,
16558
+ selectedBranchIndex: 0,
16559
+ pendingKey: undefined,
16560
+ };
16561
+ case 'setSidebarHeaderFocused':
16562
+ return {
16563
+ ...state,
16564
+ sidebarHeaderFocused: action.value,
16565
+ pendingKey: undefined,
16566
+ };
16567
+ case 'setStatusGroupHeaderFocused':
16568
+ return {
16569
+ ...state,
16570
+ statusGroupHeaderFocused: action.value,
16571
+ pendingKey: undefined,
16572
+ };
16573
+ case 'jumpToStatusGroup':
16574
+ // Used by ←/→ on the status surface to land on the first file of
16575
+ // the previous / next non-empty group. Clears header focus so the
16576
+ // user is on a real file after the jump (matches the
16577
+ // sidebar pattern where ←/→ between tabs lands on items, not on
16578
+ // the next tab's header).
16579
+ return {
16580
+ ...state,
16581
+ selectedWorktreeFileIndex: Math.max(0, action.targetIndex),
16582
+ selectedWorktreeHunkIndex: 0,
16583
+ worktreeDiffOffset: 0,
16584
+ statusGroupHeaderFocused: false,
16585
+ pendingKey: undefined,
16586
+ };
16587
+ case 'setInspectorTab':
16588
+ return {
16589
+ ...state,
16590
+ inspectorTab: action.value,
16591
+ // Reset the action cursor so a fresh tab visit always starts
16592
+ // on the first action, regardless of where the user left off
16593
+ // in a previous entity context.
16594
+ inspectorActionIndex: 0,
16595
+ pendingKey: undefined,
16596
+ };
16597
+ case 'cycleInspectorTab': {
16598
+ // Two-tab toggle — `delta` is symmetrical so direction does not
16599
+ // matter, but we keep the action shape consistent with the
16600
+ // sidebar's `nextSidebarTab` / `previousSidebarTab` so callers
16601
+ // can mirror the sidebar pattern verbatim.
16602
+ const next = state.inspectorTab === 'inspector' ? 'actions' : 'inspector';
16603
+ return {
16604
+ ...state,
16605
+ inspectorTab: next,
16606
+ inspectorActionIndex: 0,
16607
+ pendingKey: undefined,
16608
+ };
16609
+ }
16610
+ case 'moveInspectorAction':
16611
+ return {
16612
+ ...state,
16613
+ inspectorActionIndex: clampIndex$1(state.inspectorActionIndex + action.delta, action.actionCount),
16614
+ pendingKey: undefined,
16615
+ };
16616
+ case 'resetInspectorActionIndex':
16617
+ return {
16618
+ ...state,
16619
+ inspectorActionIndex: 0,
16620
+ pendingKey: undefined,
16621
+ };
16402
16622
  case 'moveTag':
16403
16623
  return {
16404
16624
  ...state,
@@ -16468,6 +16688,10 @@ function applyLogInkAction(state, action) {
16468
16688
  ...state,
16469
16689
  statusFilterMask: allOff ? { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK } : next,
16470
16690
  selectedWorktreeFileIndex: 0,
16691
+ // Group composition changed — header focus would be ambiguous
16692
+ // (cursor lands on file 0 which may belong to a different
16693
+ // group now). Reset to clear the indicator.
16694
+ statusGroupHeaderFocused: false,
16471
16695
  pendingKey: undefined,
16472
16696
  };
16473
16697
  }
@@ -16638,6 +16862,13 @@ function applyLogInkAction(state, action) {
16638
16862
  return {
16639
16863
  ...state,
16640
16864
  focus: action.value,
16865
+ // Reset sidebar header focus when leaving the sidebar so a
16866
+ // re-entry starts on items rather than mid-flag.
16867
+ sidebarHeaderFocused: action.value === 'sidebar' ? state.sidebarHeaderFocused : false,
16868
+ // The status group header lives in the 'commits' focus on
16869
+ // the status view — clear when focus moves away so a
16870
+ // re-entry starts on a real file.
16871
+ statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
16641
16872
  pendingKey: undefined,
16642
16873
  };
16643
16874
  case 'setPendingKey':
@@ -16839,6 +17070,82 @@ function action(actionValue) {
16839
17070
  action: actionValue,
16840
17071
  };
16841
17072
  }
17073
+ /**
17074
+ * Resolve which inspector action context applies for the current
17075
+ * state. Today only history commits expose actions in the inspector
17076
+ * (the renderer hard-coded `'history-commit'`); future PRs can fan
17077
+ * this out to branch / tag / stash / worktree contexts as the
17078
+ * inspector gains entity-aware sections. Returns `undefined` when no
17079
+ * actions section should be shown (so the cursor model stays a
17080
+ * no-op).
17081
+ */
17082
+ function resolveInspectorActionContext(state) {
17083
+ if (state.activeView === 'history' && !state.pendingCommitFocused) {
17084
+ return 'history-commit';
17085
+ }
17086
+ return undefined;
17087
+ }
17088
+ function getInspectorActionsForState(state) {
17089
+ const ctx = resolveInspectorActionContext(state);
17090
+ return ctx ? getInspectorActions(ctx) : [];
17091
+ }
17092
+ /**
17093
+ * Synthesize the events that fire when the user presses Enter on a
17094
+ * cursored inspector action (#791 follow-up). Mirrors
17095
+ * `getLogInkPaletteExecuteEvents` — each action's `key` field
17096
+ * routes to the same dispatch the corresponding keystroke would
17097
+ * trigger from the history view's commit cursor. Per-key dispatch
17098
+ * (rather than recursively re-running the keystroke through
17099
+ * `getLogInkInputEvents`) avoids the gating problem: most history
17100
+ * keystroke handlers require `state.focus === 'commits'`, but the
17101
+ * inspector executor fires from `state.focus === 'detail'`.
17102
+ */
17103
+ function getInspectorActionExecuteEvents(inspectorAction, state) {
17104
+ const commit = state.filteredCommits[state.selectedIndex];
17105
+ const requireCommit = (fn) => {
17106
+ if (!commit) {
17107
+ return [action({ type: 'setStatus', value: 'No commit selected' })];
17108
+ }
17109
+ return fn(commit.hash, state.selectedIndex);
17110
+ };
17111
+ switch (inspectorAction.key) {
17112
+ case 'enter':
17113
+ return requireCommit((sha, commitIndex) => [
17114
+ action({ type: 'navigateOpenDiffForCommit', sha, commitIndex }),
17115
+ ]);
17116
+ case 'c':
17117
+ return requireCommit(() => [
17118
+ action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' }),
17119
+ ]);
17120
+ case 'R':
17121
+ return requireCommit(() => [
17122
+ action({ type: 'setPendingConfirmation', value: 'revert-commit' }),
17123
+ ]);
17124
+ case 'Z':
17125
+ return requireCommit(() => [
17126
+ action({
17127
+ type: 'openInputPrompt',
17128
+ kind: 'reset-mode',
17129
+ label: 'Reset mode (soft / mixed / hard)',
17130
+ }),
17131
+ ]);
17132
+ case 'i':
17133
+ return requireCommit(() => [
17134
+ action({ type: 'setPendingConfirmation', value: 'interactive-rebase' }),
17135
+ ]);
17136
+ case 'y':
17137
+ return requireCommit(() => [{ type: 'yankFromActiveView' }]);
17138
+ case 'Y':
17139
+ return requireCommit(() => [{ type: 'yankFromActiveView', short: true }]);
17140
+ case 'O':
17141
+ return [{ type: 'runWorkflowAction', id: 'open-pr' }];
17142
+ default:
17143
+ return [action({
17144
+ type: 'setStatus',
17145
+ value: `Action ${inspectorAction.key} not yet wired`,
17146
+ })];
17147
+ }
17148
+ }
16842
17149
  /**
16843
17150
  * Build the events needed to apply the hunk under the diff cursor. The
16844
17151
  * runtime workflow handler expects payload format `<target>\n<patch>`
@@ -17484,6 +17791,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17484
17791
  action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
17485
17792
  ];
17486
17793
  }
17794
+ // `gT` chord: create a lightweight tag at the cursored commit on the
17795
+ // history view. Bare `T` is taken (delete-tag on the tags view) so we
17796
+ // use the chord. Mirrors `gH` exactly — uppercase letter after the
17797
+ // `g` chord prefix, distinct from the lowercase `gt` chord which
17798
+ // jumps to the tags view. The prompt is the affirmative gate.
17799
+ if (state.pendingKey === 'g' && inputValue === 'T') {
17800
+ if (state.activeView === 'history' &&
17801
+ state.focus === 'commits' &&
17802
+ state.filteredCommits.length > 0 &&
17803
+ !state.pendingCommitFocused) {
17804
+ return [
17805
+ action({ type: 'setPendingKey', value: undefined }),
17806
+ action({
17807
+ type: 'openInputPrompt',
17808
+ kind: 'create-tag-here',
17809
+ label: 'New tag name (at cursored commit)',
17810
+ }),
17811
+ ];
17812
+ }
17813
+ return [
17814
+ action({ type: 'setPendingKey', value: undefined }),
17815
+ action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
17816
+ ];
17817
+ }
17487
17818
  if (inputValue === 'g') {
17488
17819
  if (state.pendingKey === 'g') {
17489
17820
  return [
@@ -17565,6 +17896,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17565
17896
  hunkOffsets: context.commitDiffHunkOffsets,
17566
17897
  })];
17567
17898
  }
17899
+ // Inspector focused: cycle the inspector tab. The renderer only
17900
+ // honors the tab field on short terminals (where the inspector
17901
+ // collapses into a tabbed layout), but we let the user pre-set
17902
+ // their preference on tall terminals too.
17903
+ if (state.focus === 'detail') {
17904
+ return [action({ type: 'cycleInspectorTab', delta: -1 })];
17905
+ }
17568
17906
  return [action({ type: 'previousSidebarTab' })];
17569
17907
  }
17570
17908
  if (inputValue === ']') {
@@ -17589,6 +17927,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17589
17927
  hunkOffsets: context.commitDiffHunkOffsets,
17590
17928
  })];
17591
17929
  }
17930
+ if (state.focus === 'detail') {
17931
+ return [action({ type: 'cycleInspectorTab', delta: 1 })];
17932
+ }
17592
17933
  return [action({ type: 'nextSidebarTab' })];
17593
17934
  }
17594
17935
  // Status surface intercepts 1/2/3 before the sidebar-tab numeric
@@ -17616,11 +17957,62 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17616
17957
  if (key.rightArrow && state.focus === 'sidebar') {
17617
17958
  return [action({ type: 'nextSidebarTab' })];
17618
17959
  }
17960
+ // ←/→ on the status surface jump between the staged / unstaged /
17961
+ // untracked groups — the horizontal axis is "between groups", the
17962
+ // vertical axis (↑/↓ below) is "within the active group's files".
17963
+ // Lands on the first file of the target group (clears header
17964
+ // focus) so the user is always on a real file after a jump,
17965
+ // mirroring the sidebar's tab-switch landing behavior.
17966
+ if ((key.leftArrow || key.rightArrow) &&
17967
+ state.activeView === 'status' &&
17968
+ state.focus === 'commits' &&
17969
+ context.statusGroups &&
17970
+ context.statusGroups.length > 1) {
17971
+ const groups = context.statusGroups;
17972
+ const currentIndex = groups.findIndex((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
17973
+ state.selectedWorktreeFileIndex < group.startIndex + group.count);
17974
+ const fallback = currentIndex >= 0 ? currentIndex : 0;
17975
+ const delta = key.leftArrow ? -1 : 1;
17976
+ const nextIndex = Math.max(0, Math.min(groups.length - 1, fallback + delta));
17977
+ if (nextIndex !== fallback) {
17978
+ return [action({ type: 'jumpToStatusGroup', targetIndex: groups[nextIndex].startIndex })];
17979
+ }
17980
+ return [];
17981
+ }
17619
17982
  if (key.upArrow || inputValue === 'k') {
17983
+ // Inspector Actions tab: ↑/↓ moves the cursor through the
17984
+ // executable action list. Wins over moveDetailFile so a
17985
+ // history-commit explore with both file list AND actions visible
17986
+ // navigates the actions when the user has [/]-toggled to the
17987
+ // actions tab. (#791 follow-up)
17988
+ if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
17989
+ return [action({
17990
+ type: 'moveInspectorAction',
17991
+ delta: -1,
17992
+ actionCount: context.inspectorActionCount,
17993
+ })];
17994
+ }
17620
17995
  if (state.focus === 'detail' && context.detailFileCount) {
17621
17996
  return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
17622
17997
  }
17623
17998
  if (state.activeView === 'status' && context.worktreeFileCount) {
17999
+ // Already on the group header — ↑ is a no-op (use ←/→ to switch
18000
+ // groups). Mirrors the sidebar's "header is the top of the
18001
+ // hierarchy" behavior.
18002
+ if (state.statusGroupHeaderFocused) {
18003
+ return [];
18004
+ }
18005
+ // Cursor at the first file of its group → promote to the group
18006
+ // header rather than crossing the boundary into the previous
18007
+ // group's last file. Keeps the cursor inside its current
18008
+ // container; ←/→ is the explicit way to move between groups.
18009
+ if (context.statusGroups && context.statusGroups.length > 0) {
18010
+ const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
18011
+ state.selectedWorktreeFileIndex < group.startIndex + group.count);
18012
+ if (currentGroup && state.selectedWorktreeFileIndex === currentGroup.startIndex) {
18013
+ return [action({ type: 'setStatusGroupHeaderFocused', value: true })];
18014
+ }
18015
+ }
17624
18016
  return [action({
17625
18017
  type: 'moveWorktreeFile',
17626
18018
  delta: -1,
@@ -17645,6 +18037,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17645
18037
  previewLineCount: context.previewLineCount,
17646
18038
  })];
17647
18039
  }
18040
+ // Sidebar header focus: ↑ at item index 0 promotes the cursor
18041
+ // onto the active tab's header. Pressing ↑ again is a no-op
18042
+ // (use ←/→ to switch between tab headers, Enter to drill in).
18043
+ // Only triggers when the sidebar is focused on a content tab —
18044
+ // dedicated promoted views (`g b` etc.) keep the legacy clamp
18045
+ // behavior because they have no header to escape to.
18046
+ if (state.focus === 'sidebar' && !state.sidebarHeaderFocused) {
18047
+ if (state.sidebarTab === 'branches' && state.selectedBranchIndex === 0 && (context.branchCount ?? 0) > 0) {
18048
+ return [action({ type: 'setSidebarHeaderFocused', value: true })];
18049
+ }
18050
+ if (state.sidebarTab === 'tags' && state.selectedTagIndex === 0 && (context.tagCount ?? 0) > 0) {
18051
+ return [action({ type: 'setSidebarHeaderFocused', value: true })];
18052
+ }
18053
+ if (state.sidebarTab === 'stashes' && state.selectedStashIndex === 0 && (context.stashCount ?? 0) > 0) {
18054
+ return [action({ type: 'setSidebarHeaderFocused', value: true })];
18055
+ }
18056
+ if (state.sidebarTab === 'worktrees' && state.selectedWorktreeListIndex === 0 && (context.worktreeListCount ?? 0) > 0) {
18057
+ return [action({ type: 'setSidebarHeaderFocused', value: true })];
18058
+ }
18059
+ }
18060
+ // Already on the header — ↑ is a no-op (←/→ switches tabs).
18061
+ if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
18062
+ return [];
18063
+ }
17648
18064
  if (isBranchActionTarget(state) && context.branchCount) {
17649
18065
  return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
17650
18066
  }
@@ -17679,10 +18095,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17679
18095
  if (state.activeView === 'history' && state.pendingCommitFocused) {
17680
18096
  return [action({ type: 'unfocusPendingCommit' })];
17681
18097
  }
18098
+ if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
18099
+ return [action({
18100
+ type: 'moveInspectorAction',
18101
+ delta: 1,
18102
+ actionCount: context.inspectorActionCount,
18103
+ })];
18104
+ }
17682
18105
  if (state.focus === 'detail' && context.detailFileCount) {
17683
18106
  return [action({ type: 'moveDetailFile', delta: 1, fileCount: context.detailFileCount })];
17684
18107
  }
17685
18108
  if (state.activeView === 'status' && context.worktreeFileCount) {
18109
+ // Header focused → ↓ re-enters the group at the cursored file
18110
+ // (which is already the group's first file by construction).
18111
+ // Just clear the flag.
18112
+ if (state.statusGroupHeaderFocused) {
18113
+ return [action({ type: 'setStatusGroupHeaderFocused', value: false })];
18114
+ }
17686
18115
  return [action({
17687
18116
  type: 'moveWorktreeFile',
17688
18117
  delta: 1,
@@ -17703,6 +18132,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17703
18132
  previewLineCount: context.previewLineCount,
17704
18133
  })];
17705
18134
  }
18135
+ // Sidebar header focused: ↓ re-enters the list at index 0.
18136
+ // Clears the header flag and snaps the per-entity selection to 0
18137
+ // (mirrors the existing default selection behavior on first
18138
+ // sidebar focus).
18139
+ if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
18140
+ return [action({ type: 'setSidebarHeaderFocused', value: false })];
18141
+ }
17706
18142
  if (isBranchActionTarget(state) && context.branchCount) {
17707
18143
  return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
17708
18144
  }
@@ -17796,6 +18232,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17796
18232
  ];
17797
18233
  }
17798
18234
  }
18235
+ // Inspector Actions tab: Enter on the cursored action fires its
18236
+ // associated event (cherry-pick / revert / yank / etc.). Wins over
18237
+ // the file-list Enter below when the user has [/]-toggled to the
18238
+ // actions tab. Routes through `getInspectorActionExecuteEvents` so
18239
+ // the per-action dispatch table stays the single source of truth
18240
+ // for what each action does. (#791 follow-up)
18241
+ if (key.return &&
18242
+ state.focus === 'detail' &&
18243
+ state.inspectorTab === 'actions') {
18244
+ const actions = getInspectorActionsForState(state);
18245
+ const cursored = actions[state.inspectorActionIndex];
18246
+ if (cursored) {
18247
+ return getInspectorActionExecuteEvents(cursored, state);
18248
+ }
18249
+ }
17799
18250
  // From the inspector / commit-diff detail panel, Enter opens (or refocuses)
17800
18251
  // the diff view scoped to the currently-selected commit and file. Lets the
17801
18252
  // user drive the explore flow entirely from the right panel: j/k picks a
@@ -17834,7 +18285,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17834
18285
  const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
17835
18286
  const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
17836
18287
  sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
17837
- if (!hasInSidebarPrimaryAction) {
18288
+ // Three cases drill into the dedicated view:
18289
+ // 1. The cursor is on the tab header (user pressed ↑ at the
18290
+ // top of the list to escape the items — Enter explicitly
18291
+ // jumps to the dedicated view).
18292
+ // 2. The tab has no in-sidebar primary action defined (status,
18293
+ // tags, worktrees — drilling in is the canonical path).
18294
+ // 3. The tab has zero items (the dedicated view's empty state
18295
+ // tells the user what to do next).
18296
+ if (state.sidebarHeaderFocused || !hasInSidebarPrimaryAction) {
17838
18297
  const tabToView = {
17839
18298
  status: 'status',
17840
18299
  branches: 'branches',
@@ -17854,6 +18313,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17854
18313
  // Fall through — per-entity Enter handler below claims the keystroke.
17855
18314
  }
17856
18315
  if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
18316
+ // Group header focused → fire the group's batch workflow action.
18317
+ // Routed through the workflow runner so the runtime owns the
18318
+ // git invocation + status messaging consistently with the
18319
+ // single-file `space` toggle. The `payload` carries the group's
18320
+ // state ('staged' / 'unstaged' / 'untracked') so the runtime can
18321
+ // resolve which files to act on without re-deriving group state.
18322
+ if (state.statusGroupHeaderFocused && context.statusGroups) {
18323
+ const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
18324
+ state.selectedWorktreeFileIndex < group.startIndex + group.count);
18325
+ if (currentGroup) {
18326
+ const workflowId = currentGroup.state === 'staged'
18327
+ ? 'unstage-all-staged'
18328
+ : currentGroup.state === 'unstaged'
18329
+ ? 'stage-all-unstaged'
18330
+ : 'stage-all-untracked';
18331
+ return [{ type: 'runWorkflowAction', id: workflowId, payload: currentGroup.state }];
18332
+ }
18333
+ }
17857
18334
  return [action({
17858
18335
  type: 'navigateOpenDiffForWorktreeFile',
17859
18336
  fileIndex: state.selectedWorktreeFileIndex,
@@ -18088,6 +18565,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18088
18565
  !state.pendingCommitFocused) {
18089
18566
  return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
18090
18567
  }
18568
+ // `B` opens a create-branch prompt rooted at the cursored commit
18569
+ // (`git branch <name> <sha>` — does NOT switch to the new branch).
18570
+ // The prompt itself is the affirmative gate, so no separate y-confirm.
18571
+ // Bare uppercase `B` since the lowercase `b` is used by the `gb`
18572
+ // chord prefix and we want a single keystroke for this common op.
18573
+ if (inputValue === 'B' &&
18574
+ state.activeView === 'history' &&
18575
+ state.focus === 'commits' &&
18576
+ state.filteredCommits.length > 0 &&
18577
+ !state.pendingCommitFocused) {
18578
+ return [action({
18579
+ type: 'openInputPrompt',
18580
+ kind: 'create-branch-here',
18581
+ label: 'New branch name (at cursored commit)',
18582
+ })];
18583
+ }
18091
18584
  // `y` / `Y` yank the contextually relevant identifier from the active
18092
18585
  // view to the system clipboard:
18093
18586
  // history → cursored commit hash (Y for short hash)
@@ -18714,10 +19207,25 @@ const LOG_INK_MIN_COLUMNS = 80;
18714
19207
  const LOG_INK_MIN_ROWS = 24;
18715
19208
  const LOG_INK_DEFAULT_COLUMNS = 120;
18716
19209
  const LOG_INK_DEFAULT_ROWS = 40;
19210
+ /**
19211
+ * Terminal-row threshold below which the inspector switches to a
19212
+ * tabbed layout (commit-detail vs actions). Picked empirically: at
19213
+ * 28 rows the inspector's full stack (~30 rows when fully populated)
19214
+ * starts clipping the actions section; below that, the tabbed mode
19215
+ * gives both views their own air.
19216
+ */
19217
+ const INSPECTOR_TABBED_BELOW_ROWS = 28;
18717
19218
  function getLogInkLayout(input) {
18718
19219
  const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
18719
19220
  const rows = input.rows || LOG_INK_DEFAULT_ROWS;
18720
- const detailWidth = Math.max(30, Math.min(56, Math.floor(columns * 0.34)));
19221
+ // Inspector width at rest 20-32 cells (~22% of width), focused
19222
+ // 36-60 cells (~40% of width). Narrow rest state keeps the commit
19223
+ // graph dominant; focus expansion gives the inspector room for long
19224
+ // commit bodies / file lists / action labels. Mirrors the sidebar
19225
+ // pattern (sidebarFocused above): instant transition per render.
19226
+ const detailWidth = input.inspectorFocused
19227
+ ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
19228
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
18721
19229
  // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
18722
19230
  // (~36% of width). The transition is instant per render — focus tab to
18723
19231
  // expand, focus away to collapse.
@@ -18732,6 +19240,7 @@ function getLogInkLayout(input) {
18732
19240
  rows,
18733
19241
  sidebarWidth,
18734
19242
  tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
19243
+ inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
18735
19244
  };
18736
19245
  }
18737
19246
 
@@ -20227,6 +20736,63 @@ function resetToCommit(git, commit, mode) {
20227
20736
  : result.details,
20228
20737
  }));
20229
20738
  }
20739
+ /**
20740
+ * Create a new local branch pointed at <commit>, without switching to it.
20741
+ *
20742
+ * This is the "create branch from cursored commit" history action — the
20743
+ * user types the new branch name into an input prompt and we run
20744
+ * `git branch <name> <sha>` (NOT `git switch -c`, which is what
20745
+ * `branchActions.createBranch` does for the create-branch-at-HEAD flow).
20746
+ * The split exists because GitKraken-style "create branch here" is
20747
+ * specifically about marking a historical commit, not about switching
20748
+ * onto a new working branch.
20749
+ *
20750
+ * Note for the inspector follow-up: workflow surfacing is driven by the
20751
+ * registry in `inkWorkflows.ts`, not a hardcoded action list — adding
20752
+ * `create-branch-here` there is enough for the inspector / palette to
20753
+ * pick this up.
20754
+ */
20755
+ function createBranchFromCommit(git, name, commit) {
20756
+ const trimmedName = name.trim();
20757
+ if (!commit) {
20758
+ return Promise.resolve({
20759
+ ok: false,
20760
+ message: 'No commit selected.',
20761
+ });
20762
+ }
20763
+ if (!trimmedName) {
20764
+ return Promise.resolve({
20765
+ ok: false,
20766
+ message: 'Branch name required.',
20767
+ });
20768
+ }
20769
+ return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['branch', trimmedName, commit.hash]), `Created branch ${trimmedName} at ${commit.shortHash}`)));
20770
+ }
20771
+ /**
20772
+ * Create a lightweight tag pointed at <commit>.
20773
+ *
20774
+ * Mirrors `createBranchFromCommit` for the tag side: the user types a
20775
+ * tag name into an input prompt and we run `git tag <name> <sha>`
20776
+ * (lightweight, no `-a`/`-m`). Annotated tags remain available through
20777
+ * the existing `+` flow on the tags view; this is the per-commit
20778
+ * shortcut.
20779
+ */
20780
+ function createTagAtCommit(git, name, commit) {
20781
+ const trimmedName = name.trim();
20782
+ if (!commit) {
20783
+ return Promise.resolve({
20784
+ ok: false,
20785
+ message: 'No commit selected.',
20786
+ });
20787
+ }
20788
+ if (!trimmedName) {
20789
+ return Promise.resolve({
20790
+ ok: false,
20791
+ message: 'Tag name required.',
20792
+ });
20793
+ }
20794
+ return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['tag', trimmedName, commit.hash]), `Created tag ${trimmedName} at ${commit.shortHash}`)));
20795
+ }
20230
20796
  function startInteractiveRebase(git, commit) {
20231
20797
  if (!commit) {
20232
20798
  return Promise.resolve({
@@ -20507,6 +21073,35 @@ function parseStashDiffFiles(lines) {
20507
21073
  }
20508
21074
  return files;
20509
21075
  }
21076
+ /**
21077
+ * Resolve which stash file *contains* a given line offset — the user's
21078
+ * cursor scrolls through a concatenated multi-file patch, and this is
21079
+ * what powers the "File N/M: <path>" panel header, the inline header
21080
+ * highlighting (#791 follow-up), and the cherry-pick / open-in-editor
21081
+ * dispatchers' "what file is the cursor on" lookup.
21082
+ *
21083
+ * Returns `undefined` when the file list is empty *or* the offset
21084
+ * lands before the very first file's `diff --git` header (e.g. when
21085
+ * `--stat` summary lines lead the patch). Callers fall through to a
21086
+ * "no file selected" state in that case.
21087
+ */
21088
+ function findStashFileForOffset(files, offset) {
21089
+ if (files.length === 0)
21090
+ return undefined;
21091
+ let current;
21092
+ for (const file of files) {
21093
+ if (file.startLine <= offset) {
21094
+ current = file;
21095
+ }
21096
+ else {
21097
+ break;
21098
+ }
21099
+ }
21100
+ // First file is the canonical fallback — even if the offset lands
21101
+ // before its header (rare), we want the cursor to be "in" something
21102
+ // so the user's actions have a target.
21103
+ return current ?? files[0];
21104
+ }
20510
21105
  const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
20511
21106
  function parseDiffGitHeader(line) {
20512
21107
  const match = line.match(DIFF_GIT_HEADER);
@@ -20561,6 +21156,25 @@ function revertFile(git, file) {
20561
21156
  }
20562
21157
  return runAction$3(() => git.raw(['restore', '--', file.path]), `Reverted ${file.path}`);
20563
21158
  }
21159
+ /**
21160
+ * Group-level batch ops triggered by Enter on a status group header
21161
+ * (staged / unstaged / untracked). Pass the files belonging to that
21162
+ * group; the helpers run a single `git add` / `git restore --staged`
21163
+ * with all paths in one invocation rather than looping per-file —
21164
+ * faster + atomic from the user's point of view.
21165
+ */
21166
+ function stageAllFiles(git, files) {
21167
+ if (files.length === 0) {
21168
+ return Promise.resolve({ ok: false, message: 'No files to stage' });
21169
+ }
21170
+ return runAction$3(() => git.raw(['add', '--', ...files.map((file) => file.path)]), `Staged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
21171
+ }
21172
+ function unstageAllFiles(git, files) {
21173
+ if (files.length === 0) {
21174
+ return Promise.resolve({ ok: false, message: 'No files to unstage' });
21175
+ }
21176
+ return runAction$3(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
21177
+ }
20564
21178
 
20565
21179
  function fileState(indexStatus, worktreeStatus) {
20566
21180
  if (indexStatus === '?' && worktreeStatus === '?') {
@@ -20604,6 +21218,22 @@ function applyStatusFilterMask(files, mask) {
20604
21218
  }
20605
21219
  return files.filter((file) => mask[file.state]);
20606
21220
  }
21221
+ const WORKTREE_GROUP_ORDER = ['staged', 'unstaged', 'untracked'];
21222
+ function groupWorktreeFiles(files) {
21223
+ const groups = [];
21224
+ let cursor = 0;
21225
+ for (const groupState of WORKTREE_GROUP_ORDER) {
21226
+ const groupFiles = files.filter((file) => file.state === groupState);
21227
+ if (groupFiles.length > 0) {
21228
+ groups.push({ state: groupState, files: groupFiles, startIndex: cursor });
21229
+ cursor += groupFiles.length;
21230
+ }
21231
+ }
21232
+ return groups;
21233
+ }
21234
+ function flattenWorktreeGroups(groups) {
21235
+ return groups.flatMap((group) => group.files);
21236
+ }
20607
21237
 
20608
21238
  function hunkHeader(hunk) {
20609
21239
  return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
@@ -23508,7 +24138,16 @@ function LogInkApp(deps) {
23508
24138
  // count, selected-file resolution, and the rendered list all key off
23509
24139
  // it so toggles never desync the cursor from the rendered rows.
23510
24140
  const visibleWorktreeFiles = React.useMemo(() => applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask), [context.worktree?.files, state.statusFilterMask]);
23511
- const selectedWorktreeFile = visibleWorktreeFiles[state.selectedWorktreeFileIndex];
24141
+ // Sectioned view of the visible files (#791 follow-up). Drives the
24142
+ // status surface's three-tier cursor model: ←/→ jumps between
24143
+ // groups, ↑ at index 0 promotes to the group header, Enter on the
24144
+ // header fires the group's batch action. The renderer also consumes
24145
+ // this so the visible file list stays in canonical group order
24146
+ // regardless of whatever order `git status --porcelain` happens to
24147
+ // emit.
24148
+ const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
24149
+ const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
24150
+ const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
23512
24151
  const dispatch = React.useCallback((action) => {
23513
24152
  setState((current) => applyLogInkAction(current, action));
23514
24153
  }, []);
@@ -23752,6 +24391,80 @@ function LogInkApp(deps) {
23752
24391
  active = false;
23753
24392
  };
23754
24393
  }, [git, selected?.hash]);
24394
+ // #806 follow-up — auto-jump the history view to whichever branch /
24395
+ // tag the user is currently cursoring in the sidebar (or the
24396
+ // dedicated branches / tags view). Debounced so cursor-scrolling
24397
+ // through a long branch list doesn't dispatch on every keystroke.
24398
+ // No-op when the cursored ref's tip isn't in the loaded commit
24399
+ // window (under compact mode the cursored branch's tip may not be
24400
+ // fetched yet); a status hint surfaces in that case so the user
24401
+ // knows to toggle full graph or load older commits.
24402
+ React.useEffect(() => {
24403
+ const onBranchTab = state.activeView === 'branches' ||
24404
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
24405
+ const onTagTab = state.activeView === 'tags' ||
24406
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24407
+ if (!onBranchTab && !onTagTab)
24408
+ return;
24409
+ let cancelled = false;
24410
+ const timer = setTimeout(() => {
24411
+ if (cancelled)
24412
+ return;
24413
+ let targetHash;
24414
+ let targetLabel;
24415
+ if (onBranchTab) {
24416
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24417
+ const visible = state.filter
24418
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24419
+ : all;
24420
+ const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24421
+ if (branch) {
24422
+ targetHash = branch.hash;
24423
+ targetLabel = `branch ${branch.shortName}`;
24424
+ }
24425
+ }
24426
+ else if (onTagTab) {
24427
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
24428
+ const visible = state.filter
24429
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24430
+ : all;
24431
+ const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24432
+ if (tag) {
24433
+ targetHash = tag.hash;
24434
+ targetLabel = `tag ${tag.name}`;
24435
+ }
24436
+ }
24437
+ if (!targetHash)
24438
+ return;
24439
+ const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24440
+ if (loaded) {
24441
+ dispatch({ type: 'selectCommitByHash', hash: targetHash });
24442
+ // Confirmation status message so the user gets feedback even
24443
+ // when the dedicated branches / tags view is occupying the
24444
+ // main panel and the history cursor moves invisibly behind it.
24445
+ dispatch({
24446
+ type: 'setStatus',
24447
+ value: `Synced history to ${targetLabel} tip`,
24448
+ });
24449
+ }
24450
+ else {
24451
+ dispatch({
24452
+ type: 'setStatus',
24453
+ value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24454
+ });
24455
+ }
24456
+ }, 150);
24457
+ return () => {
24458
+ cancelled = true;
24459
+ clearTimeout(timer);
24460
+ };
24461
+ }, [
24462
+ dispatch, context.branches, context.tags,
24463
+ state.activeView, state.focus, state.sidebarTab,
24464
+ state.selectedBranchIndex, state.selectedTagIndex,
24465
+ state.branchSort, state.tagSort, state.filter,
24466
+ state.filteredCommits,
24467
+ ]);
23755
24468
  React.useEffect(() => {
23756
24469
  let active = true;
23757
24470
  async function loadWorktreeDiff() {
@@ -24127,6 +24840,30 @@ function LogInkApp(deps) {
24127
24840
  message: commit.message,
24128
24841
  });
24129
24842
  },
24843
+ 'create-branch-here': async () => {
24844
+ const commit = getSelectedInkCommit(state);
24845
+ const name = payload?.trim();
24846
+ if (!commit)
24847
+ return { ok: false, message: 'No commit selected' };
24848
+ if (!name)
24849
+ return { ok: false, message: 'Branch name required' };
24850
+ return createBranchFromCommit(git, name, {
24851
+ hash: commit.hash,
24852
+ shortHash: commit.shortHash,
24853
+ });
24854
+ },
24855
+ 'create-tag-here': async () => {
24856
+ const commit = getSelectedInkCommit(state);
24857
+ const name = payload?.trim();
24858
+ if (!commit)
24859
+ return { ok: false, message: 'No commit selected' };
24860
+ if (!name)
24861
+ return { ok: false, message: 'Tag name required' };
24862
+ return createTagAtCommit(git, name, {
24863
+ hash: commit.hash,
24864
+ shortHash: commit.shortHash,
24865
+ });
24866
+ },
24130
24867
  'checkout-file-from-commit': async () => {
24131
24868
  // payload is "<sha> <path>" so we pass both through a single
24132
24869
  // string field on the action.
@@ -24179,6 +24916,17 @@ function LogInkApp(deps) {
24179
24916
  if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
24180
24917
  return { ok: false, message: 'No GitHub remote detected for this repo' };
24181
24918
  }
24919
+ // History view: prefer the cursored commit's URL so `O` from
24920
+ // a commit context lands the user on the commit page rather
24921
+ // than the repo root or the current PR. The user-visible
24922
+ // intent of `O` is "open whatever I'm cursoring on the web";
24923
+ // a commit is what the cursor is on in the history view.
24924
+ if (state.activeView === 'history') {
24925
+ const commit = getSelectedInkCommit(state);
24926
+ if (commit) {
24927
+ return openProviderUrl(repo, { type: 'commit', commit: commit.hash });
24928
+ }
24929
+ }
24182
24930
  const pr = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
24183
24931
  if (pr) {
24184
24932
  return openProviderUrl(repo, { type: 'pull-request', number: pr.number });
@@ -24258,6 +25006,26 @@ function LogInkApp(deps) {
24258
25006
  return { ok: false, message: 'Comment body required' };
24259
25007
  return commentPullRequest(body);
24260
25008
  },
25009
+ // Status surface group-level batch ops (#791 follow-up). The
25010
+ // input handler dispatches these when the user presses Enter on a
25011
+ // group header. We re-derive the file list from the live
25012
+ // `context.worktree?.files` rather than trusting a snapshot —
25013
+ // the worktree may have changed since the keystroke fired (rare,
25014
+ // but the cost of re-filtering is negligible compared to the cost
25015
+ // of a stale add). The mask is honored too so a user who's
25016
+ // hidden a category never has it touched by accident.
25017
+ 'stage-all-unstaged': async () => {
25018
+ const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'unstaged');
25019
+ return stageAllFiles(git, files);
25020
+ },
25021
+ 'unstage-all-staged': async () => {
25022
+ const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'staged');
25023
+ return unstageAllFiles(git, files);
25024
+ },
25025
+ 'stage-all-untracked': async () => {
25026
+ const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
25027
+ return stageAllFiles(git, files);
25028
+ },
24261
25029
  };
24262
25030
  const handler = handlers[id];
24263
25031
  if (!handler) {
@@ -24266,12 +25034,24 @@ function LogInkApp(deps) {
24266
25034
  }
24267
25035
  const result = await handler();
24268
25036
  dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
24269
- // Silent refresh so the deleted item disappears from the list without
24270
- // flickering the surfaces through a 'loading' phase.
24271
- await refreshContext({ silent: true });
25037
+ // Checkout-branch is the one workflow where we want a *visible*
25038
+ // refresh so the user sees the branches sidebar repaint with the
25039
+ // new current branch (per #806 follow-up). Snap the cursor to
25040
+ // position 0 first so when the refresh completes and the new
25041
+ // current branch lands at the top (per #809's pin-current rule),
25042
+ // the cursor is already there waiting.
25043
+ if (id === 'checkout-branch' && result?.ok) {
25044
+ dispatch({ type: 'resetBranchSelection' });
25045
+ await refreshContext();
25046
+ }
25047
+ else {
25048
+ // Silent refresh so the deleted item disappears from the list
25049
+ // without flickering the surfaces through a 'loading' phase.
25050
+ await refreshContext({ silent: true });
25051
+ }
24272
25052
  }, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
24273
25053
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
24274
- state.tagSort]);
25054
+ state.statusFilterMask, state.tagSort]);
24275
25055
  // Resolve the active view's "yank target" (commit hash / branch /
24276
25056
  // tag / stash ref / file path) against the live filtered+sorted list,
24277
25057
  // copy it to the system clipboard, and surface the result on the
@@ -24326,7 +25106,7 @@ function LogInkApp(deps) {
24326
25106
  // Read from the mask-filtered list (#776) so the cursor and the
24327
25107
  // yanked path always match what's on screen — yanking a hidden
24328
25108
  // row is always a desync bug.
24329
- const path = visibleWorktreeFiles[state.selectedWorktreeFileIndex]?.path;
25109
+ const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
24330
25110
  if (path) {
24331
25111
  value = path;
24332
25112
  label = `path ${path}`;
@@ -24334,7 +25114,7 @@ function LogInkApp(deps) {
24334
25114
  }
24335
25115
  else if (view === 'diff') {
24336
25116
  if (state.diffSource === 'worktree') {
24337
- const path = visibleWorktreeFiles[state.selectedWorktreeFileIndex]?.path;
25117
+ const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
24338
25118
  if (path) {
24339
25119
  value = path;
24340
25120
  label = `path ${path}`;
@@ -24344,17 +25124,8 @@ function LogInkApp(deps) {
24344
25124
  // Walk back to the most recent file header at or before the
24345
25125
  // current preview offset — same logic the input-context block
24346
25126
  // uses to expose stashDiffSelectedPath.
24347
- const files = parseStashDiffFiles(stashDiffLines);
24348
- if (files.length > 0) {
24349
- let current = files[0];
24350
- for (const file of files) {
24351
- if (file.startLine <= state.diffPreviewOffset) {
24352
- current = file;
24353
- }
24354
- else {
24355
- break;
24356
- }
24357
- }
25127
+ const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
25128
+ if (current) {
24358
25129
  value = current.path;
24359
25130
  label = `path ${current.path}`;
24360
25131
  }
@@ -24408,7 +25179,7 @@ function LogInkApp(deps) {
24408
25179
  state.selectedTagIndex,
24409
25180
  state.selectedWorktreeFileIndex,
24410
25181
  state.tagSort,
24411
- visibleWorktreeFiles,
25182
+ visibleWorktreeFilesGrouped,
24412
25183
  ]);
24413
25184
  React.useEffect(() => {
24414
25185
  let active = true;
@@ -24648,28 +25419,14 @@ function LogInkApp(deps) {
24648
25419
  ? parseStashDiffFiles(stashDiffLines)
24649
25420
  : [];
24650
25421
  const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
24651
- const stashDiffSelectedPath = (() => {
24652
- if (state.diffSource !== 'stash' || stashDiffFiles.length === 0)
24653
- return undefined;
24654
- const offset = state.diffPreviewOffset;
24655
- // Walk backwards to the most recent file header at or before the
24656
- // current cursor offset.
24657
- let current = stashDiffFiles[0];
24658
- for (const file of stashDiffFiles) {
24659
- if (file.startLine <= offset) {
24660
- current = file;
24661
- }
24662
- else {
24663
- break;
24664
- }
24665
- }
24666
- return current.path;
24667
- })();
25422
+ const stashDiffSelectedPath = state.diffSource === 'stash'
25423
+ ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
25424
+ : undefined;
24668
25425
  getLogInkInputEvents(state, inputValue, key, {
24669
25426
  detailFileCount: detail?.files.length,
24670
25427
  previewLineCount: diffPreviewLineCount,
24671
25428
  worktreeDiffLineCount: worktreeDiff?.lines.length,
24672
- worktreeFileCount: visibleWorktreeFiles.length,
25429
+ worktreeFileCount: visibleWorktreeFilesGrouped.length,
24673
25430
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
24674
25431
  commitDiffHunkOffsets,
24675
25432
  branchCount: branchVisibleCount,
@@ -24679,7 +25436,13 @@ function LogInkApp(deps) {
24679
25436
  stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
24680
25437
  stashDiffSelectedPath,
24681
25438
  worktreeListCount: worktreeVisibleCount,
24682
- worktreeSelectedPath: visibleWorktreeFiles[state.selectedWorktreeFileIndex]?.path,
25439
+ worktreeSelectedPath: visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path,
25440
+ statusGroups: visibleWorktreeGroups.map((group) => ({
25441
+ state: group.state,
25442
+ count: group.files.length,
25443
+ startIndex: group.startIndex,
25444
+ })),
25445
+ inspectorActionCount: getInspectorActionsForState(state).length,
24683
25446
  commitDiffSelectedPath: state.diffSource === 'commit'
24684
25447
  ? selectedDetailFile?.path
24685
25448
  : undefined,
@@ -24750,6 +25513,7 @@ function LogInkApp(deps) {
24750
25513
  columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
24751
25514
  rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
24752
25515
  sidebarFocused: state.focus === 'sidebar',
25516
+ inspectorFocused: state.focus === 'detail',
24753
25517
  });
24754
25518
  if (layout.tooSmall) {
24755
25519
  return h(Box, {
@@ -24764,7 +25528,7 @@ function LogInkApp(deps) {
24764
25528
  if (showOnboarding) {
24765
25529
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
24766
25530
  }
24767
- return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
25531
+ 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));
24768
25532
  }
24769
25533
  function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
24770
25534
  const { Box, Text } = components;
@@ -24817,13 +25581,18 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
24817
25581
  ? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
24818
25582
  : undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
24819
25583
  }
24820
- function renderSidebar(h, components, state, context, contextStatus, width, theme) {
25584
+ function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme) {
24821
25585
  const { Box, Text } = components;
24822
25586
  const focused = state.focus === 'sidebar';
24823
25587
  const tabs = getLogInkSidebarTabs();
24824
25588
  // Accordion layout — every tab's title is visible on its own line, but
24825
25589
  // only the active tab expands its content underneath. Switching tabs
24826
25590
  // (1-5 / [/]) collapses the previous and expands the next.
25591
+ // When sidebar focus has been promoted to the tab header (#806
25592
+ // follow-up), the active tab's title row gets selection styling
25593
+ // and the items below it render without their cursor highlight
25594
+ // (which now lives on the header).
25595
+ const headerFocused = focused && state.sidebarHeaderFocused;
24827
25596
  const tabBlocks = tabs.flatMap((tab, tabIndex) => {
24828
25597
  const isActive = tab === state.sidebarTab;
24829
25598
  const count = sidebarTabCount(tab, context);
@@ -24831,6 +25600,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
24831
25600
  ? `${sidebarTabLabel(tab)} (${count})`
24832
25601
  : sidebarTabLabel(tab);
24833
25602
  const headerText = isActive ? `[${labelWithCount}]` : labelWithCount;
25603
+ const headerSelected = isActive && headerFocused;
24834
25604
  const blocks = [];
24835
25605
  if (tabIndex > 0) {
24836
25606
  blocks.push(h(Text, { key: `tab-spacer-${tab}` }, ''));
@@ -24839,9 +25609,15 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
24839
25609
  key: `tab-header-${tab}`,
24840
25610
  bold: isActive,
24841
25611
  dimColor: !isActive,
25612
+ // Selection styling on the header itself when the cursor has
25613
+ // been promoted off the items list. inverse swaps fg/bg so the
25614
+ // highlight reads as "this is the cursor target" identically
25615
+ // to how items render when focused.
25616
+ backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
25617
+ inverse: headerSelected,
24842
25618
  }, headerText));
24843
25619
  if (isActive) {
24844
- blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme));
25620
+ blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
24845
25621
  }
24846
25622
  return blocks;
24847
25623
  });
@@ -24860,7 +25636,15 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
24860
25636
  * surface; every other tab falls through to `sidebarLines` for its
24861
25637
  * string-based summary.
24862
25638
  */
24863
- function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme) {
25639
+ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
25640
+ // Available rows for the active tab's list. The sidebar chrome
25641
+ // takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
25642
+ // spacers); the branches tab eats 3 more for its summary header
25643
+ // (Current / Worktree / spacer). Floor of 8 keeps short terminals
25644
+ // usable; tall terminals (40+ rows) get noticeably more items.
25645
+ const sidebarChrome = 10;
25646
+ const branchHeaderRows = tab === 'branches' ? 3 : 0;
25647
+ const visibleListCount = Math.max(8, bodyRows - sidebarChrome - branchHeaderRows);
24864
25648
  if (tab === 'status') {
24865
25649
  return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
24866
25650
  }
@@ -24868,7 +25652,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24868
25652
  // ↑/↓ navigates within the sidebar list and Enter / per-entity keys
24869
25653
  // act on the cursored item without needing to drill into the
24870
25654
  // dedicated view (#791 follow-up — in-sidebar selection).
24871
- const focused = state.focus === 'sidebar' && state.sidebarTab === tab;
25655
+ // Items render with the cursor highlight only when the sidebar is
25656
+ // focused on this tab AND the cursor is on items (not promoted to
25657
+ // the tab header). The header-focused branch up in `renderSidebar`
25658
+ // owns the highlight in that case.
25659
+ const focused = state.focus === 'sidebar' && state.sidebarTab === tab && !state.sidebarHeaderFocused;
24872
25660
  if (tab === 'branches') {
24873
25661
  if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
24874
25662
  return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
@@ -24885,7 +25673,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24885
25673
  ];
24886
25674
  return [
24887
25675
  ...headerRows,
24888
- ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches'),
25676
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
24889
25677
  ];
24890
25678
  }
24891
25679
  if (tab === 'tags') {
@@ -24896,7 +25684,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24896
25684
  if (tags.length === 0) {
24897
25685
  return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
24898
25686
  }
24899
- return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags');
25687
+ return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
24900
25688
  }
24901
25689
  if (tab === 'stashes') {
24902
25690
  if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
@@ -24906,7 +25694,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24906
25694
  if (stashes.length === 0) {
24907
25695
  return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
24908
25696
  }
24909
- return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes');
25697
+ return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
24910
25698
  }
24911
25699
  // worktrees
24912
25700
  if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
@@ -24920,7 +25708,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24920
25708
  const marker = worktree.current ? '*' : ' ';
24921
25709
  const wstate = worktree.dirty ? 'dirty' : 'clean';
24922
25710
  return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
24923
- }, 'tab-worktrees');
25711
+ }, 'tab-worktrees', visibleListCount);
24924
25712
  }
24925
25713
  /**
24926
25714
  * Render a sliding-window list of selectable sidebar rows. The cursor
@@ -24929,10 +25717,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
24929
25717
  * Sliding window keeps the cursor in view as the user navigates a long
24930
25718
  * list; truncation hints surface the count of hidden rows.
24931
25719
  */
24932
- function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix) {
25720
+ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
24933
25721
  if (items.length === 0)
24934
25722
  return [];
24935
- const window = getSidebarVisibleWindow(items.length, selectedIndex);
25723
+ const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
24936
25724
  const elements = [];
24937
25725
  if (window.truncatedAbove > 0) {
24938
25726
  elements.push(h(Text, {
@@ -25168,6 +25956,16 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
25168
25956
  backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
25169
25957
  }, truncate$1(label, 140));
25170
25958
  }
25959
+ function buildStatusSurfaceRows(groups) {
25960
+ const rows = [];
25961
+ for (const group of groups) {
25962
+ rows.push({ kind: 'header', group });
25963
+ group.files.forEach((file, offset) => {
25964
+ rows.push({ kind: 'file', group, file, flatIndex: group.startIndex + offset });
25965
+ });
25966
+ }
25967
+ return rows;
25968
+ }
25171
25969
  function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
25172
25970
  const { Box, Text } = components;
25173
25971
  const focused = state.focus === 'commits';
@@ -25177,26 +25975,64 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
25177
25975
  // uses for j/k navigation. `visibleFiles` may be a strict subset of
25178
25976
  // worktree.files when the user has narrowed via 1/2/3.
25179
25977
  const visibleFiles = applyStatusFilterMask(worktree?.files || [], state.statusFilterMask);
25978
+ // Group + canonical-sort. The runtime + input handler agree on this
25979
+ // order so a `selectedWorktreeFileIndex` of N always points to the
25980
+ // same file across all three (renderer / input / workflow handlers).
25981
+ const visibleGroups = groupWorktreeFiles(visibleFiles);
25982
+ const surfaceRows = buildStatusSurfaceRows(visibleGroups);
25180
25983
  const listRows = Math.max(4, bodyRows - 5);
25181
25984
  const selectedIndex = state.selectedWorktreeFileIndex;
25985
+ const headerFocused = state.statusGroupHeaderFocused;
25986
+ // Resolve the cursor's row index in the flat (header-and-file) row
25987
+ // list. Used to window the visible slice around the cursor.
25988
+ const cursorRowIndex = (() => {
25989
+ if (!surfaceRows.length)
25990
+ return 0;
25991
+ const currentGroup = visibleGroups.find((group) => selectedIndex >= group.startIndex && selectedIndex < group.startIndex + group.files.length);
25992
+ if (!currentGroup)
25993
+ return 0;
25994
+ if (headerFocused) {
25995
+ const idx = surfaceRows.findIndex((row) => row.kind === 'header' && row.group === currentGroup);
25996
+ return idx >= 0 ? idx : 0;
25997
+ }
25998
+ const idx = surfaceRows.findIndex((row) => row.kind === 'file' && row.flatIndex === selectedIndex);
25999
+ return idx >= 0 ? idx : 0;
26000
+ })();
25182
26001
  const cleanHint = formatLogInkStatusEmpty({ hasChanges: Boolean(worktree?.files.length) });
25183
- const startIndex = Math.max(0, selectedIndex - Math.floor(listRows / 2));
26002
+ const windowStart = Math.max(0, Math.min(Math.max(0, surfaceRows.length - listRows), cursorRowIndex - Math.floor(listRows / 2)));
25184
26003
  const isLoading = isLogInkContextKeyLoading(contextStatus, 'worktree');
25185
- const fileRows = isLoading || !visibleFiles.length
26004
+ const renderedRows = isLoading || !surfaceRows.length
25186
26005
  ? []
25187
- : visibleFiles.slice(startIndex).slice(0, listRows).map((file, offset) => {
25188
- const index = startIndex + offset;
25189
- const isSelected = index === selectedIndex;
26006
+ : surfaceRows.slice(windowStart, windowStart + listRows).map((row, offset) => {
26007
+ const rowIndex = windowStart + offset;
26008
+ if (row.kind === 'header') {
26009
+ const groupContainsCursor = selectedIndex >= row.group.startIndex &&
26010
+ selectedIndex < row.group.startIndex + row.group.files.length;
26011
+ const headerSelected = focused && headerFocused && groupContainsCursor;
26012
+ const arrow = theme.ascii ? '>' : '▾';
26013
+ const groupLabel = capitalizeGroupName(row.group.state);
26014
+ const text = ` ${arrow} ${groupLabel} (${row.group.files.length})`;
26015
+ return h(Text, {
26016
+ key: `status-group-${row.group.state}-${rowIndex}`,
26017
+ bold: true,
26018
+ dimColor: !headerSelected && rowIndex > cursorRowIndex,
26019
+ backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
26020
+ inverse: headerSelected,
26021
+ }, truncate$1(text, 140));
26022
+ }
26023
+ const isSelected = !headerFocused && row.flatIndex === selectedIndex;
25190
26024
  const cursorPart = `${isSelected ? '>' : ' '} `;
25191
- const dotColor = getStageStatusDotColor(file.state, theme);
26025
+ const dotColor = getStageStatusDotColor(row.file.state, theme);
25192
26026
  const useDot = dotColor !== undefined;
25193
26027
  const dotCells = useDot ? cellWidth(STAGE_STATUS_DOT) + 1 : 0;
25194
- const tail = `${file.indexStatus}${file.worktreeStatus} ${file.state.padEnd(9)} ${file.path}`;
25195
- const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells));
26028
+ const tail = `${row.file.indexStatus}${row.file.worktreeStatus} ${row.file.path}`;
26029
+ const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells - 2));
25196
26030
  return h(Text, {
25197
- key: `status-row-${index}`,
25198
- dimColor: offset > 0,
25199
- }, cursorPart, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
26031
+ key: `status-file-${row.flatIndex}-${rowIndex}`,
26032
+ dimColor: !isSelected && rowIndex > cursorRowIndex,
26033
+ backgroundColor: isSelected && focused && !theme.noColor ? theme.colors.selection : undefined,
26034
+ inverse: isSelected && focused,
26035
+ }, ` ${cursorPart}`, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
25200
26036
  });
25201
26037
  // When the mask narrows the list to nothing but the underlying repo
25202
26038
  // is non-clean, surface why the panel looks empty so the user can
@@ -25226,11 +26062,14 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
25226
26062
  // never touch the filter.
25227
26063
  ...(isStatusFilterMaskActive(state.statusFilterMask)
25228
26064
  ? [h(Text, { key: 'status-mask-indicator', dimColor: true }, `filter: ${formatStatusFilterMask(state.statusFilterMask)} (1/2/3 to toggle)`)]
25229
- : []), ...fileRows, ...fallbackLines.map((line, index) => h(Text, {
26065
+ : []), ...renderedRows, ...fallbackLines.map((line, index) => h(Text, {
25230
26066
  key: `status-surface-fallback-${index}`,
25231
26067
  dimColor: index > 0,
25232
26068
  }, truncate$1(line, 140))));
25233
26069
  }
26070
+ function capitalizeGroupName(value) {
26071
+ return value.charAt(0).toUpperCase() + value.slice(1);
26072
+ }
25234
26073
  function isStatusFilterMaskActive(mask) {
25235
26074
  return !mask.staged || !mask.unstaged || !mask.untracked;
25236
26075
  }
@@ -25680,20 +26519,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
25680
26519
  const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
25681
26520
  const stashFiles = parseStashDiffFiles(lines);
25682
26521
  const fileCount = stashFiles.length;
25683
- const currentFile = (() => {
25684
- if (fileCount === 0)
25685
- return undefined;
25686
- let current = stashFiles[0];
25687
- for (const file of stashFiles) {
25688
- if (file.startLine <= state.diffPreviewOffset) {
25689
- current = file;
25690
- }
25691
- else {
25692
- break;
25693
- }
25694
- }
25695
- return current;
25696
- })();
26522
+ const currentFile = findStashFileForOffset(stashFiles, state.diffPreviewOffset);
25697
26523
  const currentFileIndex = currentFile
25698
26524
  ? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
25699
26525
  : -1;
@@ -25720,14 +26546,41 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
25720
26546
  const headerLines = splitRequestedButTooNarrow
25721
26547
  ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
25722
26548
  : baseHeaderLines;
26549
+ // File header anchor map: absolute line index → owning stash file.
26550
+ // Lets the body-render pass restyle each `diff --git` row in O(1)
26551
+ // and decide which one is the *active* file (the one currently
26552
+ // containing `diffPreviewOffset`). The active header gets the
26553
+ // selection background to mark "the file the cursor is inside."
26554
+ const stashFileByStartLine = new Map(stashFiles.map((file) => [file.startLine, file]));
26555
+ const activeStartLine = currentFile?.startLine;
25723
26556
  const stashBodyNodes = stashDiffLoading || !lines.length
25724
26557
  ? []
25725
26558
  : splitActive
25726
26559
  ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
25727
- : visibleLines.map((line, index) => h(Text, {
25728
- key: `stash-diff-line-${state.diffPreviewOffset + index}`,
25729
- ...diffLineProps(line, theme),
25730
- }, truncate$1(line, width - 4)));
26560
+ : visibleLines.map((line, index) => {
26561
+ const absoluteIndex = state.diffPreviewOffset + index;
26562
+ const headerFile = stashFileByStartLine.get(absoluteIndex);
26563
+ if (headerFile) {
26564
+ // Replace the verbose `diff --git a/<path> b/<path>` text
26565
+ // with a compact `▾ <path>` marker — the path itself is
26566
+ // the meaningful identifier, not the a/b duplication. The
26567
+ // active file's header gets selection styling so the user
26568
+ // sees at a glance which file the cursor is inside.
26569
+ const isActive = absoluteIndex === activeStartLine;
26570
+ const arrow = theme.ascii ? '> ' : '▾ ';
26571
+ return h(Text, {
26572
+ key: `stash-diff-line-${absoluteIndex}`,
26573
+ bold: true,
26574
+ color: theme.noColor ? undefined : theme.colors.accent,
26575
+ backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
26576
+ inverse: isActive && focused,
26577
+ }, truncate$1(`${arrow}${headerFile.path}`, width - 4));
26578
+ }
26579
+ return h(Text, {
26580
+ key: `stash-diff-line-${absoluteIndex}`,
26581
+ ...diffLineProps(line, theme),
26582
+ }, truncate$1(line, width - 4));
26583
+ });
25731
26584
  return h(Box, {
25732
26585
  borderColor: focusBorderColor(theme, focused),
25733
26586
  borderStyle: theme.borderStyle,
@@ -25833,7 +26686,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
25833
26686
  }, truncate$1(line, 140)))
25834
26687
  : []));
25835
26688
  }
25836
- function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme) {
26689
+ function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme) {
25837
26690
  const focused = state.focus === 'detail';
25838
26691
  if (state.showHelp) {
25839
26692
  return renderHelpPanel(h, components, state, width, theme, focused);
@@ -25890,16 +26743,11 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
25890
26743
  if (state.activeView === 'stash') {
25891
26744
  return renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
25892
26745
  }
25893
- return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme, focused);
26746
+ return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
25894
26747
  }
25895
- function renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, theme, focused) {
26748
+ function renderHistoryInspector(h, components, state, context, _contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, tabbed, theme, focused) {
25896
26749
  const { Box, Text } = components;
25897
26750
  const selected = getSelectedInkCommit(state);
25898
- const workflowSections = getLogInkWorkflowSections({
25899
- ...context,
25900
- contextLoading: isLogInkContextLoading(contextStatus),
25901
- selectedCommit: selected,
25902
- });
25903
26751
  if (!detail) {
25904
26752
  const fallbackLines = [
25905
26753
  selected?.message || 'No commit selected.',
@@ -25915,7 +26763,10 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
25915
26763
  }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
25916
26764
  key: `detail-${index}`,
25917
26765
  dimColor: index > 1,
25918
- }, truncate$1(line, width - 4))));
26766
+ }, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
26767
+ cursorIndex: state.inspectorActionIndex,
26768
+ cursorActive: focused && state.inspectorTab === 'actions',
26769
+ }));
25919
26770
  }
25920
26771
  const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
25921
26772
  // P5.1 — link the commit hash and each ref out to GitHub when we know
@@ -25926,18 +26777,26 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
25926
26777
  const refNodes = detail.refs.length
25927
26778
  ? renderInspectorRefs(h, Text, detail.refs, repository)
25928
26779
  : null;
26780
+ // Inspector reorder (PR — drop duplicative Workflows trailer):
26781
+ // 1. Commit message (the headline of what you're looking at)
26782
+ // 2. Metadata (hash / author / date / refs / stats)
26783
+ // 3. Body preview (up to 8 lines now that the trailer is gone)
26784
+ // 4. Changed files list (cursored entry highlights)
26785
+ // 5. Actions cheat-sheet (per-entity keystrokes; destructive marked)
26786
+ // The Workflows: trailer that used to repeat the repo / branch /
26787
+ // status from the top header and left sidebar is intentionally gone.
25929
26788
  const headerNodes = [
25930
26789
  h(Text, { key: 'detail-msg' }, truncate$1(detail.message, width - 4)),
25931
26790
  h(Text, { key: 'detail-spacer-1' }, ''),
25932
26791
  h(Text, { key: 'detail-commit', dimColor: true }, 'Commit: ', commitLink),
25933
26792
  h(Text, { key: 'detail-author', dimColor: true }, truncate$1(`Author: ${detail.author}`, width - 4)),
25934
- h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
26793
+ h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
25935
26794
  refNodes
25936
- ? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
25937
- : h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
25938
- h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(statLine, width - 4)),
26795
+ ? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
26796
+ : h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
26797
+ h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(`Stats: ${statLine}`, width - 4)),
25939
26798
  h(Text, { key: 'detail-spacer-2' }, ''),
25940
- ...(detail.body ? detail.body.split('\n').slice(0, 6) : ['No commit body.']).map((line, index) => h(Text, {
26799
+ ...(detail.body ? detail.body.split('\n').slice(0, 8) : ['No commit body.']).map((line, index) => h(Text, {
25941
26800
  key: `detail-body-${index}`,
25942
26801
  dimColor: true,
25943
26802
  }, truncate$1(line, width - 4))),
@@ -25946,24 +26805,98 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
25946
26805
  ];
25947
26806
  const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
25948
26807
  const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
25949
- const trailerLines = [
25950
- '',
25951
- 'Workflows:',
25952
- ...workflowSections.flatMap((section) => [
25953
- section.title,
25954
- ...section.lines.slice(0, 3).map((line) => ` ${line}`),
25955
- ]).slice(0, 12),
25956
- ];
26808
+ // Tabbed mode (#806 follow-up — short terminals): render only the
26809
+ // active inspector tab with a `[Inspector] Actions` header so the
26810
+ // user knows what they're seeing and how to switch (`[/]` while
26811
+ // focus is on the inspector). Tall terminals stack both sections
26812
+ // as before.
26813
+ if (tabbed) {
26814
+ const activeTab = state.inspectorTab;
26815
+ const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
26816
+ bold: activeTab === 'inspector',
26817
+ dimColor: activeTab !== 'inspector',
26818
+ }, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
26819
+ bold: activeTab === 'actions',
26820
+ dimColor: activeTab !== 'actions',
26821
+ }, activeTab === 'actions' ? '[Actions]' : ' Actions '));
26822
+ return h(Box, {
26823
+ borderColor: focusBorderColor(theme, focused),
26824
+ borderStyle: theme.borderStyle,
26825
+ flexDirection: 'column',
26826
+ width,
26827
+ paddingX: 1,
26828
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
26829
+ ? [...headerNodes, ...fileListNodes]
26830
+ : renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
26831
+ cursorIndex: state.inspectorActionIndex,
26832
+ cursorActive: focused,
26833
+ })));
26834
+ }
25957
26835
  return h(Box, {
25958
26836
  borderColor: focusBorderColor(theme, focused),
25959
26837
  borderStyle: theme.borderStyle,
25960
26838
  flexDirection: 'column',
25961
26839
  width,
25962
26840
  paddingX: 1,
25963
- }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...trailerLines.map((line, index) => h(Text, {
25964
- key: `detail-trailer-${index}`,
25965
- dimColor: index > 0,
25966
- }, truncate$1(line, width - 4))));
26841
+ }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
26842
+ cursorIndex: state.inspectorActionIndex,
26843
+ cursorActive: focused && state.inspectorTab === 'actions',
26844
+ }));
26845
+ }
26846
+ /**
26847
+ * Render the trailing "Actions:" section that surfaces which keystrokes
26848
+ * apply to whatever the inspector is focused on. Keys are colored with
26849
+ * `theme.colors.accent` so they pop as the actionable element. Destructive
26850
+ * actions get the danger color plus a `[!]` marker so they don't blend
26851
+ * into the cherry-pick / yank rows.
26852
+ *
26853
+ * Truncates labels when the inspector is narrow (down to the 26-cell
26854
+ * minimum from `getLogInkLayout`) so an overflowing label never wraps and
26855
+ * collides with the next row.
26856
+ */
26857
+ function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
26858
+ const actions = getInspectorActions(context);
26859
+ if (!actions.length)
26860
+ return [];
26861
+ // Width budget for each row: subtract padding + " " gutter, the key
26862
+ // column (left-padded to 5 cells so labels align), the " " gap
26863
+ // between key and label, and the optional " [!]" suffix (5 cells).
26864
+ const KEY_COLUMN = 5;
26865
+ const GAP = ' ';
26866
+ const DESTRUCTIVE_SUFFIX = ' [!]';
26867
+ const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
26868
+ const cursorIndex = options.cursorIndex ?? 0;
26869
+ const cursorActive = options.cursorActive ?? false;
26870
+ const nodes = [
26871
+ h(Text, { key: 'actions-spacer' }, ''),
26872
+ h(Text, { key: 'actions-title' }, cursorActive ? '[Actions]' : 'Actions:'),
26873
+ ...actions.map((action, index) => {
26874
+ const isSelected = cursorActive && index === cursorIndex;
26875
+ const keyCell = action.key.padEnd(KEY_COLUMN);
26876
+ const label = truncate$1(action.label, labelBudget);
26877
+ const children = [
26878
+ h(Text, {
26879
+ key: `actions-${index}-key`,
26880
+ color: action.destructive ? theme.colors.danger : theme.colors.accent,
26881
+ }, keyCell),
26882
+ GAP,
26883
+ label,
26884
+ ];
26885
+ if (action.destructive) {
26886
+ children.push(h(Text, {
26887
+ key: `actions-${index}-mark`,
26888
+ color: theme.colors.danger,
26889
+ dimColor: false,
26890
+ }, DESTRUCTIVE_SUFFIX));
26891
+ }
26892
+ return h(Text, {
26893
+ key: `actions-${index}`,
26894
+ backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
26895
+ inverse: isSelected,
26896
+ }, ...children);
26897
+ }),
26898
+ ];
26899
+ return nodes;
25967
26900
  }
25968
26901
  /**
25969
26902
  * Build a commit URL for the repo when GitHub provider info is available.
@@ -26537,7 +27470,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
26537
27470
  const git = options.git || getRepo();
26538
27471
  const rows = options.rows || (await getLogRows(git, logArgv));
26539
27472
  await startInkInteractiveLog(git, rows, {}, {
26540
- appLabel: 'coco ui',
27473
+ appLabel: 'coco',
26541
27474
  idleTips: config.logTui?.idleTips,
26542
27475
  initialView: 'history',
26543
27476
  logArgv,
@@ -26550,7 +27483,7 @@ async function startCocoUi(argv) {
26550
27483
  const logArgv = createLogArgvFromUiArgv(argv);
26551
27484
  const rows = await getLogRows(git, logArgv);
26552
27485
  await startInkInteractiveLog(git, rows, {}, {
26553
- appLabel: 'coco ui',
27486
+ appLabel: 'coco',
26554
27487
  idleTips: config.logTui?.idleTips,
26555
27488
  initialView: argv.view || 'history',
26556
27489
  logArgv,