git-coco 0.39.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 +642 -156
  2. package/dist/index.js +642 -156
  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.39.0";
81
+ const BUILD_VERSION = "0.40.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -14905,6 +14905,35 @@ function getLogInkWorkflowActions() {
14905
14905
  kind: 'normal',
14906
14906
  requiresConfirmation: false,
14907
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
+ },
14908
14937
  {
14909
14938
  id: 'delete-branch',
14910
14939
  key: 'D',
@@ -15922,6 +15951,88 @@ function extractDiffHunk(input) {
15922
15951
  return { patchText };
15923
15952
  }
15924
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
+
15925
16036
  /**
15926
16037
  * Sort modes for the promoted views (P4.2).
15927
16038
  *
@@ -16128,6 +16239,7 @@ function withPushedView(state, value) {
16128
16239
  diffSource: value === 'diff' ? state.diffSource : undefined,
16129
16240
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
16130
16241
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
16242
+ statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
16131
16243
  pendingKey: undefined,
16132
16244
  };
16133
16245
  }
@@ -16150,6 +16262,7 @@ function withPoppedView(state) {
16150
16262
  diffSource: next === 'diff' ? state.diffSource : undefined,
16151
16263
  stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
16152
16264
  pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
16265
+ statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
16153
16266
  pendingKey: undefined,
16154
16267
  };
16155
16268
  }
@@ -16167,6 +16280,7 @@ function withReplacedView(state, value) {
16167
16280
  diffSource: value === 'diff' ? state.diffSource : undefined,
16168
16281
  stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
16169
16282
  pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
16283
+ statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
16170
16284
  pendingKey: undefined,
16171
16285
  };
16172
16286
  }
@@ -16298,9 +16412,12 @@ function createLogInkState(rows, options = {}) {
16298
16412
  focus: 'commits',
16299
16413
  sidebarTab: 'status',
16300
16414
  userSidebarTab: 'status',
16415
+ sidebarHeaderFocused: false,
16416
+ statusGroupHeaderFocused: false,
16301
16417
  statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
16302
16418
  diffViewMode: 'unified',
16303
16419
  inspectorTab: 'inspector',
16420
+ inspectorActionIndex: 0,
16304
16421
  };
16305
16422
  }
16306
16423
  function getSelectedInkCommit(state) {
@@ -16341,12 +16458,21 @@ function applyLogInkAction(state, action) {
16341
16458
  return {
16342
16459
  ...state,
16343
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,
16344
16468
  pendingKey: undefined,
16345
16469
  };
16346
16470
  case 'focusPrevious':
16347
16471
  return {
16348
16472
  ...state,
16349
16473
  focus: cycleValue(FOCUS_ORDER, state.focus, -1),
16474
+ sidebarHeaderFocused: false,
16475
+ statusGroupHeaderFocused: false,
16350
16476
  pendingKey: undefined,
16351
16477
  };
16352
16478
  case 'move':
@@ -16411,6 +16537,9 @@ function applyLogInkAction(state, action) {
16411
16537
  selectedWorktreeFileIndex: clampIndex$1(state.selectedWorktreeFileIndex + action.delta, action.fileCount),
16412
16538
  selectedWorktreeHunkIndex: 0,
16413
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,
16414
16543
  };
16415
16544
  }
16416
16545
  case 'moveBranch':
@@ -16429,10 +16558,40 @@ function applyLogInkAction(state, action) {
16429
16558
  selectedBranchIndex: 0,
16430
16559
  pendingKey: undefined,
16431
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
+ };
16432
16587
  case 'setInspectorTab':
16433
16588
  return {
16434
16589
  ...state,
16435
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,
16436
16595
  pendingKey: undefined,
16437
16596
  };
16438
16597
  case 'cycleInspectorTab': {
@@ -16444,9 +16603,22 @@ function applyLogInkAction(state, action) {
16444
16603
  return {
16445
16604
  ...state,
16446
16605
  inspectorTab: next,
16606
+ inspectorActionIndex: 0,
16447
16607
  pendingKey: undefined,
16448
16608
  };
16449
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
+ };
16450
16622
  case 'moveTag':
16451
16623
  return {
16452
16624
  ...state,
@@ -16516,6 +16688,10 @@ function applyLogInkAction(state, action) {
16516
16688
  ...state,
16517
16689
  statusFilterMask: allOff ? { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK } : next,
16518
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,
16519
16695
  pendingKey: undefined,
16520
16696
  };
16521
16697
  }
@@ -16686,6 +16862,13 @@ function applyLogInkAction(state, action) {
16686
16862
  return {
16687
16863
  ...state,
16688
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,
16689
16872
  pendingKey: undefined,
16690
16873
  };
16691
16874
  case 'setPendingKey':
@@ -16887,6 +17070,82 @@ function action(actionValue) {
16887
17070
  action: actionValue,
16888
17071
  };
16889
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
+ }
16890
17149
  /**
16891
17150
  * Build the events needed to apply the hunk under the diff cursor. The
16892
17151
  * runtime workflow handler expects payload format `<target>\n<patch>`
@@ -17698,11 +17957,62 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17698
17957
  if (key.rightArrow && state.focus === 'sidebar') {
17699
17958
  return [action({ type: 'nextSidebarTab' })];
17700
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
+ }
17701
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
+ }
17702
17995
  if (state.focus === 'detail' && context.detailFileCount) {
17703
17996
  return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
17704
17997
  }
17705
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
+ }
17706
18016
  return [action({
17707
18017
  type: 'moveWorktreeFile',
17708
18018
  delta: -1,
@@ -17727,6 +18037,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17727
18037
  previewLineCount: context.previewLineCount,
17728
18038
  })];
17729
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
+ }
17730
18064
  if (isBranchActionTarget(state) && context.branchCount) {
17731
18065
  return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
17732
18066
  }
@@ -17761,10 +18095,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17761
18095
  if (state.activeView === 'history' && state.pendingCommitFocused) {
17762
18096
  return [action({ type: 'unfocusPendingCommit' })];
17763
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
+ }
17764
18105
  if (state.focus === 'detail' && context.detailFileCount) {
17765
18106
  return [action({ type: 'moveDetailFile', delta: 1, fileCount: context.detailFileCount })];
17766
18107
  }
17767
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
+ }
17768
18115
  return [action({
17769
18116
  type: 'moveWorktreeFile',
17770
18117
  delta: 1,
@@ -17785,6 +18132,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17785
18132
  previewLineCount: context.previewLineCount,
17786
18133
  })];
17787
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
+ }
17788
18142
  if (isBranchActionTarget(state) && context.branchCount) {
17789
18143
  return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
17790
18144
  }
@@ -17878,6 +18232,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17878
18232
  ];
17879
18233
  }
17880
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
+ }
17881
18250
  // From the inspector / commit-diff detail panel, Enter opens (or refocuses)
17882
18251
  // the diff view scoped to the currently-selected commit and file. Lets the
17883
18252
  // user drive the explore flow entirely from the right panel: j/k picks a
@@ -17916,7 +18285,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17916
18285
  const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
17917
18286
  const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
17918
18287
  sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
17919
- 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) {
17920
18297
  const tabToView = {
17921
18298
  status: 'status',
17922
18299
  branches: 'branches',
@@ -17936,6 +18313,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17936
18313
  // Fall through — per-entity Enter handler below claims the keystroke.
17937
18314
  }
17938
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
+ }
17939
18334
  return [action({
17940
18335
  type: 'navigateOpenDiffForWorktreeFile',
17941
18336
  fileIndex: state.selectedWorktreeFileIndex,
@@ -20678,6 +21073,35 @@ function parseStashDiffFiles(lines) {
20678
21073
  }
20679
21074
  return files;
20680
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
+ }
20681
21105
  const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
20682
21106
  function parseDiffGitHeader(line) {
20683
21107
  const match = line.match(DIFF_GIT_HEADER);
@@ -20732,6 +21156,25 @@ function revertFile(git, file) {
20732
21156
  }
20733
21157
  return runAction$3(() => git.raw(['restore', '--', file.path]), `Reverted ${file.path}`);
20734
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
+ }
20735
21178
 
20736
21179
  function fileState(indexStatus, worktreeStatus) {
20737
21180
  if (indexStatus === '?' && worktreeStatus === '?') {
@@ -20775,6 +21218,22 @@ function applyStatusFilterMask(files, mask) {
20775
21218
  }
20776
21219
  return files.filter((file) => mask[file.state]);
20777
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
+ }
20778
21237
 
20779
21238
  function hunkHeader(hunk) {
20780
21239
  return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
@@ -23124,88 +23583,6 @@ function formatPullRequestStateLine(pr) {
23124
23583
  return parts.join(' · ');
23125
23584
  }
23126
23585
 
23127
- /**
23128
- * Hardcoded per-entity action lists surfaced inside the right-hand
23129
- * inspector panel. The inspector used to repeat the repo / branch /
23130
- * status content the top header and left sidebar already show; we drop
23131
- * that trailer in favor of an actionable cheat-sheet so the user knows
23132
- * exactly which keystrokes apply to whatever they have under the cursor.
23133
- *
23134
- * Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
23135
- * - Most per-entity actions live in `inkInput.ts` as direct keystroke
23136
- * handlers (e.g. `c` cherry-pick, `R` revert) rather than as
23137
- * globally-registered bindings, so the registry would be a partial
23138
- * view at best.
23139
- * - The bindings registry's `contexts` model (normal / search / focus
23140
- * name) does not cleanly map to inspector entity types like "branch"
23141
- * or "tag". Filtering it would mean replicating the same per-view
23142
- * scoping logic the input dispatcher already encodes.
23143
- * - New per-entity actions are added infrequently — the maintenance
23144
- * cost of mirroring them here is low and keeps this file the single
23145
- * source of truth for "what shows in the inspector".
23146
- *
23147
- * If you wire up a new per-entity keystroke in `inkInput.ts` — for
23148
- * example a "create branch from this commit" or "create tag from this
23149
- * commit" action — add the matching row to the relevant array below so
23150
- * it shows up in the inspector automatically.
23151
- */
23152
- const HISTORY_COMMIT_ACTIONS = [
23153
- { key: 'enter', label: 'Open diff' },
23154
- { key: 'c', label: 'Cherry-pick' },
23155
- { key: 'R', label: 'Revert', destructive: true },
23156
- { key: 'Z', label: 'Reset to commit', destructive: true },
23157
- { key: 'i', label: 'Interactive rebase', destructive: true },
23158
- { key: 'y', label: 'Yank hash' },
23159
- { key: 'Y', label: 'Yank short hash' },
23160
- { key: 'O', label: 'Open in browser' },
23161
- ];
23162
- const BRANCH_ACTIONS = [
23163
- { key: 'enter', label: 'Checkout' },
23164
- { key: '+', label: 'New branch' },
23165
- { key: 'R', label: 'Rename' },
23166
- { key: 'u', label: 'Set upstream' },
23167
- { key: 'D', label: 'Delete', destructive: true },
23168
- { key: 'P', label: 'Push current' },
23169
- { key: 'F', label: 'Fetch all' },
23170
- { key: 'y', label: 'Yank name' },
23171
- ];
23172
- const TAG_ACTIONS = [
23173
- { key: '+', label: 'New tag' },
23174
- { key: 'P', label: 'Push tag' },
23175
- { key: 'T', label: 'Delete', destructive: true },
23176
- { key: 'R', label: 'Delete remote', destructive: true },
23177
- { key: 'y', label: 'Yank name' },
23178
- ];
23179
- const STASH_ACTIONS = [
23180
- { key: 'enter', label: 'Open diff' },
23181
- { key: 'a', label: 'Apply' },
23182
- { key: 'p', label: 'Pop' },
23183
- { key: 'X', label: 'Drop', destructive: true },
23184
- { key: 'y', label: 'Yank ref' },
23185
- ];
23186
- const WORKTREE_ACTIONS = [
23187
- { key: 'W', label: 'Remove', destructive: true },
23188
- { key: 'y', label: 'Yank path' },
23189
- ];
23190
- function getInspectorActions(context) {
23191
- switch (context) {
23192
- case 'history-commit':
23193
- return HISTORY_COMMIT_ACTIONS;
23194
- case 'branch':
23195
- return BRANCH_ACTIONS;
23196
- case 'tag':
23197
- return TAG_ACTIONS;
23198
- case 'stash':
23199
- return STASH_ACTIONS;
23200
- case 'worktree':
23201
- return WORKTREE_ACTIONS;
23202
- default: {
23203
- const exhaustive = context;
23204
- throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
23205
- }
23206
- }
23207
- }
23208
-
23209
23586
  function sectionLines(title, diff) {
23210
23587
  const lines = diff.split('\n').map((line) => line.trimEnd());
23211
23588
  return [
@@ -23761,7 +24138,16 @@ function LogInkApp(deps) {
23761
24138
  // count, selected-file resolution, and the rendered list all key off
23762
24139
  // it so toggles never desync the cursor from the rendered rows.
23763
24140
  const visibleWorktreeFiles = React.useMemo(() => applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask), [context.worktree?.files, state.statusFilterMask]);
23764
- 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];
23765
24151
  const dispatch = React.useCallback((action) => {
23766
24152
  setState((current) => applyLogInkAction(current, action));
23767
24153
  }, []);
@@ -24620,6 +25006,26 @@ function LogInkApp(deps) {
24620
25006
  return { ok: false, message: 'Comment body required' };
24621
25007
  return commentPullRequest(body);
24622
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
+ },
24623
25029
  };
24624
25030
  const handler = handlers[id];
24625
25031
  if (!handler) {
@@ -24645,7 +25051,7 @@ function LogInkApp(deps) {
24645
25051
  }
24646
25052
  }, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
24647
25053
  state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
24648
- state.tagSort]);
25054
+ state.statusFilterMask, state.tagSort]);
24649
25055
  // Resolve the active view's "yank target" (commit hash / branch /
24650
25056
  // tag / stash ref / file path) against the live filtered+sorted list,
24651
25057
  // copy it to the system clipboard, and surface the result on the
@@ -24700,7 +25106,7 @@ function LogInkApp(deps) {
24700
25106
  // Read from the mask-filtered list (#776) so the cursor and the
24701
25107
  // yanked path always match what's on screen — yanking a hidden
24702
25108
  // row is always a desync bug.
24703
- const path = visibleWorktreeFiles[state.selectedWorktreeFileIndex]?.path;
25109
+ const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
24704
25110
  if (path) {
24705
25111
  value = path;
24706
25112
  label = `path ${path}`;
@@ -24708,7 +25114,7 @@ function LogInkApp(deps) {
24708
25114
  }
24709
25115
  else if (view === 'diff') {
24710
25116
  if (state.diffSource === 'worktree') {
24711
- const path = visibleWorktreeFiles[state.selectedWorktreeFileIndex]?.path;
25117
+ const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
24712
25118
  if (path) {
24713
25119
  value = path;
24714
25120
  label = `path ${path}`;
@@ -24718,17 +25124,8 @@ function LogInkApp(deps) {
24718
25124
  // Walk back to the most recent file header at or before the
24719
25125
  // current preview offset — same logic the input-context block
24720
25126
  // uses to expose stashDiffSelectedPath.
24721
- const files = parseStashDiffFiles(stashDiffLines);
24722
- if (files.length > 0) {
24723
- let current = files[0];
24724
- for (const file of files) {
24725
- if (file.startLine <= state.diffPreviewOffset) {
24726
- current = file;
24727
- }
24728
- else {
24729
- break;
24730
- }
24731
- }
25127
+ const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
25128
+ if (current) {
24732
25129
  value = current.path;
24733
25130
  label = `path ${current.path}`;
24734
25131
  }
@@ -24782,7 +25179,7 @@ function LogInkApp(deps) {
24782
25179
  state.selectedTagIndex,
24783
25180
  state.selectedWorktreeFileIndex,
24784
25181
  state.tagSort,
24785
- visibleWorktreeFiles,
25182
+ visibleWorktreeFilesGrouped,
24786
25183
  ]);
24787
25184
  React.useEffect(() => {
24788
25185
  let active = true;
@@ -25022,28 +25419,14 @@ function LogInkApp(deps) {
25022
25419
  ? parseStashDiffFiles(stashDiffLines)
25023
25420
  : [];
25024
25421
  const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
25025
- const stashDiffSelectedPath = (() => {
25026
- if (state.diffSource !== 'stash' || stashDiffFiles.length === 0)
25027
- return undefined;
25028
- const offset = state.diffPreviewOffset;
25029
- // Walk backwards to the most recent file header at or before the
25030
- // current cursor offset.
25031
- let current = stashDiffFiles[0];
25032
- for (const file of stashDiffFiles) {
25033
- if (file.startLine <= offset) {
25034
- current = file;
25035
- }
25036
- else {
25037
- break;
25038
- }
25039
- }
25040
- return current.path;
25041
- })();
25422
+ const stashDiffSelectedPath = state.diffSource === 'stash'
25423
+ ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
25424
+ : undefined;
25042
25425
  getLogInkInputEvents(state, inputValue, key, {
25043
25426
  detailFileCount: detail?.files.length,
25044
25427
  previewLineCount: diffPreviewLineCount,
25045
25428
  worktreeDiffLineCount: worktreeDiff?.lines.length,
25046
- worktreeFileCount: visibleWorktreeFiles.length,
25429
+ worktreeFileCount: visibleWorktreeFilesGrouped.length,
25047
25430
  worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
25048
25431
  commitDiffHunkOffsets,
25049
25432
  branchCount: branchVisibleCount,
@@ -25053,7 +25436,13 @@ function LogInkApp(deps) {
25053
25436
  stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
25054
25437
  stashDiffSelectedPath,
25055
25438
  worktreeListCount: worktreeVisibleCount,
25056
- 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,
25057
25446
  commitDiffSelectedPath: state.diffSource === 'commit'
25058
25447
  ? selectedDetailFile?.path
25059
25448
  : undefined,
@@ -25199,6 +25588,11 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
25199
25588
  // Accordion layout — every tab's title is visible on its own line, but
25200
25589
  // only the active tab expands its content underneath. Switching tabs
25201
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;
25202
25596
  const tabBlocks = tabs.flatMap((tab, tabIndex) => {
25203
25597
  const isActive = tab === state.sidebarTab;
25204
25598
  const count = sidebarTabCount(tab, context);
@@ -25206,6 +25600,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
25206
25600
  ? `${sidebarTabLabel(tab)} (${count})`
25207
25601
  : sidebarTabLabel(tab);
25208
25602
  const headerText = isActive ? `[${labelWithCount}]` : labelWithCount;
25603
+ const headerSelected = isActive && headerFocused;
25209
25604
  const blocks = [];
25210
25605
  if (tabIndex > 0) {
25211
25606
  blocks.push(h(Text, { key: `tab-spacer-${tab}` }, ''));
@@ -25214,6 +25609,12 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
25214
25609
  key: `tab-header-${tab}`,
25215
25610
  bold: isActive,
25216
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,
25217
25618
  }, headerText));
25218
25619
  if (isActive) {
25219
25620
  blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
@@ -25251,7 +25652,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
25251
25652
  // ↑/↓ navigates within the sidebar list and Enter / per-entity keys
25252
25653
  // act on the cursored item without needing to drill into the
25253
25654
  // dedicated view (#791 follow-up — in-sidebar selection).
25254
- 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;
25255
25660
  if (tab === 'branches') {
25256
25661
  if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
25257
25662
  return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
@@ -25551,6 +25956,16 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
25551
25956
  backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
25552
25957
  }, truncate$1(label, 140));
25553
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
+ }
25554
25969
  function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
25555
25970
  const { Box, Text } = components;
25556
25971
  const focused = state.focus === 'commits';
@@ -25560,26 +25975,64 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
25560
25975
  // uses for j/k navigation. `visibleFiles` may be a strict subset of
25561
25976
  // worktree.files when the user has narrowed via 1/2/3.
25562
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);
25563
25983
  const listRows = Math.max(4, bodyRows - 5);
25564
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
+ })();
25565
26001
  const cleanHint = formatLogInkStatusEmpty({ hasChanges: Boolean(worktree?.files.length) });
25566
- 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)));
25567
26003
  const isLoading = isLogInkContextKeyLoading(contextStatus, 'worktree');
25568
- const fileRows = isLoading || !visibleFiles.length
26004
+ const renderedRows = isLoading || !surfaceRows.length
25569
26005
  ? []
25570
- : visibleFiles.slice(startIndex).slice(0, listRows).map((file, offset) => {
25571
- const index = startIndex + offset;
25572
- 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;
25573
26024
  const cursorPart = `${isSelected ? '>' : ' '} `;
25574
- const dotColor = getStageStatusDotColor(file.state, theme);
26025
+ const dotColor = getStageStatusDotColor(row.file.state, theme);
25575
26026
  const useDot = dotColor !== undefined;
25576
26027
  const dotCells = useDot ? cellWidth(STAGE_STATUS_DOT) + 1 : 0;
25577
- const tail = `${file.indexStatus}${file.worktreeStatus} ${file.state.padEnd(9)} ${file.path}`;
25578
- 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));
25579
26030
  return h(Text, {
25580
- key: `status-row-${index}`,
25581
- dimColor: offset > 0,
25582
- }, 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);
25583
26036
  });
25584
26037
  // When the mask narrows the list to nothing but the underlying repo
25585
26038
  // is non-clean, surface why the panel looks empty so the user can
@@ -25609,11 +26062,14 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
25609
26062
  // never touch the filter.
25610
26063
  ...(isStatusFilterMaskActive(state.statusFilterMask)
25611
26064
  ? [h(Text, { key: 'status-mask-indicator', dimColor: true }, `filter: ${formatStatusFilterMask(state.statusFilterMask)} (1/2/3 to toggle)`)]
25612
- : []), ...fileRows, ...fallbackLines.map((line, index) => h(Text, {
26065
+ : []), ...renderedRows, ...fallbackLines.map((line, index) => h(Text, {
25613
26066
  key: `status-surface-fallback-${index}`,
25614
26067
  dimColor: index > 0,
25615
26068
  }, truncate$1(line, 140))));
25616
26069
  }
26070
+ function capitalizeGroupName(value) {
26071
+ return value.charAt(0).toUpperCase() + value.slice(1);
26072
+ }
25617
26073
  function isStatusFilterMaskActive(mask) {
25618
26074
  return !mask.staged || !mask.unstaged || !mask.untracked;
25619
26075
  }
@@ -26063,20 +26519,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
26063
26519
  const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
26064
26520
  const stashFiles = parseStashDiffFiles(lines);
26065
26521
  const fileCount = stashFiles.length;
26066
- const currentFile = (() => {
26067
- if (fileCount === 0)
26068
- return undefined;
26069
- let current = stashFiles[0];
26070
- for (const file of stashFiles) {
26071
- if (file.startLine <= state.diffPreviewOffset) {
26072
- current = file;
26073
- }
26074
- else {
26075
- break;
26076
- }
26077
- }
26078
- return current;
26079
- })();
26522
+ const currentFile = findStashFileForOffset(stashFiles, state.diffPreviewOffset);
26080
26523
  const currentFileIndex = currentFile
26081
26524
  ? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
26082
26525
  : -1;
@@ -26103,14 +26546,41 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
26103
26546
  const headerLines = splitRequestedButTooNarrow
26104
26547
  ? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
26105
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;
26106
26556
  const stashBodyNodes = stashDiffLoading || !lines.length
26107
26557
  ? []
26108
26558
  : splitActive
26109
26559
  ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
26110
- : visibleLines.map((line, index) => h(Text, {
26111
- key: `stash-diff-line-${state.diffPreviewOffset + index}`,
26112
- ...diffLineProps(line, theme),
26113
- }, truncate$1(line, width - 4)));
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
+ });
26114
26584
  return h(Box, {
26115
26585
  borderColor: focusBorderColor(theme, focused),
26116
26586
  borderStyle: theme.borderStyle,
@@ -26293,7 +26763,10 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26293
26763
  }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
26294
26764
  key: `detail-${index}`,
26295
26765
  dimColor: index > 1,
26296
- }, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
26766
+ }, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
26767
+ cursorIndex: state.inspectorActionIndex,
26768
+ cursorActive: focused && state.inspectorTab === 'actions',
26769
+ }));
26297
26770
  }
26298
26771
  const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
26299
26772
  // P5.1 — link the commit hash and each ref out to GitHub when we know
@@ -26354,7 +26827,10 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26354
26827
  paddingX: 1,
26355
26828
  }, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
26356
26829
  ? [...headerNodes, ...fileListNodes]
26357
- : renderInspectorActionsSection(h, Text, 'history-commit', width, theme)));
26830
+ : renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
26831
+ cursorIndex: state.inspectorActionIndex,
26832
+ cursorActive: focused,
26833
+ })));
26358
26834
  }
26359
26835
  return h(Box, {
26360
26836
  borderColor: focusBorderColor(theme, focused),
@@ -26362,7 +26838,10 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26362
26838
  flexDirection: 'column',
26363
26839
  width,
26364
26840
  paddingX: 1,
26365
- }, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
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
+ }));
26366
26845
  }
26367
26846
  /**
26368
26847
  * Render the trailing "Actions:" section that surfaces which keystrokes
@@ -26375,7 +26854,7 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
26375
26854
  * minimum from `getLogInkLayout`) so an overflowing label never wraps and
26376
26855
  * collides with the next row.
26377
26856
  */
26378
- function renderInspectorActionsSection(h, Text, context, width, theme) {
26857
+ function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
26379
26858
  const actions = getInspectorActions(context);
26380
26859
  if (!actions.length)
26381
26860
  return [];
@@ -26386,10 +26865,13 @@ function renderInspectorActionsSection(h, Text, context, width, theme) {
26386
26865
  const GAP = ' ';
26387
26866
  const DESTRUCTIVE_SUFFIX = ' [!]';
26388
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;
26389
26870
  const nodes = [
26390
26871
  h(Text, { key: 'actions-spacer' }, ''),
26391
- h(Text, { key: 'actions-title' }, 'Actions:'),
26872
+ h(Text, { key: 'actions-title' }, cursorActive ? '[Actions]' : 'Actions:'),
26392
26873
  ...actions.map((action, index) => {
26874
+ const isSelected = cursorActive && index === cursorIndex;
26393
26875
  const keyCell = action.key.padEnd(KEY_COLUMN);
26394
26876
  const label = truncate$1(action.label, labelBudget);
26395
26877
  const children = [
@@ -26407,7 +26889,11 @@ function renderInspectorActionsSection(h, Text, context, width, theme) {
26407
26889
  dimColor: false,
26408
26890
  }, DESTRUCTIVE_SUFFIX));
26409
26891
  }
26410
- return h(Text, { key: `actions-${index}` }, ...children);
26892
+ return h(Text, {
26893
+ key: `actions-${index}`,
26894
+ backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
26895
+ inverse: isSelected,
26896
+ }, ...children);
26411
26897
  }),
26412
26898
  ];
26413
26899
  return nodes;